Files
Dune/tests/semuta-sapho-station.test.ts
T
Quintine 98ead3a4b5
Verify and publish checkpoint container / container (push) Canceled after 0s
Enable Semuta in public Discovery games
2026-10-29 09:31:40 +11:00

114 lines
6.8 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeSemutaGameForAudit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm';
import { discoveryStormActions } from '../game/discovery-storm-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaShrineLobby } from './fixture-semuta-shrine';
import { advanceTestingStationContest } from './fixture-testing-station-contest';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
const SAPHO = 'richese-juice-of-sapho';
const STATION = 'ecological-testing-station';
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
const seat = (game: Game, id: string) => {
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
};
const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
/** Genuine opt-in Richese/Discovery setup to first Storm. The original physical
* station and two counters are staged at a controlled pending storm boundary;
* actual unique Sapho and Semuta move from their original card zones. This is
* not a natural Discovery reveal or full-game claim. */
function fixture(advanced: boolean, mode: 'first' | 'last') {
let game = initializeSemutaGameForAudit(semutaShrineLobby(advanced));
game = advanceTestingStationContest(game, state => state.status === 'playing');
assert.equal(game.phase, 0); assert.equal(game.semutaPreview, true);
const inventory = cards(game);
assert.equal(inventory.filter(id => id === SAPHO).length, 1);
assert.equal(inventory.filter(id => id === SEMUTA_DRUG_ID).length, 1);
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const token = game.discoveries!.tokens.find(row => row.face === STATION);
assert.ok(token && token.status === 'supply');
const placement = Object.values(DISCOVERY_CARD_PLACEMENTS).find(row => row.type === token.type);
assert.ok(placement);
Object.assign(token, {status:'placed', territory:placement.territory, sector:placement.sector, revealedTurn:game.turn});
validateDiscoveryState(game.discoveries!);
for (const id of ['e', 'g']) {
const player = seat(game, id); assert.ok(player.reserves > 0);
player.reserves--; player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1;
}
const order = game.order.filter(id => id === 'e' || id === 'g');
assert.equal(order.length, 2);
const owner = order[mode === 'first' ? 1 : 0];
moveSemutaEmperorCard(game, SAPHO, owner);
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
assert.deepEqual(cards(game), inventory);
game.stormPending = 3;
game.stormMovementSource = createStormSource(game.turn, 'dials', 3);
game.ready = game.players.map(player => player.id);
game.phaseOpening = null; game.stormResolution = null;
game.ecologicalStorm = createDiscoveryStorm(game, 'semuta-station-original');
game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event};
return {game, owner, order, inventory};
}
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test(
`${advanced ? 'Advanced' : 'Basic'} ${mode} Sapho station choice resumes after Semuta claim or pass`, () => {
const f = fixture(advanced, mode);
const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === mode);
assert.ok(option, 'The physical Sapho is eligible for the original station queue');
const action = {type:'card', card:SAPHO, scope:option.scope, event:option.event, mode:option.mode};
const pending = applyAction(copy(f.game), f.owner, action);
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'sapho:station');
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoStationDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.ecologicalStorm?.queue?.order, [...f.order].reverse());
assert.equal(pending.ecologicalStorm?.queue?.choices.length, 0);
assert.equal(pending.stormPending, 3); assert.equal(pending.storm, f.game.storm);
assert.equal(pending.decision, null, 'The original station choice waits behind the reaction');
const pendingEvent = pending.pendingTreacheryDiscard!.batch.event;
assert.throws(() => applyAction(copy(pending), f.owner, action));
for (const claim of [false, true]) {
let resumed = reactSemutaSpecial(copy(pending), 'r', claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.decision?.kind, 'ecologicalStorm');
assert.equal(resumed.decision?.player, resumed.ecologicalStorm?.owner);
assert.equal(resumed.decision?.event, resumed.ecologicalStorm?.event);
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SAPHO), claim);
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.throws(() => applyAction(copy(resumed), 'r', {type:'semutaCommit', event:pendingEvent}));
for (const delta of [1, -1]) {
const owner = resumed.ecologicalStorm!.owner;
const choice = discoveryStormActions(viewGame(resumed, owner)).find(row => row.delta === delta);
assert.ok(choice);
resumed = applyAction(resumed, owner, choice);
}
assert.equal(resumed.ecologicalStorm?.stage, 'complete');
assert.equal(discoveryStormDistance(resumed.ecologicalStorm!), 3);
assert.equal(resumed.phase, 1);
assert.deepEqual(cards(resumed), f.inventory);
}
},
);
void test('all four bot profiles can legally respond to the parked Sapho station discard', () => {
const f = fixture(false, 'first');
const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first');
assert.ok(option);
const pending = applyAction(f.game, f.owner, {type:'card', card:SAPHO,
scope:option.scope, event:option.event, mode:option.mode});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'r');
view.players.find(player => player.id === 'r')!.bot = difficulty;
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
assert.ok(action, `${difficulty} must offer a legal Semuta response`);
assert.doesNotThrow(() => applyAction(copy(pending), 'r', action));
}
});