Files
Dune/tests/fixture-sapho-started-movement.ts
T

221 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
GUILD_FIRST_SAPHO, actSaphoGuildFirst, nextSaphoGuildFirstStep,
type SaphoGuildFirstStep,
} from './fixture-sapho-guild-first';
import { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
export const STARTED_MOVEMENT_SAPHO = GUILD_FIRST_SAPHO;
export type SaphoStartedMovementStep = SaphoGuildFirstStep;
export const actSaphoStartedMovement = actSaphoGuildFirst;
export const nextSaphoStartedMovementStep = nextSaphoGuildFirstStep;
export const saphoStartedMovementPlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const saphoStartedMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment &&
!game.pendingKarama && !game.currentAuctionSale;
/** Count physical zones once, excluding historical ordinary-auction aliases. */
export function saphoStartedMovementInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length); return ids;
}
export function advanceSaphoStartedMovement(game: Game, until: (state: Game) => boolean,
actions: SaphoStartedMovementStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSaphoStartedMovementStep(game); assert.ok(step, 'Complete the original owned control first');
game = actSaphoStartedMovement(game, step.actor, step.action, actions);
}
throw Error('The native Sapho programme did not reach its finite boundary');
}
/** Original public Basic or Advanced setup, actual cache1 purchase and positive
* shipment. Only unopened original deal/Spice ordering and original scalar
* lotteries are selected; no played custody, resources, phase or queue is set. */
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean;
truthFor?: readonly ('richese' | 'guild')[]; ownerBeforeAtreides?: boolean } = {}) {
const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false;
assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule');
const owner = 'emperor', guild = 'guild', canceler = 'atreides';
let game = createGame('SAPHOSTARTEDMOVEMENT', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SaphoStartedMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoStartedMovement(game, until, actions); };
if (options.ownerBeforeAtreides) {
act('atreides', { type: 'seatPosition', position: 5 });
act(owner, { type: 'seatPosition', position: 2 });
act('atreides', { type: 'seatPosition', position: 3 });
}
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = saphoStartedMovementInventory(game); assert.equal(originalInventory.length, 45);
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const counter = game.deck[index].id;
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
const truthFor = options.truthFor ?? [];
assert.ok(truthFor.length <= 2 && new Set(truthFor).size === truthFor.length,
'Select at most the two original unopened Truthtrance cards');
const startingTruthSelection = truthFor.map(actor => {
const position = game.players.findIndex(player => player.id === actor);
const from = game.deck.findIndex((card, index) => index >= position && card.effect === 'truthtrance');
assert.ok(position >= 0 && from >= position, 'The selected original Truth must remain in the unopened starting deal');
const card = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { actor, position, from, card };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
for (const row of startingTruthSelection)
assert.ok(saphoStartedMovementPlayer(game, row.actor).hand.some(card => card.id === row.card));
const spiceInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), spiceInventory);
let beforeBid: Game | undefined, bidStep: SaphoStartedMovementStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !saphoStartedMovementPlayer(game, owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO); count++) {
const step = nextSaphoStartedMovementStep(game); assert.ok(step);
if (step.actor === owner && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bidStep = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bidStep && bidAt >= 0);
assert.ok(saphoStartedMovementPlayer(game, owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO));
const acquired = structuredClone(game), acquisition = {
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
};
assert.equal(saphoStartedMovementPlayer(beforeBid, owner).spice - saphoStartedMovementPlayer(game, owner).spice, 1);
assert.equal(saphoStartedMovementPlayer(game, 'richese').spice - saphoStartedMovementPlayer(beforeBid, 'richese').spice, 1);
const timing: { before: Game; step: SaphoStartedMovementStep; declared: Game; after: Game }[] = [];
if (advanced) {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const before = structuredClone(game), step: SaphoStartedMovementStep = { actor: guild,
action: { type: 'decision', take: guildTake } };
act(step.actor, step.action); const declared = structuredClone(game); reach(saphoStartedMovementClean);
timing.push({ before, step, declared, after: structuredClone(game) });
} else reach(state => state.phase === 5 && saphoStartedMovementClean(state));
const beforeShipment = structuredClone(game), current = game.active; assert.ok(current && current !== owner);
assert.equal(current, guildTake ? guild : 'richese');
const step: SaphoStartedMovementStep = { actor: current,
action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } };
const shipmentAt = actions.length;
act(step.actor, step.action); const declared = structuredClone(game); reach(saphoStartedMovementClean);
const shipment = { before: beforeShipment, step, declared,
steps: structuredClone(actions.slice(shipmentAt)), after: structuredClone(game) };
assert.equal(game.active, current); assert.equal(saphoStartedMovementPlayer(game, current).shipped, true);
assert.equal(saphoStartedMovementPlayer(game, current).moved, 0);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).spice - saphoStartedMovementPlayer(game, current).spice,
current === guild ? 1 : 2);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).reserves - saphoStartedMovementPlayer(game, current).reserves, 1);
assert.equal(saphoStartedMovementPlayer(game, current).forces['polar_sink:0'], 1);
assert.equal(saphoStartedMovementPlayer(game, owner).shipped, false);
assert.equal(saphoStartedMovementPlayer(game, owner).moved, 0);
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
assert.deepEqual(saphoStartedMovementInventory(game), originalInventory);
const source = structuredClone(game);
return { game, source, advanced, guildTake, owner, guild, canceler, counter, karama: counter,
card: STARTED_MOVEMENT_SAPHO, current, actor: current, beforeShipment, shipment,
lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, acquired, acquisition, timing,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
...(options.ownerBeforeAtreides ? ['The original lobby uses real seat-position actions to seat Emperor before Atreides; default seating is unchanged.'] : []),
'Actual public ready/start (Advanced uses the original preview acknowledgement), original setup and paid cache1 purchase give Emperor the physical Sapho. Only an unopened original Atreides Karama deal position, one Basic/two Advanced original land Spice positions and scoped scalar lotteries are selected.',
`The original ${advanced ? guildTake ? 'Guild TAKE' : 'Guild WAIT' : 'Basic in-order turn'} is followed by an actual one-force Polar Sink shipment costing ${current === guild ? 1 : 2}. The source stops after all original shipment responses settle, before Sapho or the current actor’s ground move/end. No dealt hand, balance, force, phase, queue, timing grant or pending receipt is installed.`,
] };
}
export type SaphoStartedMovementFixture = ReturnType<typeof createSaphoStartedMovementFixture>;
/** afterCurrent is projected explanation, never an action field. */
export function saphoStartedMovementAction(game: Game, mode: 'first' | 'last', owner = 'emperor'): Action {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option, `The actual waiting tail must offer Sapho ${mode}`);
return { type: 'card', card: STARTED_MOVEMENT_SAPHO, scope: option.scope, event: option.event, mode: option.mode };
}
/** LABELLED supplemental source: the borrowed canonical fixture physically
* conserves Sapho/Semuta custody rather than earning their acquisition. Its
* setup, current shipment, subsequent card disposal and continuation are real.
* No further card, resource, profile, phase or queue assignment is made here. */
export function createSaphoStartedSemutaFixture(advanced = false) {
const inherited = createSemutaHomeworldSaphoFixture('movement', 'last', advanced);
let game = structuredClone(inherited.game);
const actions: SaphoStartedMovementStep[] = [];
const beforeShipment = structuredClone(game), current = game.active; assert.ok(current && current !== inherited.owner);
const sources = nativeShipmentSources(viewGame(game, current), 1, 0); assert.ok(sources);
const step: SaphoStartedMovementStep = { actor: current, action: { type: 'ship', territory: 'polar_sink',
sector: 0, amount: 1, elite: 0, homeworldSources: sources, allyPayment: 0 } };
game = applySemutaHomeworldChoamStep(game, step.actor, step.action, actions);
game = advanceSemutaHomeworldChoam(game, saphoStartedMovementClean, actions);
assert.equal(game.active, current); assert.equal(saphoStartedMovementPlayer(game, current).shipped, true);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).spice - saphoStartedMovementPlayer(game, current).spice, 2);
const shipment = { before: beforeShipment, step, after: structuredClone(game), steps: structuredClone(actions) };
const option = viewGame(game, inherited.owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option, 'The original owner changes last position in this waiting tail');
const action: Action = { type: 'card', card: inherited.card, scope: option.scope, event: option.event, mode: option.mode };
const originalInventory = saphoStartedMovementInventory(inherited.game);
assert.deepEqual(saphoStartedMovementInventory(game), originalInventory);
return { ...inherited, game, source: structuredClone(game), current, actor: current, action, event: option.event,
advanced, beforeShipment, shipment, originalInventory,
sourceActionCount: inherited.actions.length + actions.length, actions: [...inherited.actions, ...actions],
nativeAcquisition: false,
staging: [...inherited.staging,
'Inherited CONTROLLED conserved Sapho/Semuta custody is supplemental, not a native purchase claim. This new branch actually pays two spice to ship one original Homeworld force to Polar Sink before queued LAST, with no added played-state assignments.'] };
}
/** Finish each actual combined turn once. Following Emperor/Guild turns make
* real paid shipments; an already shipped current actor only completes its
* original turn. Native Guild choices/response controls remain real actions. */
export function finishSaphoStartedMovement(state: Game, fixture: Pick<SaphoStartedMovementFixture, 'owner' | 'guild'>,
options: { guildTake?: boolean; shipFollowing?: boolean } = {}, actions: SaphoStartedMovementStep[] = []) {
let game = state;
const turns: { actor: string; before: Game; shipment: SaphoStartedMovementStep | null;
afterShipment: Game; end: SaphoStartedMovementStep; afterEnd: Game }[] = [];
const timing: { before: Game; step: SaphoStartedMovementStep; declared: Game; after: Game }[] = [];
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
for (let count = 0; game.phase === 5 && count < 160; count++) {
if (!saphoStartedMovementClean(game)) {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming') {
const before = structuredClone(game), step: SaphoStartedMovementStep = { actor: fixture.guild,
action: { type: 'decision', take: options.guildTake ?? false } };
act(step.actor, step.action); const declared = structuredClone(game);
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean, actions);
timing.push({ before, step, declared, after: structuredClone(game) });
} else {
const step = nextSaphoStartedMovementStep(game); assert.ok(step); act(step.actor, step.action);
}
continue;
}
const actor = game.active; assert.ok(actor && !turns.some(turn => turn.actor === actor));
const before = structuredClone(game);
const shipment: SaphoStartedMovementStep | null = options.shipFollowing !== false &&
(actor === fixture.owner || actor === fixture.guild) && !saphoStartedMovementPlayer(game, actor).shipped
? { actor, action: { type: 'ship', territory: actor === fixture.owner ? 'imperial_basin' : 'carthag',
sector: actor === fixture.owner ? 9 : 11, amount: 1, elite: 0, allyPayment: 0 } } : null;
if (shipment) {
act(shipment.actor, shipment.action); game = advanceSaphoStartedMovement(game, saphoStartedMovementClean, actions);
}
const afterShipment = structuredClone(game), end: SaphoStartedMovementStep = { actor, action: { type: 'endMovement' } };
act(end.actor, end.action); turns.push({ actor, before, shipment, afterShipment, end, afterEnd: structuredClone(game) });
}
assert.notEqual(game.phase, 5, 'Every original combined turn must finish finitely');
return { game, turns, timing, actions };
}