Files
Dune/tests/semuta-homeworld-choam-returns.test.ts

163 lines
9.8 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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import {
createSemutaHomeworldChoamReturnsFixture, playSemutaHomeworldChoamReturns,
semutaHomeworldChoamReturnsForces, type SemutaHomeworldChoamReturnsFixture,
} from './fixture-semuta-homeworld-choam-returns';
const wallets = (game: Game) => game.players.map(player => ({ id: player.id, spice: player.spice }));
const counters = (game: Game, id: string) => {
const p = semutaEmperorPlayer(game, id);
return { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, revived: p.revived, freeForcesRevived: p.freeForcesRevived, battleLosses: p.battleLosses };
};
const homeForces = (game: Game) => viewGame(game, game.host).homeworlds!.worlds!
.map(home => ({ id: home.id, native: home.native, forces: home.forces }));
/** Shared observable contract: exactly one printed effect follows the recovery,
* and recovery changes card ownership only, not the source's native economics. */
function assertSettled(f: SemutaHomeworldChoamReturnsFixture, done: Game, claim: boolean) {
assert.equal(done.pendingTreacheryDiscard, null);
const holder = semutaEmperorPlayer(done, f.holder);
assert.equal(holder.hand.some(card => card.id === f.card), claim);
assert.equal(holder.hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(semutaEmperorPlayer(done, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(biddingPhysicalIds(done), f.originalCards);
assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.setup));
assert.deepEqual(wallets(done), wallets(f.game));
assert.equal(done.turn, f.game.turn);
assert.equal(done.phase, f.game.phase);
for (const player of done.players.filter(player => player.id !== f.target))
assert.deepEqual(counters(done, player.id), counters(f.game, player.id));
if (f.power === 'laLaLa') {
const before = viewGame(f.game, f.target).revival;
const after = viewGame(done, f.target).revival;
assert.ok(before.freeRemaining > 0, 'Original Atreides has a genuine free allowance');
assert.equal(before.freeBlocked, false);
assert.equal(after.freeRemaining, 0, 'The printed power removes the actual free-revival allowance');
assert.equal(after.freeBlocked, true);
assert.equal(after.limit, before.limit, 'Paid revival allowance is not canceled');
assert.equal(viewGame(done, f.owner).choamWorthless!.targets.includes(f.target), false);
for (const player of done.players.filter(player => player.id !== f.target))
assert.equal(viewGame(done, player.id).revival.freeRemaining, viewGame(f.game, player.id).revival.freeRemaining);
assert.deepEqual(counters(done, f.target), counters(f.game, f.target),
'The original Revival has no Tanks: proactive denial must not invent or revive a casualty');
assert.deepEqual(homeForces(done), homeForces(f.game));
} else {
const before = semutaEmperorPlayer(f.game, f.target), after = semutaEmperorPlayer(done, f.target);
assert.equal(after.forces[f.from], before.forces[f.from] - 1);
assert.equal(after.reserves, before.reserves + 1, 'One ORIGINAL board token returns to native reserves');
assert.equal(after.tanks, before.tanks, 'Trip is not a resurrection from Tanks');
assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assert.equal(after.battleLosses, before.battleLosses);
assert.deepEqual(after.elites, before.elites);
assert.deepEqual(Object.entries(after.forces).filter(([key]) => key !== f.from),
Object.entries(before.forces).filter(([key]) => key !== f.from));
const expectedHomes = structuredClone(homeForces(f.game));
const nativeHome = expectedHomes.find(home => home.native === f.target)!;
nativeHome.forces[f.target].normal++;
assert.deepEqual(homeForces(done), expectedHomes, 'Public Caladan custody gains exactly that one ordinary token');
assert.equal(done.decision?.kind, 'choamMentat');
assert.equal(done.decision!.player, f.owner, 'The original closing owner resumes before victory');
assert.equal(viewGame(done, f.target).mentatVictoryPending, true);
assert.equal(done.status, 'playing');
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
}
assert.throws(() => applyAction(done, f.owner, f.action), 'The original printed power cannot replay');
if (claim) assert.throws(() => applyAction(done, f.holder, f.action),
'Recovering a Worthless card does not grant the non-CHOAM holder its faction power');
}
function consumeOriginalSuffix(f: SemutaHomeworldChoamReturnsFixture, state: Game) {
const before = structuredClone(counters(state, f.target));
const homes = homeForces(state);
const next = f.power === 'gamont'
? applyAction(state, f.owner, { type: 'decision', done: true })
: advanceSemutaHomeworldChoam(state, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision);
assert.equal(next.turn, f.game.turn + Number(f.power === 'gamont'));
assert.equal(next.phase, f.power === 'gamont' ? 0 : 5);
assert.equal(next.status, 'playing');
assert.deepEqual(counters(next, f.target), before, 'The real next stage must not run the power a second time');
assert.deepEqual(homeForces(next), homes);
assert.deepEqual(biddingPhysicalIds(next), f.originalCards);
if (f.power === 'gamont') {
assert.equal(next.choamMentatPending, false);
assert.throws(() => applyAction(next, f.owner, { type: 'decision', done: true }),
'The original closing decision is consumed once');
}
}
for (const advanced of [false, true]) {
for (const power of ['laLaLa', 'gamont'] as const) {
for (const claim of [false, true]) void test(
`Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power}: Semuta ${claim ? 'claims' : 'passes'} before the original effect`, () => {
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
const pending = playSemutaHomeworldChoamReturns(f);
const cost = pending.pendingTreacheryDiscard;
assert.ok(cost, 'The actual printed disposal pauses before its native effect');
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(counters(pending, f.target), counters(f.game, f.target));
assert.deepEqual(homeForces(pending), homeForces(f.game));
assert.deepEqual(wallets(pending), wallets(f.game));
assert.equal(viewGame(pending, f.target).revival.freeRemaining, viewGame(f.game, f.target).revival.freeRemaining);
assert.throws(() => applyAction(pending, f.owner, f.action));
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }),
'Closing/Revival cannot skip the actual recovery window');
assert.throws(() => applyAction(pending, f.target, { type: 'ready' }));
assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }));
const done = reactSemutaHomeworldChoam(pending, f.holder, claim);
assertSettled(f, done, claim);
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
assert.throws(() => applyAction(done, f.target, { type: 'semutaPass', event: cost.batch.event }));
consumeOriginalSuffix(f, done);
},
);
for (const difficulty of DIFFICULTIES) void test(
`${difficulty} legally completes Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power} recovery and original controls`, () => {
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
let game = playSemutaHomeworldChoamReturns(f);
const event = game.pendingTreacheryDiscard!.batch.event;
const view = viewGame(game, f.holder);
view.players.find(player => player.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
game = applyAction(game, f.holder, action);
for (const player of game.players) {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer'
&& !game.pendingTreacheryDiscard.reaction.passed.includes(player.id))
game = applyAction(game, player.id, { type: 'semutaPass', event });
}
const claimed = semutaEmperorPlayer(game, f.holder).hand.some(card => card.id === f.card);
assertSettled(f, game, claimed);
if (power === 'gamont') {
const closingView = viewGame(game, f.owner);
closingView.players.find(player => player.id === f.owner)!.bot = difficulty;
const close = botActions(closingView)[0];
assert.ok(close);
const after = applyAction(game, f.owner, close);
assert.equal(after.turn, game.turn + 1);
assert.equal(after.phase, 0);
assert.equal(after.choamMentatPending, false);
assert.deepEqual(counters(after, f.target), counters(game, f.target));
assert.deepEqual(homeForces(after), homeForces(game));
assert.deepEqual(biddingPhysicalIds(after), f.originalCards);
} else consumeOriginalSuffix(f, game);
},
);
}
}