375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import {
|
|
nexusTraitorFixture,
|
|
nexusTraitorInventory,
|
|
nexusTraitorBattle,
|
|
} from './fixture-nexus-traitors';
|
|
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
|
|
function fixture(
|
|
options: {
|
|
revealed?: number;
|
|
phase?: number;
|
|
opponentFaction?: 'atreides' | 'richese';
|
|
} = {},
|
|
): Game {
|
|
const g = nexusTraitorFixture({
|
|
ownerFaction: 'tleilaxu',
|
|
opponentFaction: options.opponentFaction,
|
|
phase: options.phase,
|
|
});
|
|
const cards = g.nexusCards!.cards!;
|
|
const index = cards.deck.indexOf('tleilaxu');
|
|
assert.ok(index >= 0);
|
|
assert.equal(cards.hands.p, 'harkonnen');
|
|
cards.deck[index] = 'harkonnen';
|
|
cards.hands.p = 'tleilaxu';
|
|
g.players[0].faceDancers!.forEach((card, i) => {
|
|
card.revealed = i < (options.revealed ?? 2);
|
|
});
|
|
nexusTraitorInventory(g);
|
|
return g;
|
|
}
|
|
function action(g: Game): Action {
|
|
const offer = viewGame(g, 'p').nexusTleilaxu!.cunning!;
|
|
assert.equal(offer.blocked, null);
|
|
return { type: 'nexusFaceDancers', event: offer.event };
|
|
}
|
|
function refresh(g: Game): Game {
|
|
return applyAction(g, 'p', action(g));
|
|
}
|
|
function hold(g: Game, owner: string, effect: string): string {
|
|
const source = g.richeseCache?.some((card) => card.effect === effect)
|
|
? g.richeseCache
|
|
: g.deck;
|
|
const index = source.findIndex((card) => card.effect === effect);
|
|
assert.ok(index >= 0);
|
|
const card = source.splice(index, 1)[0];
|
|
g.players.find((p) => p.id === owner)!.hand.push(card);
|
|
return card.id;
|
|
}
|
|
function reject(g: Game, id: string, attempted: Action): void {
|
|
const before = JSON.stringify(g);
|
|
assert.throws(() => applyAction(g, id, attempted));
|
|
assert.equal(JSON.stringify(g), before);
|
|
}
|
|
function inventory(g: Game): string[] {
|
|
return [
|
|
...g.deck,
|
|
...g.discard,
|
|
...(g.richeseCache ?? []),
|
|
...g.players.flatMap((p) => p.hand),
|
|
]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
}
|
|
|
|
/** The leader identity moves between existing physical zones before the battle;
|
|
* actual battle resolution and the ordinary reveal create the revealed dancer. */
|
|
function actuallyReveal(): Game {
|
|
let g = fixture({ revealed: 0, phase: 6 });
|
|
const owner = g.players[0],
|
|
winner = g.players[1],
|
|
loser = g.players[2];
|
|
const leader = winner.leaders.reduce((best, next) =>
|
|
next.strength > best.strength ? next : best,
|
|
);
|
|
const opposing = loser.leaders.reduce((best, next) =>
|
|
next.strength < best.strength ? next : best,
|
|
);
|
|
assert.ok(leader.strength > opposing.strength);
|
|
if (!owner.faceDancers!.some((c) => c.leader === leader.id)) {
|
|
const displaced = owner.faceDancers![0].leader;
|
|
const index = g.traitorReserve!.indexOf(leader.id);
|
|
if (index >= 0) g.traitorReserve![index] = displaced;
|
|
else {
|
|
const holder = g.players.find((p) => p.traitors.includes(leader.id));
|
|
assert.ok(holder);
|
|
holder.traitors[holder.traitors.indexOf(leader.id)] = displaced;
|
|
}
|
|
owner.faceDancers![0].leader = leader.id;
|
|
}
|
|
for (const p of g.players) {
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.tanks = 0;
|
|
}
|
|
for (const p of [winner, loser]) {
|
|
p.forces = { 'pasty_mesa:5': 3 };
|
|
p.reserves = 17;
|
|
}
|
|
g.active = 'q';
|
|
g.order = ['q', 'r', 'p'];
|
|
g = applyAction(g, 'q', {
|
|
type: 'chooseBattle',
|
|
territory: 'pasty_mesa',
|
|
target: 'r',
|
|
});
|
|
for (const id of ['q', 'r'])
|
|
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
|
|
g = applyAction(g, id, {
|
|
type: 'battlePreparationReady',
|
|
event: g.battle!.event,
|
|
});
|
|
while (g.battle!.preparation) {
|
|
const step = nextStrongholdFactionsNativeStep(g);
|
|
assert.ok(step);
|
|
g = applyAction(g, step.actor, step.action);
|
|
}
|
|
for (const [id, disc] of [
|
|
['q', leader.id],
|
|
['r', opposing.id],
|
|
])
|
|
g = applyAction(g, id, { type: 'battlePlan', leader: disc, dial: 0 });
|
|
for (const id of ['q', 'r'])
|
|
g = applyAction(g, id, { type: 'traitorCall', call: false });
|
|
assert.equal(g.decision?.kind, 'faceDance');
|
|
g = applyAction(g, 'p', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
sources: { reserves: 1 },
|
|
sector: 5,
|
|
});
|
|
assert.equal(g.players[0].faceDancers!.filter((c) => c.revealed).length, 1);
|
|
nexusTraitorInventory(g);
|
|
return g;
|
|
}
|
|
|
|
void test('a real winning-leader Face Dance supplies the revealed card for an atomic Nexus refresh', () => {
|
|
const original = actuallyReveal();
|
|
const replaced = original.players[0].faceDancers!.find(
|
|
(c) => c.revealed,
|
|
)!.leader;
|
|
const next = original.traitorReserve![0];
|
|
const g = refresh(original);
|
|
assert.deepEqual(g.nexusFaceDancerHistory![0].replaced, [replaced]);
|
|
assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, [next]);
|
|
assert.ok(g.traitorReserve!.includes(replaced));
|
|
assert.ok(
|
|
g.players[0].faceDancers!.some((c) => c.leader === next && !c.revealed),
|
|
);
|
|
assert.equal(
|
|
g.players[0].faceDancers!.some((c) => c.leader === replaced),
|
|
false,
|
|
);
|
|
assert.equal(g.nexusCards!.cards!.hands.p, null);
|
|
assert.equal(
|
|
g.nexusCards!.cards!.discard.filter((c) => c === 'tleilaxu').length,
|
|
1,
|
|
);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
|
nexusTraitorInventory(g);
|
|
});
|
|
|
|
void test('all revealed dancers refresh together off turn in multiple phases without a choice or acknowledgement', () => {
|
|
for (const phase of [2, 4, 5, 6, 8]) {
|
|
const original = fixture({ phase });
|
|
original.active = 'q';
|
|
const before = structuredClone(original.players[0].faceDancers!);
|
|
const drawn = original.traitorReserve!.slice(0, 2);
|
|
const g = refresh(original);
|
|
assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, drawn);
|
|
assert.deepEqual(g.players[0].faceDancers, [
|
|
before[2],
|
|
...drawn.map((leader) => ({ leader, revealed: false })),
|
|
]);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.active, 'q');
|
|
assert.equal(g.phase, phase);
|
|
nexusTraitorInventory(g);
|
|
}
|
|
});
|
|
|
|
void test('the shared-deck refresh preserves an actual native Prescience cancellation window', () => {
|
|
let g = fixture({ opponentFaction: 'atreides' });
|
|
hold(g, 'r', 'karama');
|
|
g = nexusTraitorBattle(g, false);
|
|
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
|
const before = nexusReload(g);
|
|
g = refresh(g);
|
|
assert.deepEqual(g.response, before.response);
|
|
assert.deepEqual(g.battle, before.battle);
|
|
g = nexusAllow(nexusReload(g));
|
|
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 });
|
|
assert.equal(g.battle!.prescience!.value, 0);
|
|
nexusTraitorInventory(g);
|
|
});
|
|
|
|
void test('refreshing during an actual paid Nullentropy choice preserves its fee, search and original discard pile', () => {
|
|
let g = fixture({ opponentFaction: 'richese' });
|
|
const box = hold(g, 'q', 'nullentropyBox');
|
|
for (const kind of ['shield', 'projectile']) {
|
|
const index = g.deck.findIndex((c) => c.kind === kind);
|
|
assert.ok(index >= 0);
|
|
g.discard.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
g.players[1].spice = 10;
|
|
g = applyAction(g, 'q', { type: 'card', card: box });
|
|
assert.equal(g.decision?.kind, 'nullentropy');
|
|
const before = nexusReload(g),
|
|
allCards = inventory(g);
|
|
g = refresh(nexusReload(g));
|
|
assert.deepEqual(g.pendingNullentropy, before.pendingNullentropy);
|
|
assert.deepEqual(g.decision, before.decision);
|
|
assert.deepEqual(g.discard, before.discard);
|
|
assert.equal(g.players[1].spice, 8);
|
|
const selected = g.discard[0].id;
|
|
g = applyAction(nexusReload(g), 'q', {
|
|
type: 'decision',
|
|
event: g.pendingNullentropy!.event,
|
|
card: selected,
|
|
});
|
|
assert.equal(g.players[1].spice, 8);
|
|
assert.ok(g.players[1].hand.some((c) => c.id === selected));
|
|
assert.deepEqual(inventory(g), allCards);
|
|
});
|
|
|
|
void test('active Truthtrance blocks refresh without spending the card or exposing a new dancer', () => {
|
|
let g = fixture();
|
|
const request = action(g),
|
|
truth = hold(g, 'r', 'truthtrance');
|
|
g = applyAction(g, 'r', { type: 'card', card: truth });
|
|
assert.match(
|
|
viewGame(g, 'p').nexusTleilaxu!.cunning!.blocked!,
|
|
/Truthtrance/,
|
|
);
|
|
reject(g, 'p', request);
|
|
assert.equal(g.nexusFaceDancerHistory, undefined);
|
|
assert.equal(g.nexusCards!.cards!.hands.p, 'tleilaxu');
|
|
});
|
|
|
|
void test('ordinary Mentat replacement and Nexus refresh keep their independent usage in either order', () => {
|
|
for (const normalFirst of [false, true]) {
|
|
let g = fixture({ revealed: 1 });
|
|
const normal = (state: Game) =>
|
|
nexusAllow(
|
|
applyAction(state, 'p', {
|
|
type: 'replaceFaceDancer',
|
|
leader: state.players[0].faceDancers!.find((c) => !c.revealed)!
|
|
.leader,
|
|
}),
|
|
);
|
|
if (normalFirst) g = normal(g);
|
|
const used = g.players[0].faceDancerReplacedTurn;
|
|
g = refresh(g);
|
|
assert.equal(g.players[0].faceDancerReplacedTurn, used);
|
|
if (!normalFirst) g = normal(g);
|
|
assert.equal(g.players[0].faceDancerReplacedTurn, g.turn);
|
|
reject(g, 'p', {
|
|
type: 'replaceFaceDancer',
|
|
leader: g.players[0].faceDancers![0].leader,
|
|
});
|
|
nexusTraitorInventory(g);
|
|
}
|
|
});
|
|
|
|
void test('private replacement identities and history never enter another seat projection', () => {
|
|
const initial = fixture(),
|
|
changed = nexusReload(initial);
|
|
[changed.traitorReserve![0], changed.traitorReserve![2]] = [
|
|
changed.traitorReserve![2],
|
|
changed.traitorReserve![0],
|
|
];
|
|
const one = refresh(initial),
|
|
two = refresh(changed);
|
|
assert.notDeepEqual(
|
|
viewGame(one, 'p').players[0].faceDancers,
|
|
viewGame(two, 'p').players[0].faceDancers,
|
|
);
|
|
for (const id of ['q', 'r']) {
|
|
const a = viewGame(one, id),
|
|
b = viewGame(two, id);
|
|
assert.deepEqual(a.players, b.players);
|
|
assert.deepEqual(a.nexusTleilaxu, b.nexusTleilaxu);
|
|
assert.deepEqual(
|
|
a.log.map((entry) => entry.text),
|
|
b.log.map((entry) => entry.text),
|
|
);
|
|
assert.equal(a.players[0].faceDancers, undefined);
|
|
assert.equal(Object.hasOwn(a, 'nexusFaceDancerHistory'), false);
|
|
assert.equal(Object.hasOwn(a, 'traitorReserve'), false);
|
|
}
|
|
});
|
|
|
|
void test('stale, foreign, subset, duplicate and zero-revealed requests reject without mutation', () => {
|
|
const g = fixture(),
|
|
request = action(g);
|
|
reject(g, 'p', { ...request, event: 'old-event' });
|
|
reject(g, 'q', request);
|
|
reject(g, 'p', { ...request, cards: [g.players[0].faceDancers![0].leader] });
|
|
const done = refresh(g);
|
|
reject(done, 'p', request);
|
|
const empty = fixture({ revealed: 0 });
|
|
assert.match(
|
|
viewGame(empty, 'p').nexusTleilaxu!.cunning!.blocked!,
|
|
/No revealed/,
|
|
);
|
|
reject(empty, 'p', {
|
|
type: 'nexusFaceDancers',
|
|
event: viewGame(empty, 'p').nexusTleilaxu!.cunning!.event,
|
|
});
|
|
});
|
|
|
|
void test('saved replacement receipts reject corrupted identity, source, ownership and duplicate history', () => {
|
|
const done = refresh(fixture());
|
|
for (const mutate of [
|
|
(g: Game) => {
|
|
g.nexusFaceDancerHistory![0].drawn[0] = 'invented';
|
|
},
|
|
(g: Game) => {
|
|
g.nexusFaceDancerHistory![0].source.reserve.reverse();
|
|
},
|
|
(g: Game) => {
|
|
g.nexusFaceDancerHistory![0].owner = 'q';
|
|
},
|
|
(g: Game) => {
|
|
g.nexusFaceDancerHistory![0].turn++;
|
|
},
|
|
(g: Game) => {
|
|
g.nexusFaceDancerHistory!.push(
|
|
structuredClone(g.nexusFaceDancerHistory![0]),
|
|
);
|
|
},
|
|
]) {
|
|
const changed = nexusReload(done);
|
|
mutate(changed);
|
|
const before = JSON.stringify(changed);
|
|
for (const p of changed.players)
|
|
assert.throws(() => viewGame(changed, p.id));
|
|
assert.throws(() => normalizeAutomaticGame(changed));
|
|
assert.throws(() => applyAction(changed, 'p', { type: 'advanceBots' }));
|
|
assert.equal(JSON.stringify(changed), before);
|
|
}
|
|
});
|
|
|
|
void test('all four profiles perform the real atomic refresh using only their projected offer', () => {
|
|
const initial = fixture();
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(initial, 'p');
|
|
view.players[0].bot = difficulty;
|
|
const before = structuredClone(view),
|
|
actions = botActions(view);
|
|
assert.deepEqual(view, before);
|
|
assert.equal(actions[0]?.type, 'nexusFaceDancers');
|
|
const g = applyAction(initial, 'p', actions[0]);
|
|
assert.equal(g.nexusFaceDancerHistory!.length, 1);
|
|
assert.equal(
|
|
g.players[0].faceDancers!.every((c) => !c.revealed),
|
|
true,
|
|
);
|
|
nexusTraitorInventory(g);
|
|
}
|
|
});
|