Files
Dune/tests/fixture-testing-station-contest.ts
T

443 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 {
applyAction,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { fighterCount } from '../game/advisors';
import { DISCOVERY_CARD_PLACEMENTS, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryStormActions } from '../game/discovery-storm-options';
import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type TestingStationContestStep = { actor: string; action: Action };
export type TestingStationContestFixture = {
initial: Game;
setup: Game;
afterSetup: Game;
/** Natural turn-3 Storm phase with two actual occupants and the ordinary source. */
game: Game;
/** The same turn-3 Storm phase before any dial, card or ready: the ordinary window. */
beforeStorm: Game;
actions: TestingStationContestStep[];
staging: string[];
owner: string;
second: string;
token: string;
parent: string;
parentSector: number;
source: { turn: number; kind: string; distance: number };
/** The two occupants in original storm order. */
order: [string, string];
event: string;
};
const STATION = 'ecological-testing-station';
/** Printed player-circle storm order for the board's current storm position. */
export function testingStationStormOrder(
game: Game,
ids: readonly string[],
): string[] {
const distance = (id: string) =>
(PLAYER_CIRCLE_SECTORS[game.playerPositions![id] - 1] - game.storm + 18) %
18 || 18;
return [...ids].sort((a, b) => distance(a) - distance(b));
}
export function testingStationOccupants(game: Game): string[] {
return game.order.filter((id) => {
const player = game.players.find((candidate) => candidate.id === id);
return !!player && fighterCount(player, STATION) > 0;
});
}
const clean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision;
/** Real native continuations; only the target turn-three station window stays open. */
export function nextTestingStationContestStep(
game: Game,
): TestingStationContestStep | null {
if (game.status === 'finished') return null;
if (game.decision?.kind === 'ecologicalStorm') {
if (game.turn >= 3) return null;
const actor = game.decision.player;
const action = discoveryStormActions(viewGame(game,actor)).find(choice => choice.delta === 0);
assert.ok(action, 'Earlier natural station opportunities must preserve the ordinary distance.');
return {actor,action};
}
if (game.decision?.kind === 'discoveryEntry') {
const decision = game.decision;
const offer = viewGame(game, decision.player).discoveryEntry;
return {
actor: decision.player,
action:
offer && !offer.blocked && offer.sources.length
? {
type: 'decision',
event: decision.event,
accept: true,
groups: offer.sources,
}
: { type: 'decision', event: decision.event, accept: false },
};
}
// Optional native offers stay declined so no unrelated ride, Ambassador or
// Battle can change the two-occupant board under audit.
if (game.decision?.kind === 'wormRide')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (
game.decision?.kind === 'ecazPlacement' ||
game.decision?.kind === 'ecazAmbassador'
)
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
return nextSpiceBankerIncomeNativeStep(game);
}
function step(
game: Game,
actions?: TestingStationContestStep[],
): Game {
const next = nextTestingStationContestStep(game);
assert.ok(
next,
`Original Discovery/Ecaz continuation stopped at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`,
);
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advanceTestingStationContest(
state: Game,
until: (game: Game) => boolean,
actions?: TestingStationContestStep[],
): Game {
let game = state;
for (let n = 0; n < 2600; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = step(game, actions);
}
throw Error('Original Discovery/Ecaz chronology did not reach its boundary.');
}
/** Conserved staging only: original unplayed Spice Cards move to the front. */
function frontCard(
game: Game,
position: number,
matches: (card: Game['spiceDeck'][number]) => boolean,
): void {
const index = game.spiceDeck.findIndex(
(card, i) => i >= position && matches(card),
);
assert.ok(
index >= position,
'The original unplayed Spice Card must remain available.',
);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Only the actual Hiereg supply lottery is selected; token identity stays real. */
function selectedTokenLottery<T>(
game: Game,
face: DiscoveryTokenFace,
operation: () => T,
): T {
const pool = game
.discoveries!.tokens.filter(
(token) => token.status === 'supply' && token.type === 'hiereg',
)
.map((token) => token.face);
const index = pool.indexOf(face);
assert.ok(index >= 0, 'The station Hiereg token must remain in supply.');
const scalar = Math.floor(((index + 0.5) / pool.length) * 0x100000000);
const descriptor = Object.getOwnPropertyDescriptor(
crypto,
'getRandomValues',
);
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) {
array[0] = scalar;
return array;
}
return Reflect.apply(native, crypto, [array]) as V;
};
try {
return operation();
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Held-card staging only: a real unplayed Weather Control card moves to a hand. */
export function holdTestingStationStormCard(
game: Game,
owner: string,
effect: string,
) {
const card = [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
.find((candidate) => candidate.effect === effect);
assert.ok(card, 'The original Treachery deck must still hold that card.');
game.deck = game.deck.filter((candidate) => candidate.id !== card.id);
game.discard = game.discard.filter((candidate) => candidate.id !== card.id);
for (const player of game.players)
player.hand = player.hand.filter((candidate) => candidate.id !== card.id);
game.players.find((player) => player.id === owner)!.hand.push(card);
return card;
}
/** Original fresh Ecaz/Guild/Fremen/Emperor Discovery setup. Two distinct
* non-advisor occupants reach the revealed station through real controls: the
* first free-enters from the printed Hagga Basin destination, a real worm Nexus
* forms the reciprocal alliance, and the ally then pays the ordinary stronghold
* rate to ship in. No force, phase, source, token or frame is fabricated. */
export function createTestingStationContestFixture(
options: { advanced?: boolean } = {},
): TestingStationContestFixture {
const advanced = options.advanced ?? false;
let game = createGame(
'TESTINGSTATIONCONTEST',
newPlayer('ecaz', 'Ecaz', 'ecaz'),
advanced,
['ecaz'],
);
for (const faction of ['guild', 'fremen', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game.discoveryEnabled = true;
for (const player of game.players)
if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const ecaz = game.players.find((player) => player.faction === 'ecaz')!.id;
const guild = game.players.find((player) => player.faction === 'guild')!.id;
assert.notEqual(ecaz, guild);
const initial = structuredClone(game);
const actions: TestingStationContestStep[] = [];
const staging: string[] = [];
game = initializeDiscoveryGameForAudit(game);
const setup = structuredClone(game);
game = advanceTestingStationContest(game, (state) => state.status === 'playing', actions);
// Printed Hagga Basin Discovery blow on turn one; a real ordinary worm on turn
// two; everything else stays an original unplayed card of the same deck.
const wormPosition = advanced ? 2 : 1;
frontCard(game, 0, (card) => 'territory' in card && card.discovery === 'discovery-hagga-basin');
if (advanced)
frontCard(game, 1, (card) => 'territory' in card && !card.discovery);
frontCard(game, wormPosition, (card) => 'worm' in card && !card.greatMaker);
for (let position = wormPosition + 1; position < wormPosition + 3; position++)
frontCard(game, position, (card) => 'territory' in card && !card.discovery);
staging.push(
'Reordered original unplayed Spice Cards only: the printed Hagga Basin Discovery first, then an ordinary Shai-Hulud for turn two.',
);
const afterSetup = structuredClone(game);
game = selectedTokenLottery(game, 'ecological-testing-station', () =>
advanceTestingStationContest(
game,
(state) =>
state.discoveries!.tokens.some(
(token) => token.face === 'ecological-testing-station' && token.status === 'placed',
),
actions,
),
);
const token = game.discoveries!.tokens.find(
(candidate) => candidate.face === 'ecological-testing-station',
)!;
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
assert.equal(token.status, 'placed');
assert.equal(token.territory, placement.territory);
assert.equal(token.sector, placement.sector);
staging.push(
'Selected the actual Hiereg supply lottery; the station token itself was placed by the printed card.',
);
// Turn one: the collector ships to the token's printed destination, then
// inspects and reveals the token during the ordinary Collection phase.
game = advanceTestingStationContest(
game,
(state) => state.phase === 5 && state.active === ecaz && clean(state),
actions,
);
const shipment: TestingStationContestStep = {
actor: ecaz,
action: {
type: 'ship',
territory: placement.territory,
sector: placement.sector,
amount: 2,
elite: 0,
allyPayment: 0,
},
};
actions.push(structuredClone(shipment));
game = applyAction(game, shipment.actor, shipment.action);
game = advanceTestingStationContest(
game,
(state) => state.phase === 7 && clean(state) && !state.pendingShipment,
actions,
);
assert.equal(game.turn, 1);
assert.ok(viewGame(game, ecaz).discoveries!.canInspect.includes(token.id));
const inspect: TestingStationContestStep = {
actor: ecaz,
action: { type: 'discovery', token: token.id, reveal: false },
};
const reveal: TestingStationContestStep = {
actor: ecaz,
action: { type: 'discovery', token: token.id, reveal: true },
};
for (const next of [inspect, reveal]) {
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(
game.discoveries!.tokens.find((candidate) => candidate.id === token.id)!.revealedTurn,
1,
);
staging.push(
'Ordinary paid shipment to the printed destination followed by that seat’s real Collection inspection and public reveal.',
);
// Turn two: the location's own next-turn free entry, then a real worm Nexus
// and reciprocal alliance, then the ally's ordinary paid shipment.
game = advanceTestingStationContest(
game,
(state) =>
state.turn === 2 &&
state.decision?.kind === 'discoveryEntry' &&
state.decision.player === ecaz,
actions,
);
const entry = nextTestingStationContestStep(game);
assert.ok(entry, 'The station occupant must receive its real free-entry choice.');
assert.equal(entry.action.type, 'decision');
assert.equal(entry.action.accept, true);
actions.push(structuredClone(entry));
game = applyAction(game, entry.actor, entry.action);
assert.equal(fighterCount(game.players.find((p) => p.id === ecaz)!, STATION), 2);
staging.push(
'The station occupant entered through the printed next-turn free entry; no force was placed there directly.',
);
game = advanceTestingStationContest(
game,
(state) =>
state.turn === 2 &&
state.phase === 1 &&
!!state.nexus &&
!state.spiceWindow &&
!state.spiceResolution &&
clean(state),
actions,
);
const offer: TestingStationContestStep = { actor: ecaz, action: { type: 'alliance', target: guild } };
const accept: TestingStationContestStep = { actor: guild, action: { type: 'alliance', target: ecaz } };
for (const next of [offer, accept]) {
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.players.find((player) => player.id === ecaz)!.ally, guild);
assert.equal(game.players.find((player) => player.id === guild)!.ally, ecaz);
game = advanceTestingStationContest(
game,
(state) => state.phase === 5 && state.active === guild && clean(state),
actions,
);
const secondShipment: TestingStationContestStep = {
actor: guild,
action: {
type: 'ship',
territory: STATION,
sector: 0,
amount: 1,
elite: 0,
allyPayment: 0,
},
};
actions.push(structuredClone(secondShipment));
game = applyAction(game, secondShipment.actor, secondShipment.action);
game = advanceTestingStationContest(game,clean,actions);
assert.equal(fighterCount(game.players.find((p) => p.id === guild)!, STATION), 1);
staging.push(
'A real worm Nexus produced the reciprocal Ecaz/Guild alliance; the ally then used its own ordinary paid shipment into the occupied revealed location.',
);
game = advanceTestingStationContest(
game,
(state) => state.turn === 3 && state.phase === 0 && clean(state),
actions,
);
const beforeStorm = structuredClone(game);
game = advanceTestingStationContest(
game,
(state) => state.turn === 3 && state.phase === 0 && state.decision?.kind === 'ecologicalStorm',
actions,
);
const frame = game.ecologicalStorm!;
assert.ok(
actions.some((next) => next.action.type === 'ship') &&
actions.some((next) => next.action.type === 'alliance') &&
actions.some((next) => next.action.type === 'discovery') &&
actions.some(
(next) => next.action.type === 'decision' && next.action.accept === true,
),
'The contested station must be reached through real controls only.',
);
const occupants = testingStationOccupants(game);
assert.equal(occupants.length, 2);
assert.deepEqual(frame.queue?.order, occupants);
assert.deepEqual(
occupants,
testingStationStormOrder(game, occupants),
'Both occupants must queue in original storm order.',
);
const seats = game.players
.map((player) => player.id)
.filter((id) => occupants.includes(id));
if (JSON.stringify(seats) !== JSON.stringify(testingStationStormOrder(game, seats)))
assert.notDeepEqual(
occupants,
seats,
'The contested queue must use the printed storm order, never seat order.',
);
assert.equal(frame.owner, occupants[0]);
assert.equal(game.decision!.player, frame.owner);
assert.ok(game.decision?.kind === 'ecologicalStorm');
assert.equal(frame.event, game.decision!.event);
assert.notEqual(game.stormMovementSource?.kind, 'weather');
assert.equal(game.stormMovementSource?.turn, 3);
assert.equal(game.stormPending, game.stormMovementSource!.distance);
assert.equal(game.ecologicalStorm!.base, game.stormPending);
return {
initial,
setup,
afterSetup,
game,
beforeStorm,
actions,
staging,
owner: occupants[0],
second: occupants[1],
token: token.id,
parent: placement.territory,
parentSector: placement.sector,
source: {
turn: game.stormMovementSource!.turn,
kind: game.stormMovementSource!.kind,
distance: game.stormMovementSource!.distance,
},
order: [occupants[0], occupants[1]],
event: frame.event,
};
}