163 lines
9.8 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
}
|
|
}
|