Files
Dune/tests/fixture-semuta-tupile.ts

175 lines
8.7 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeHomeworldOccupationGameForAudit,
initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer as semutaTupilePlayer,
} from './fixture-semuta-emperor-share';
export const SEMUTA_TUPILE_WORLD = 'homeworld:choam';
export type SemutaTupileStep = { actor: string; action: Action };
export type SemutaTupileFixture = {
game: Game;
setup: Game;
occupied: Game;
beforeDeparture: Game;
owner: string;
holder: string;
occupier: string;
ally: string;
event: string;
action: Action;
selected: string[];
semuta: string;
sourceActions: SemutaTupileStep[];
actions: SemutaTupileStep[];
cards: string[];
};
export function semutaTupileCards(game: Game): string[] {
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
}
/** Public physical totals include visitors, which are not native reserves. */
export function semutaTupileForceTotals(game: Game) {
return game.players.map(player => ({ id: player.id, total: player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0) +
Object.values(game.homeworlds!.custody!.visitors).reduce((sum, visitors) =>
sum + (visitors[player.id]?.normal ?? 0) + (visitors[player.id]?.elite ?? 0), 0) }));
}
export function nextSemutaTupileStep(game: Game): SemutaTupileStep {
const pending = game.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (reaction?.stage === 'offer' && pending) return {
actor: game.players.find(player => !reaction.passed.includes(player.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event },
};
if (game.decision?.kind === 'homeworldShipmentGuild') return {
actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, allow: true },
};
return nextRicheseSkillsTechNativeStep(game);
}
export function semutaTupileCleanupStep(game: Game): SemutaTupileStep {
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
const actor = game.decision!.player;
const cleanup = viewGame(game, actor).homeworldTupileCleanup!;
assert.ok(cleanup && !cleanup.blocked);
return { actor, action: { type: 'decision', event: cleanup.event,
cards: cleanup.eligibleCards.slice(0, cleanup.excess) } };
}
export function reactSemutaTupile(state: Game, holder: string, claim: boolean): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
let game = state;
for (const player of state.players)
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
/** Original setup and phase actions establish the alliance, occupation epoch,
* lease and departure. Later conserved physical positions isolate the producer;
* they never assign its clocks, alliances, wallets, visitor custody or history. */
export function createSemutaTupileFixture(options: { nativeOnly?: boolean } = {}): SemutaTupileFixture {
let game = createGame('SEMUTATUPILE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = options.nativeOnly
? initializeHomeworldOccupationGameForAudit(game)
: initializeSemutaGameForAudit(game);
const actions: SemutaTupileStep[] = [], sourceActions: SemutaTupileStep[] = [];
const act = (step: SemutaTupileStep, source = false) => {
actions.push(step);
if (source) sourceActions.push(step);
game = applyAction(game, step.actor, step.action);
};
const clean = () => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
const reach = (until: () => boolean) => {
for (let n = 0; !until() && n < 2400; n++) {
assert.notEqual(game.status, 'finished', 'Original native actions must reach Tupile');
act(nextSemutaTupileStep(game));
}
assert.ok(until(), 'Original native actions must reach the requested Tupile boundary');
};
const orderUnplayedSpice = (kinds: ('land' | 'worm')[]) => {
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
(kind === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(index >= position, 'An original unplayed Spice Card must remain');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
};
reach(() => game.status === 'playing');
const setup = structuredClone(game), cards = semutaTupileCards(game);
orderUnplayedSpice(['land', 'land']);
reach(() => game.phase === 5 && clean());
// Conserved later positions: remove all Arrakis encounters without changing
// native Homeworld custody, phase controls or original twenty-counter stocks.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
}
reach(() => game.turn === 2 && game.phase === 1 && clean());
orderUnplayedSpice(['worm', 'land', 'land']);
reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution && clean());
act({ actor: 'g', action: { type: 'alliance', target: 'a' } }, true);
act({ actor: 'a', action: { type: 'alliance', target: 'g' } }, true);
reach(() => game.phase === 5 && game.active === 'g' && clean());
// Conserved later CHOAM position: its original native counters are on Arrakis,
// not imaginary visitors. The ORIGINAL invasion must qualify the sole occupier.
const choam = semutaTupilePlayer(game, 'c');
choam.forces['polar_sink:0'] = (choam.forces['polar_sink:0'] ?? 0) + choam.reserves;
choam.reserves = 0;
const invade = homeworldShipmentChoice(viewGame(game, 'g'), SEMUTA_TUPILE_WORLD,
{ 'homeworld:guild': { normal: 1, elite: 0 } });
assert.ok(invade.action, invade.blocked ?? undefined);
act({ actor: 'g', action: invade.action }, true);
reach(() => clean());
assert.equal(semutaTupilePlayer(game, 'g').tupileHandSlot, true);
assert.equal(semutaTupilePlayer(game, 'a').tupileHandSlot, true);
const occupied = structuredClone(game);
// No further worm is needed: the reciprocal alliance remains native.
orderUnplayedSpice(['land', 'land', 'land', 'land']);
reach(() => game.turn === 3 && game.phase === 5 && game.active === 'g' && clean());
// Conserved original physical cards only, staged after all native auctions.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
for (const [id, size] of [['g', 5], ['a', 5], ['r', 4]] as const) {
const player = semutaTupilePlayer(game, id);
while (player.hand.length < size) {
const card = semutaEmperorCardId(game, candidate => game.deck.some(held => held.id === candidate.id) &&
candidate.id !== SEMUTA_DRUG_ID && candidate.effect !== 'karama');
moveSemutaEmperorCard(game, card, id);
}
assert.equal(player.hand.length, handLimit(player));
}
const beforeDeparture = structuredClone(game);
const depart = homeworldShipmentChoice(viewGame(game, 'g'), 'homeworld:guild',
{ [SEMUTA_TUPILE_WORLD]: { normal: 1, elite: 0 } });
assert.ok(depart.action, depart.blocked ?? undefined);
act({ actor: 'g', action: depart.action }, true);
reach(() => game.decision?.kind === 'homeworldTupileCleanup');
assert.equal(game.homeworldTupileState!.current, null);
assert.equal(game.homeworldTupileState!.cleanups.filter(row => row.status === 'pending').length, 2);
const cleanup = semutaTupileCleanupStep(game);
const selected = cleanup.action.cards as string[];
assert.equal(selected.length, 1, 'Each ordinary granted-five holder owes its own original one-card cleanup');
assert.deepEqual(semutaTupileCards(game), cards);
assert.ok(semutaTupileForceTotals(game).every(row => row.total === 20));
return { game, setup, occupied, beforeDeparture, owner: cleanup.actor, holder: 'r',
occupier: 'g', ally: 'a', event: cleanup.action.event as string, action: cleanup.action,
selected, semuta, sourceActions, actions, cards };
}