Files
Dune/tests/semuta-ix-substitution.test.ts
T

167 lines
9.7 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { semutaIxLoserDiscardFixture, semutaIxWinnerDiscardFixture,
semutaIxPlayer, semutaIxInventory, semutaIxPhysical, semutaIxPassDiscard,
semutaIxSubstitutionStep, semutaIxAllowResponses, type SemutaIxFixture } from './fixture-semuta-ix-substitution';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function assertCasualties(game: Game, f: SemutaIxFixture, substitute: boolean) {
const ix = semutaIxPlayer(game, f.winner), emperor = semutaIxPlayer(game, f.loser);
assert.deepEqual(ix.forces, { [f.location]: 2 });
assert.equal(ix.elites!.forces[f.location] ?? 0, Number(substitute));
assert.equal(ix.reserves, 17);
assert.equal(ix.elites!.reserves, 6);
assert.equal(ix.tanks, 1);
assert.equal(ix.elites!.tanks, substitute ? 0 : 1);
assert.equal(ix.battleLosses, 1, 'One casualty remains one casualty when its type is exchanged.');
assert.deepEqual(emperor.forces, {});
assert.equal(emperor.reserves, 15);
assert.equal(emperor.tanks, 5);
assert.equal(emperor.battleLosses, 5);
assert.equal(game.lastBattleContext!.winner, f.winner);
assert.equal(game.lastBattleContext!.result, 'normal');
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5);
}
assert.deepEqual(semutaIxInventory(game), f.cards);
}
function finishShieldCleanup(game: Game, f: SemutaIxFixture): Game {
assert.equal(game.decision?.kind, 'battleCards');
if (game.decision?.kind !== 'battleCards') throw new Error('Original winner cleanup was not restored.');
assert.equal(game.decision.player, f.winner);
assert.deepEqual(game.decision.cards, [f.shield]);
game = applyAction(game, f.winner, { type: 'decision', discard: [f.shield] });
if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.discard.filter(c => c.id === f.shield).length, 1);
assert.equal(game.phase, 7);
return game;
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`Semuta ${mode} reacts to actual loser discard before native Ix losses/substitution, then disposes Tooth once`, () => {
const f = semutaIxLoserDiscardFixture(advanced), pending = f.game;
// The former guard skipped this offer while the used winning Tooth was reserved.
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.equal(pending.pendingTreacheryDiscard!.batch.cause, 'battle:mandatory');
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.snooper]);
assert.deepEqual(pending.pendingWinnerDiscards!.cards, [f.tooth]);
assert.equal(pending.pendingIxSubstitution == null, true, 'Native substitution is staged only AFTER this reaction and winner losses.');
assert.equal(semutaIxPlayer(pending, f.winner).forces[f.location], 3);
assert.equal(semutaIxPlayer(pending, f.winner).tanks, 0);
assert.equal(pending.discard.filter(c => c.id === f.snooper).length, 1);
assert.equal(pending.discard.some(c => c.id === f.tooth), false);
const event = pending.pendingTreacheryDiscard!.batch.event;
const premature = semutaIxSubstitutionStep(f, true);
assert.throws(() => applyAction(reload(pending), premature.actor, premature.action));
assert.throws(() => applyAction(reload(pending), f.winner, { type: 'decision', decline: true }));
for (const claim of [false, true]) for (const substitute of [false, true]) {
let game = claim ? applyAction(reload(pending), f.holder, { type: 'semutaCommit', event })
: semutaIxPassDiscard(reload(pending));
assert.equal(game.decision?.kind, 'ixSubstitution');
assert.equal(game.decision?.player, f.winner);
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.location]: 1 });
assert.deepEqual(game.pendingIxSubstitution!.cards, [f.shield]);
assertCasualties(game, f, false);
assert.equal(semutaIxPlayer(game, f.winner).hand.some(c => c.id === f.tooth), true);
assert.equal(game.discard.some(c => c.id === f.tooth), false);
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.snooper), claim);
assert.throws(() => applyAction(reload(game), f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(reload(game), f.winner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }));
const step = semutaIxSubstitutionStep(f, substitute);
game = applyAction(reload(game), step.actor, step.action);
if (substitute) {
assert.equal(game.response?.kind, 'ixSubstitution');
assertCasualties(game, f, false);
game = semutaIxAllowResponses(reload(game));
}
assertCasualties(game, f, substitute);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.discard.filter(c => c.id === f.tooth).length, 1);
// Finish any later neutral Tooth opportunity before optional Shield cleanup.
if (game.pendingTreacheryDiscard) {
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'winnerMandatoryDiscard');
assert.deepEqual(game.pendingTreacheryDiscard.batch.entries.map(e => e.card.id), [f.tooth]);
assert.throws(() => applyAction(reload(game), step.actor, step.action));
if (claim) assert.throws(() => applyAction(reload(game), f.holder,
{ type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event }));
game = semutaIxPassDiscard(reload(game));
}
assert.throws(() => applyAction(reload(game), step.actor, step.action));
const physical = reload(game);
assert.throws(() => applyAction(game, f.holder, { type: 'semutaPass', event }));
assert.deepEqual(game, physical);
game = finishShieldCleanup(reload(game), f);
assertCasualties(game, f, substitute);
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.snooper), claim);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claim));
assert.throws(() => applyAction(reload(game), step.actor, step.action));
}
});
void test(`Semuta ${mode} claims or passes used Tooth only after original substitution/decline and resumes shield cleanup`, () => {
for (const substitute of [false, true]) {
const f = semutaIxWinnerDiscardFixture(advanced, substitute), pending = f.game;
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
assertCasualties(pending, f, substitute);
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const claim of [false, true]) {
let game = claim ? applyAction(reload(pending), f.holder, { type: 'semutaCommit', event })
: semutaIxPassDiscard(reload(pending));
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(pending), 'Semuta never replays support, bounty, losses or substitution.');
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.tooth), claim);
assert.equal(game.discard.filter(c => c.id === f.tooth).length, Number(!claim));
assert.equal(game.discard.filter(c => c.id === f.snooper).length, 1);
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
game = finishShieldCleanup(reload(game), f);
assertCasualties(game, f, substitute);
assert.equal(semutaIxPlayer(game, f.holder).hand.some(c => c.id === f.tooth), claim);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claim));
assert.throws(() => applyAction(reload(game), f.holder, { type: 'semutaCommit', event }));
const step = semutaIxSubstitutionStep(f, true);
assert.throws(() => applyAction(reload(game), step.actor, step.action));
}
}
});
void test(`Semuta ${mode} all four original bot profiles legally participate in fresh discard and typed Ix aftermath`, () => {
const f = semutaIxLoserDiscardFixture(advanced);
for (const profile of DIFFICULTIES) {
let game = reload(f.game);
for (const p of game.players) p.bot = profile;
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
let sawIx = false;
for (let step = 0; game.phase === 6 && step < 80; step++) {
if (game.decision?.kind === 'ixSubstitution') sawIx = true;
let next: Game | undefined;
for (const p of game.players) {
const action = botActions(viewGame(game, p.id))[0];
if (action) { next = applyAction(reload(game), p.id, action); break; }
}
assert.ok(next, `${profile} must offer a legal native continuation.`);
game = next;
}
assert.equal(sawIx, true);
assert.equal(game.phase, 7);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(semutaIxPlayer(game, f.winner).forces[f.location], 2);
assert.equal(semutaIxPlayer(game, f.winner).tanks, 1);
assert.equal(semutaIxPlayer(game, f.loser).tanks, 5);
assert.deepEqual(semutaIxInventory(game), f.cards);
}
});
}