Files
Dune/tests/fixture-discovery-no-field-blow.ts
T

172 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import type { NoFieldValue } from '../game/richese-no-field';
import { actFamilyAtomicsNoField, familyAtomicsNoFieldPlayer, familyAtomicsNoFieldReload,
familyAtomicsNoFieldInventory, familyAtomicsNoFieldClean, nextFamilyAtomicsNoFieldStep,
type FamilyAtomicsNoFieldStep } from './fixture-family-atomics-no-field';
export type DiscoveryNoFieldBlowStep = FamilyAtomicsNoFieldStep;
export const actDiscoveryNoFieldBlow = actFamilyAtomicsNoField;
export const discoveryNoFieldBlowPlayer = familyAtomicsNoFieldPlayer;
export const discoveryNoFieldBlowReload = familyAtomicsNoFieldReload;
export const discoveryNoFieldBlowClean = familyAtomicsNoFieldClean;
export function discoveryNoFieldBlowInventory(game: Game) {
const original = familyAtomicsNoFieldInventory(game); validateDiscoveryState(game.discoveries!); return original;
}
export function nextDiscoveryNoFieldBlow(game: Game): DiscoveryNoFieldBlowStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const d = game.decision;
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d?.kind === 'richeseAllyOpportunity') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
}
// The inherited native phase5 branch explicitly ends the current turn before
// optional policy actions, including newly supported No-Field transport.
return nextFamilyAtomicsNoFieldStep(game);
}
export function advanceDiscoveryNoFieldBlow(game: Game, until: (state: Game) => boolean,
actions: DiscoveryNoFieldBlowStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Discovery chronology ended before its requested boundary');
const next = nextDiscoveryNoFieldBlow(game); assert.ok(next, 'Complete the actual native pending control');
game = actDiscoveryNoFieldBlow(game, next.actor, next.action, actions);
}
throw Error('The original Discovery/No-Field chronology did not reach its finite boundary');
}
export function settleDiscoveryNoFieldBlow(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game {
return advanceDiscoveryNoFieldBlow(game, discoveryNoFieldBlowClean, actions);
}
export function finishDiscoveryNoFieldBlowPhase(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game {
return advanceDiscoveryNoFieldBlow(game, state => state.phase !== 1 && discoveryNoFieldBlowClean(state), actions);
}
const printed = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-wind-pass-north')!;
function selectLand(game: Game, position: number) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
card.territory !== printed.territory && card.sector !== game.storm);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
}
/** Public opt-in and actual shipments only. Marker-only sources use turn1
* shipment and turn2 original blow; mixed sources first ship physical1, then
* the marker on turn2, and wait for the actual turn3 blow. No audit initializer
* or installed hand, wallet, army, phase, token, event or receipt is used.
*/
export function createDiscoveryNoFieldBlowFixture(options: {
advanced?: boolean; value?: NoFieldValue; companions?: boolean;
} = {}) {
const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false;
const owner = 'richese', other = 'emperor', observer = 'atreides';
let game = createGame('DISCOVERYNFBLOW', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: DiscoveryNoFieldBlowStep[] = [];
const act = (actor: string, action: Action) => { game = actDiscoveryNoFieldBlow(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldBlow(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
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.ok(game.players.every(player => !player.hand.length));
const ordinary = ['projectile', 'shield', 'snooper'].map(kind => game.deck.find(card => card.kind === kind));
const dealOrder = ordinary.map((card, position) => {
assert.ok(card); const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries);
assert.deepEqual(discoveryNoFieldBlowPlayer(game, owner).forces, {});
const original = discoveryNoFieldBlowInventory(game), originalDiscovery = structuredClone(game.discoveries);
const firstSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])];
reach(state => state.turn === 1 && state.phase === 5 && discoveryNoFieldBlowClean(state));
let markerShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null;
let physicalShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null;
let otherShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null;
let token = '';
const shipMarker = () => {
const own = discoveryNoFieldBlowPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); token = selected.id;
markerShipment = transition(() => {
act(owner, { type: 'ship', noField: token, event: own.noFieldEvent, territory: printed.territory, sector: printed.sector });
reach(discoveryNoFieldBlowClean);
});
assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).spice - discoveryNoFieldBlowPlayer(game, owner).spice, 2);
assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).reserves, discoveryNoFieldBlowPlayer(game, owner).reserves);
};
for (let count = 0; game.phase === 5 && count < 80; count++) {
if (discoveryNoFieldBlowClean(game) && game.active === owner && !discoveryNoFieldBlowPlayer(game, owner).shipped) {
if (companions) physicalShipment = transition(() => {
act(owner, { type: 'ship', territory: printed.territory, sector: printed.sector, amount: 1, elite: 0, allyPayment: 0 });
reach(discoveryNoFieldBlowClean);
});
else shipMarker();
continue;
}
if (discoveryNoFieldBlowClean(game) && game.active === other && !discoveryNoFieldBlowPlayer(game, other).shipped) {
otherShipment = transition(() => {
act(other, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
reach(discoveryNoFieldBlowClean);
});
continue;
}
const next = nextDiscoveryNoFieldBlow(game); assert.ok(next); act(next.actor, next.action);
}
assert.ok(otherShipment); assert.ok(!companions || physicalShipment);
reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldBlowClean(state));
let secondSpiceOrder: ReturnType<typeof selectLand>[] = [];
if (companions) {
secondSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])];
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldBlowClean(state));
shipMarker();
reach(state => state.turn === 3 && state.phase === 0 && discoveryNoFieldBlowClean(state));
}
assert.ok(markerShipment);
const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game };
const beforeTargetStorm = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery); assert.ok(from >= 0);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.unshift(card);
const blowSelection = { position: 0, from, card: structuredClone(card) };
const followingSpiceOrder = advanced ? [selectLand(game, 1)] : [];
const turn = companions ? 3 : 2;
reach(state => state.turn === turn && state.phase === 1 && discoveryNoFieldBlowClean(state) &&
!state.spiceWindow && !state.spiceSequence && !state.spiceResolution);
const opening = structuredClone(game), readyFrom = actions.length;
assert.equal(game.ready.length, 0);
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
const readyPrefix = structuredClone(actions.slice(readyFrom));
const drawer = game.players.at(-1)!.id, drawAction: Action = { type: 'ready' };
assert.equal(game.spiceWindow ?? null, null); assert.equal(game.thumperWindowOpen, true);
assert.deepEqual(game.spiceDeck[0], printed);
assert.equal(game.ready.length, game.players.length - 1); assert.equal(game.ready.includes(drawer), false);
const own = discoveryNoFieldBlowPlayer(game, owner), event = own.noFieldEvent; assert.ok(event);
assert.equal(own.noField!.deployed!.tokenId, token);
assert.equal(event, discoveryNoFieldBlowPlayer(shipment.after, owner).noFieldEvent);
assert.equal(own.forces[`${printed.territory}:${printed.sector}`] ?? 0, companions ? 1 : 0);
assert.equal(own.tanks, 0); assert.equal(discoveryNoFieldBlowPlayer(game, other).forces['polar_sink:0'], 1);
assert.deepEqual(discoveryNoFieldBlowInventory(game), original); assert.deepEqual(game.discoveries, originalDiscovery);
return { game, beforeBlow: structuredClone(game), opening, initial, lobby, setup, afterSetup,
advanced, value, companions, owner, actor: drawer, drawer, other, observer, token, event,
marker: structuredClone(own.noField), printed, target: printed.territory, location: `${printed.territory}:${printed.sector}`,
placement: DISCOVERY_CARD_PLACEMENTS[printed.discovery], drawAction, drawStep: { actor: drawer, action: drawAction },
markerShipment: shipment, physicalShipment, otherShipment, beforeTargetStorm, original, originalDiscovery,
dealOrder, firstSpiceOrder, secondSpiceOrder, blowSelection, followingSpiceOrder, readyPrefix,
setupActions, actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, markerShipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, otherShipment: otherShipment.steps.length,
readyPrefix: readyPrefix.length, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck, public Discovery moduleOption before readiness/start, no audit initializer or other optional module. ` +
'Only original unopened Treachery/Spice positions and scalar lotteries are selected. Richese pays two spice for its original concealed0/3/5 token; physical reserves remain untouched. Emperor separately pays2 for one original Polar counter. ' +
(companions ? 'Richese first actually ships physical1 to WindPassNorth17 on turn1, then its marker there on turn2, and reaches the original turn3 Discovery blow. '
: 'Richese actually ships the marker to WindPassNorth17 on turn1 and reaches the original turn2 Discovery blow. ') +
'Native dials/Storm, quiet auctions, ordered Advanced Charity/Revival and actual turn ends preserve the same deployed event. Two actual Ready controls prime the original first-reveal window; the exposed final Ready performs the printed draw. No hand, purse, force, phase, token, marker event, spice, discovery placement or casualty is installed.' };
}
export type DiscoveryNoFieldBlowFixture = ReturnType<typeof createDiscoveryNoFieldBlowFixture>;