Files
Dune/tests/semuta-skill-aftermath.test.ts

214 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { ownedTech } from '../game/tech-tokens';
import {
allocateSemutaSkillAftermathLosses, assertSemutaSkillAftermathComponents,
claimSemutaSkillAftermath, createSemutaSkillAftermathFixture, finishSemutaSkillAftermath,
passSemutaSkillAftermath, reloadSemutaSkillAftermath, semutaSkillAftermathPlayer,
} from './fixture-semuta-skill-aftermath';
const profiles = [
{ name: 'Basic native', advanced: false, tech: false, strongholds: false },
{ name: 'Advanced native', advanced: true, tech: false, strongholds: false },
{ name: 'Advanced original Tech', advanced: true, tech: true, strongholds: false },
{ name: 'Advanced original Strongholds', advanced: true, tech: false, strongholds: true },
];
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
for (const profile of profiles) for (const claim of [false, true]) {
void test(`${profile.name}: mandatory loser ${claim ? 'claim' : 'pass'} restores exactly one original typed Suk rescue`, () => {
const f = createSemutaSkillAftermathFixture(profile);
let game = reloadSemutaSkillAftermath(f.game);
const original = semutaSkillAftermathPlayer(f.beforeBattle, f.owner);
const enemy = semutaSkillAftermathPlayer(f.beforeBattle, f.opponent);
const event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:mandatory');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.losingCard]);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.equal(game.pendingSukRescue!.losses, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, false);
assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 8, 'Winner casualties await the fresh loser discard reaction');
assert.equal(semutaSkillAftermathPlayer(game, f.owner).tanks, original.tanks);
assert.equal(semutaSkillAftermathPlayer(game, f.opponent).forces[f.key] ?? 0, 0);
assert.equal(semutaSkillAftermathPlayer(game, f.opponent).tanks, enemy.tanks + 8);
assert.equal(semutaSkillAftermathPlayer(game, f.opponent).leaders.find(l => l.id === f.losingLeader)!.dead, true);
if (profile.tech) assert.deepEqual(game.techTokens, f.beforeBattle.techTokens, 'Original reward remains after rescue/card cleanup');
reject(game, f.owner, { type: 'decision', event: game.pendingSukRescue!.event, choice: 0 });
game = claim ? claimSemutaSkillAftermath(game, f.holder, f.losingCard) : passSemutaSkillAftermath(game);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaSelect', event, card: f.losingCard });
game = allocateSemutaSkillAftermathLosses(reloadSemutaSkillAftermath(game));
assert.ok(game.decision?.kind === 'sukRescue');
assert.equal(game.decision.mode, 'skilled');
assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 8);
const rescue = game.decision;
const choice = rescue.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.kind === 'normal' && o.kept.key === f.key);
assert.ok(choice >= 0);
const rescueAction: Action = { type: 'decision', event: rescue.event, choice };
reject(game, f.opponent, rescueAction);
reject(game, f.owner, { ...rescueAction, event: `${rescue.event}-stale` });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, rescueAction);
const rescued = semutaSkillAftermathPlayer(game, f.owner);
assert.equal(rescued.forces[f.key], 5, 'Three ordinary board casualties removed; saved fourth remains');
if (profile.advanced) assert.equal(rescued.elites!.forces[f.key], 2, 'Neither elite counter was selected as a casualty');
assert.equal(rescued.reserves, original.reserves + 2);
assert.equal(rescued.tanks, original.tanks + 1);
if (profile.advanced) {
assert.equal(rescued.elites!.reserves, original.elites!.reserves);
assert.equal(rescued.elites!.tanks, original.elites!.tanks);
}
assert.equal(rescued.battleLosses, original.battleLosses + 4);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.ok(game.decision?.kind === 'battleCards');
assert.deepEqual([...game.decision.cards].sort(), [f.weapon, f.defense].sort());
reject(game, f.owner, rescueAction);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
assertSemutaSkillAftermathComponents(game, f.inventory);
// Original optional winner disposal happens AFTER the skill, using its own receipt.
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [f.weapon] });
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleCleanup');
assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:winner');
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
const cleanupEvent = game.pendingTreacheryDiscard!.batch.event;
assert.notEqual(cleanupEvent, event);
const physicalAfterRescue = structuredClone({ forces: rescued.forces, reserves: rescued.reserves, tanks: rescued.tanks, elites: rescued.elites });
game = finishSemutaSkillAftermath(reloadSemutaSkillAftermath(game));
const finished = semutaSkillAftermathPlayer(game, f.owner);
assert.deepEqual({ forces: finished.forces, reserves: finished.reserves, tanks: finished.tanks, elites: finished.elites }, physicalAfterRescue);
assert.ok(finished.hand.some(c => c.id === f.defense));
assert.equal(finished.hand.some(c => c.id === f.weapon), false);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim);
assert.equal(game.phase, 7);
if (profile.tech) {
const won = ownedTech(f.beforeBattle.techTokens, f.opponent)[0];
assert.ok(won);
assert.equal(game.techTokens![won].owner, f.owner);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
}
reject(game, f.owner, rescueAction);
reject(game, f.holder, { type: 'semutaCommit', event: cleanupEvent });
assertSemutaSkillAftermathComponents(game, f.inventory);
});
}
for (const profile of profiles) for (const claim of [false, true]) {
void test(`${profile.name}: loser ${claim ? 'claim' : 'pass'} restores Rihani ${claim ? 'exchange' : 'decline'} before compulsory winning Artillery disposal`, () => {
const f = createSemutaSkillAftermathFixture({ ...profile, skill: 'rihani-decipherer' });
let game = reloadSemutaSkillAftermath(f.game);
const ownerBefore = semutaSkillAftermathPlayer(f.beforeBattle, f.owner);
const event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.losingCard]);
assert.equal(game.lastBattleContext!.rihani!.completed, false);
assert.deepEqual(game.pendingWinnerDiscards!.cards, [f.weapon]);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).hand.some(c => c.id === f.weapon), 'Winning compulsory Artillery is not an invented pre-skill target');
assert.equal(game.rihaniHistory?.length ?? 0, 0);
assert.equal(game.discard.some(c => c.id === f.weapon), false);
const initialTraitors = [...semutaSkillAftermathPlayer(game, f.owner).traitors];
game = claim ? claimSemutaSkillAftermath(game, f.holder, f.losingCard) : passSemutaSkillAftermath(game);
game = allocateSemutaSkillAftermathLosses(reloadSemutaSkillAftermath(game));
assert.ok(game.decision?.kind === 'rihani' && game.decision.stage === 'offer');
assert.equal(game.pendingTreacheryDiscard, null, 'Traitor drawing is not a Treachery discard reaction');
assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 4);
if (profile.advanced) assert.equal(semutaSkillAftermathPlayer(game, f.owner).elites!.forces[f.key], 2);
assert.equal(semutaSkillAftermathPlayer(game, f.owner).tanks, ownerBefore.tanks + 4);
assert.equal(semutaSkillAftermathPlayer(game, f.owner).reserves, ownerBefore.reserves);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).leaders.find(l => l.id === f.trainer && !l.dead));
const rihaniEvent = game.decision.event;
const drawAction: Action = { type: 'decision', event: rihaniEvent, draw: claim };
reject(game, f.opponent, drawAction);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaSelect', event, card: f.weapon });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, drawAction);
let exchangeAction: Action | undefined;
let kept: string | undefined, given: string | undefined, unkept: string | undefined;
if (claim) {
assert.ok(game.decision?.kind === 'rihani' && game.decision.stage === 'return');
const receipt = game.rihaniHistory![0];
assert.equal(receipt.drawn.length, 2);
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, [...initialTraitors, ...receipt.drawn]);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingWinnerDiscards!.cards[0], f.weapon);
kept = receipt.drawn[0]; unkept = receipt.drawn[1]; given = receipt.eligible[0];
exchangeAction = { type: 'decision', event: rihaniEvent, cards: [kept, given] };
reject(game, f.owner, drawAction);
reject(game, f.owner, { type: 'decision', event: rihaniEvent, cards: [given, kept] });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, exchangeAction);
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, [...initialTraitors.filter(id => id !== given), kept]);
assert.ok(game.traitorReserve!.includes(given) && game.traitorReserve!.includes(unkept));
assert.equal(game.traitorReserve!.includes(kept), false);
} else {
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, initialTraitors);
assert.deepEqual(game.rihaniHistory![0].drawn, []);
assert.equal(game.rihaniHistory![0].given, null);
}
assert.equal(game.lastBattleContext!.rihani!.completed, true);
assert.equal(game.rihaniHistory!.length, 1);
assert.equal(game.rihaniHistory![0].stage, 'complete');
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:winnerMandatory');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.weapon]);
assert.ok(game.pendingTreacheryDiscard!.batch.entries.every(e => e.card.kind === 'artillery'), 'No Traitor or Face Dancer is a Treachery discard target');
const compulsoryEvent = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.owner, drawAction);
if (exchangeAction) reject(game, f.owner, exchangeAction);
game = passSemutaSkillAftermath(reloadSemutaSkillAftermath(game));
assert.ok(game.decision?.kind === 'battleCards');
assert.deepEqual(game.decision.cards, [f.defense]);
const cleanup = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [f.defense] });
assert.equal(cleanup.pendingTreacheryDiscard!.continuation.kind, 'battleCleanup');
game = finishSemutaSkillAftermath(reloadSemutaSkillAftermath(cleanup));
assert.equal(game.rihaniHistory!.length, 1);
assert.equal(game.phase, 7);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim);
if (claim) {
assert.equal(game.rihaniHistory![0].kept, kept);
assert.equal(game.rihaniHistory![0].given, given);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).traitors.includes(kept!));
assert.ok(game.traitorReserve!.includes(given!) && game.traitorReserve!.includes(unkept!));
}
reject(game, f.holder, { type: 'semutaCommit', event: compulsoryEvent });
reject(game, f.owner, drawAction);
assertSemutaSkillAftermathComponents(game, f.inventory);
});
}
void test('A late compulsory Artillery claim cannot precede or replay the original Rihani exchange', () => {
const f = createSemutaSkillAftermathFixture({ advanced: true, skill: 'rihani-decipherer' });
let game = allocateSemutaSkillAftermathLosses(passSemutaSkillAftermath(reloadSemutaSkillAftermath(f.game)));
assert.ok(game.decision?.kind === 'rihani');
const event = game.decision.event;
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', event, draw: true });
const receipt = game.rihaniHistory![0], kept = receipt.drawn[0], given = receipt.eligible[0];
const exchange: Action = { type: 'decision', event, cards: [kept, given] };
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, exchange);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
const compulsoryEvent = game.pendingTreacheryDiscard!.batch.event;
const skillReceipt = structuredClone(game.rihaniHistory), forces = structuredClone(semutaSkillAftermathPlayer(game, f.owner).forces);
game = claimSemutaSkillAftermath(reloadSemutaSkillAftermath(game), f.holder, f.weapon);
assert.ok(game.decision?.kind === 'battleCards');
assert.deepEqual(game.rihaniHistory, skillReceipt);
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).forces, forces);
assert.ok(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.weapon));
assert.ok(semutaSkillAftermathPlayer(game, f.owner).traitors.includes(kept));
reject(game, f.owner, exchange);
reject(game, f.holder, { type: 'semutaCommit', event: compulsoryEvent });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [] });
game = finishSemutaSkillAftermath(game);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).hand.some(c => c.id === f.defense));
assert.ok(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.weapon));
assert.deepEqual(game.rihaniHistory, skillReceipt);
assertSemutaSkillAftermathComponents(game, f.inventory);
// Projection at the completed JSON boundary exercises canonical skill receipts.
for (const player of game.players) viewGame(reloadSemutaSkillAftermath(game), player.id);
});