168 lines
9.9 KiB
TypeScript
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);
|
|
}
|
|
});
|