Files
Dune/tests/nexus-face-dancer-engine.test.ts
T

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);
}
});