Files
Dune/tests/emperor-face-dance-return-engine.test.ts

168 lines
9.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actEmperorFaceDanceReturn as act, commitEmperorFaceDanceReturn as commit,
createEmperorFaceDanceReturnFixture as fixture, finishEmperorFaceDanceReturn as finish,
emperorFaceDanceReturnHomes as homes, emperorFaceDanceReturnInventory as inventory,
emperorFaceDanceReturnPhysical as physical, emperorFaceDanceReturnPlayer as player,
type EmperorFaceDanceReturnFixture,
} from './fixture-emperor-face-dance-return';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function revival(game: Game) {
return { freeRevival: game.freeRevival, emperorExtra: game.emperorExtra,
players: game.players.map(seat => ({ id: seat.id, revived: seat.revived,
eliteRevived: seat.elites?.revived, freeForcesRevived: seat.freeForcesRevived,
leaderRevived: seat.leaderRevived, revivalCycle: seat.revivalCycle })) };
}
function source(f: EmperorFaceDanceReturnFixture) {
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, true);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 6);
assert.deepEqual(f.game.expansions, ['ix']);
assert.deepEqual(f.game.players.map(seat => seat.faction), ['emperor', 'tleilaxu', 'atreides']);
assert.ok(f.game.homeworlds?.custody);
assert.ok(!f.game.nexusCards && !f.game.leaderSkills && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(f.game.decision?.kind === 'faceDance');
assert.equal(f.game.decision.player, f.tleilaxu);
assert.equal(f.game.decision.winner, f.emperor);
assert.equal(f.game.decision.identity, f.winnerLeader);
assert.equal(f.game.response ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
const offer = viewGame(f.game, f.tleilaxu).decision;
assert.ok(offer?.kind === 'faceDance');
assert.equal(offer.blocked ?? null, null,
'The original Emperor opportunity must project as available after typed return integration');
assert.equal(f.shipmentStep.actor, f.emperor);
assert.equal(f.shipmentStep.action.amount, f.amount);
assert.equal(f.shipmentStep.action.elite, f.elite);
assert.deepEqual(f.shipmentStep.action.homeworldSources, {
'homeworld:emperor': { normal: f.normal, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: f.elite },
});
assert.equal(player(f.afterShipment, f.emperor).forces[f.location], f.amount);
assert.equal(player(f.afterShipment, f.emperor).elites!.forces[f.location] ?? 0, f.elite);
assert.deepEqual(homes(f.game)['homeworld:emperor'], { normal: 15 - f.normal, elite: 0 });
assert.deepEqual(homes(f.game)['homeworld:emperor:salusa'], { normal: 0, elite: 5 - f.elite });
assert.equal(player(f.game, f.emperor).forces[f.location], f.survivorCount);
assert.equal(player(f.game, f.emperor).tanks, f.dial);
assert.equal(player(f.game, f.emperor).elites!.tanks, 0);
assert.equal(player(f.game, f.opponent).tanks, 10);
assert.equal(player(f.game, f.opponent).forces[f.location] ?? 0, 0);
assert.equal(f.game.lastBattleContext!.winner, f.emperor);
assert.equal(f.game.lastBattleContext!.result, 'normal');
assert.equal(f.game.lastBattleContext!.event, f.event);
assert.deepEqual([...f.faceDancerDeal.order].sort(), [...f.faceDancerDeal.original].sort());
assert.equal(f.faceDancerDeal.scalars.length, f.faceDancerDeal.original.length - 1);
assert.equal(f.faceDancerDeal.order[0], f.winnerLeader);
assert.ok(player(f.afterSetup, f.tleilaxu).faceDancers!.some(card => card.leader === f.winnerLeader && !card.revealed));
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function returned(f: EmperorFaceDanceReturnFixture, game: Game, replacement: number) {
assert.equal(player(game, f.emperor).forces[f.location] ?? 0, 0);
assert.equal(player(game, f.emperor).elites!.forces[f.location] ?? 0, 0);
assert.equal(player(game, f.emperor).reserves, player(f.game, f.emperor).reserves + f.survivorCount);
assert.equal(player(game, f.emperor).elites!.reserves, player(f.game, f.emperor).elites!.reserves + f.survivors.elite);
assert.deepEqual(homes(game)['homeworld:emperor'], { normal: 15 - f.dial, elite: 0 },
'Only the exact live normal counters return to Kaitain');
assert.deepEqual(homes(game)['homeworld:emperor:salusa'], { normal: 0, elite: 5 },
'Only the exact live Sardaukar return to Salusa');
assert.equal(player(game, f.emperor).tanks, f.dial);
assert.equal(player(game, f.emperor).elites!.tanks, 0);
assert.equal(player(game, f.emperor).battleLosses, player(f.game, f.emperor).battleLosses);
assert.equal(player(game, f.tleilaxu).forces[f.location] ?? 0, replacement);
assert.equal(player(game, f.tleilaxu).reserves, player(f.game, f.tleilaxu).reserves - replacement);
assert.deepEqual(homes(game)['homeworld:tleilaxu'], { normal: 20 - replacement, elite: 0 },
'Replacement withdraws the same physical native reserve counters');
const leader = player(game, f.emperor).leaders.find(candidate => candidate.id === f.winnerLeader)!;
assert.equal(leader.dead, true);
assert.equal(leader.deaths, player(f.game, f.emperor).leaders.find(candidate => candidate.id === f.winnerLeader)!.deaths + 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.filter(card => card.revealed).length, 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
assert.equal(game.pendingFaceDance ?? null, null);
assert.notEqual(game.decision?.kind, 'faceDance');
assert.deepEqual(game.lastBattleContext, f.originalContext,
'Face Dance preserves the completed original victory instead of resolving a second battle');
assert.deepEqual(inventory(game), f.originalInventory);
}
void test('public Advanced Emperor shipment, original Face Dancer deal and real normal casualty earn an unblocked offer', () => {
const f = fixture(); source(f);
assert.deepEqual(f.survivors, { normal: 2, elite: 2 });
assert.deepEqual(physical(f.game), f.originalPhysical);
});
for (const army of ['mixed', 'normal', 'elite'] as const) for (const zero of [false, true]) {
void test(`native Advanced Emperor ${army} survivors return to exact Homeworlds with ${zero ? 'zero' : 'positive'} replacement`, () => {
const f = fixture({ army }); source(f);
const before = structuredClone(f.game), amount = zero ? 0 : f.survivorCount;
let game = commit(f, f.game, zero ? f.zero : f.action);
assert.deepEqual(f.game, before, 'Submitting the original action does not mutate its input');
returned(f, game, amount);
assert.deepEqual(game.players.map(seat => seat.spice), before.players.map(seat => seat.spice),
'The return grants no bounty, income, revival or transport charge');
assert.deepEqual(revival(game), revival(before), 'Returning live survivors consumes no revival allowance');
const counters = physical(game);
rejected(game, f.tleilaxu, f.action);
game = finish(game);
assert.equal(game.phase, 7); returned(f, game, amount);
assert.deepEqual(physical(game), counters);
assert.deepEqual(revival(game), revival(before));
});
}
void test('declining the original Advanced Emperor Face Dance preserves the typed army, native pools and living leader', () => {
const f = fixture(); source(f);
const before = physical(f.game), wallets = f.game.players.map(seat => seat.spice);
let game = commit(f, f.game, f.decline);
assert.deepEqual(physical(game), before);
assert.deepEqual(game.players.map(seat => seat.spice), wallets);
assert.equal(player(game, f.emperor).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
assert.equal(game.pendingFaceDance ?? null, null);
game = finish(game);
assert.equal(game.phase, 7); assert.deepEqual(physical(game), before);
assert.deepEqual(game.lastBattleContext, f.originalContext);
assert.deepEqual(inventory(game), f.originalInventory);
rejected(game, f.tleilaxu, f.action);
});
void test('native Emperor Face Dance rejects foreign, stale and excessive replacement sources before its outcome', () => {
const f = fixture(); source(f);
rejected(f.game, f.emperor, f.action);
rejected(f.game, f.opponent, f.action);
rejected(f.game, f.tleilaxu, { ...f.action, sources: { 'homeworld:emperor': 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { [f.location]: 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { reserves: f.survivorCount + 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { reserves: player(f.game, f.tleilaxu).reserves + 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { reserves: -1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sector: 9 });
assert.deepEqual(physical(f.game), f.originalPhysical);
assert.deepEqual(inventory(f.game), f.originalInventory);
const committed = commit(f);
rejected(committed, f.tleilaxu, f.action);
returned(f, committed, f.survivorCount);
});
void test('all four minimal profiles use the real unblocked Emperor offer and exact native replacement source', () => {
const f = fixture(); source(f);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.tleilaxu);
view.players.find(seat => seat.id === f.tleilaxu)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, f.action, `${difficulty} submits the original projected Face Dance action`);
let game = act(f.game, f.tleilaxu, action);
returned(f, game, f.survivorCount);
game = finish(game);
assert.equal(game.phase, 7); returned(f, game, f.survivorCount);
}
});