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

179 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import type { NoFieldValue } from '../game/richese-no-field';
import { discoveryNoFieldBlowPlayer, discoveryNoFieldBlowReload, discoveryNoFieldBlowInventory,
discoveryNoFieldBlowClean, nextDiscoveryNoFieldBlow, type DiscoveryNoFieldBlowStep } from './fixture-discovery-no-field-blow';
export type DiscoveryNoFieldEntryStep = DiscoveryNoFieldBlowStep;
export const discoveryNoFieldEntryPlayer = discoveryNoFieldBlowPlayer;
export const discoveryNoFieldEntryReload = discoveryNoFieldBlowReload;
export const discoveryNoFieldEntryInventory = discoveryNoFieldBlowInventory;
export const discoveryNoFieldEntryClean = discoveryNoFieldBlowClean;
export const nextDiscoveryNoFieldEntry = nextDiscoveryNoFieldBlow;
/** Only original scalar lotteries are chosen. UUIDs and non-scalar native byte
* entropy remain untouched. A selected Discovery face is sampled from its real
* current supply at the actual printed Spice draw, never installed afterward.
*/
function originalLottery<T>(game: Game, face: DiscoveryTokenFace | undefined, operation: () => T): T {
let scalar = 0xffffffff;
if (face) {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face); assert.ok(index >= 0);
scalar = Math.floor((index + 0.5) / eligible.length * 0x100000000);
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = scalar;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actDiscoveryNoFieldEntry(game: Game, actor: string, action: Action,
actions: DiscoveryNoFieldEntryStep[] = [], face?: DiscoveryTokenFace): Game {
const next = originalLottery(game, face, () => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function advanceDiscoveryNoFieldEntry(game: Game, until: (state: Game) => boolean,
actions: DiscoveryNoFieldEntryStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Discovery entry chronology ended before its requested boundary');
const next = nextDiscoveryNoFieldEntry(game); assert.ok(next, 'Complete the actual native pending control');
game = actDiscoveryNoFieldEntry(game, next.actor, next.action, actions);
}
throw Error('The original Discovery/No-Field entry chronology did not reach its finite boundary');
}
export function settleDiscoveryNoFieldEntry(game: Game, actions: DiscoveryNoFieldEntryStep[] = []): Game {
return advanceDiscoveryNoFieldEntry(game, discoveryNoFieldEntryClean, actions);
}
export function finishDiscoveryNoFieldEntry(game: Game, actions: DiscoveryNoFieldEntryStep[] = []): Game {
return advanceDiscoveryNoFieldEntry(game, state => !state.discoveryEntry && discoveryNoFieldEntryClean(state), actions);
}
function selectLand(game: Game, position: number, parent: string) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && card.territory !== parent && 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) };
}
/** First original Discovery blow, native supply placement, paid parent shipment
* and actual Collection inspection/reveal. Mixed physical1 is paid a full turn
* before its marker. Both then enter the original NEXT-turn free-entry offer.
* No audit initializer or played component/state installation is used.
*/
export function createDiscoveryNoFieldEntryFixture(options: {
advanced?: boolean; value?: NoFieldValue; companions?: boolean; face?: 'cistern' | 'shrine';
} = {}) {
const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false;
const face = options.face ?? 'cistern', owner = 'richese', observer = 'atreides', other = 'emperor';
const printed = 'discovery-hagga-basin', parent = DISCOVERY_CARD_PLACEMENTS[printed];
let game = createGame('DISCOVERYNFENTRY', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: DiscoveryNoFieldEntryStep[] = [];
const act = (actor: string, action: Action, lotteryFace?: DiscoveryTokenFace) => {
game = actDiscoveryNoFieldEntry(game, actor, action, actions, lotteryFace);
};
const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldEntry(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);
const original = discoveryNoFieldEntryInventory(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed); 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, parent.territory)] : [];
reach(state => state.phase === 1 && discoveryNoFieldEntryClean(state) && !state.spiceWindow && !state.spiceSequence && !state.spiceResolution);
const placement = transition(() => {
assert.deepEqual(game.ready, []);
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
act(game.players.at(-1)!.id, { type: 'ready' }, face);
});
const discovery = game.discoveries!.tokens.find(token => token.face === face && token.status === 'placed'); assert.ok(discovery);
assert.equal(discovery.territory, parent.territory); assert.equal(discovery.sector, parent.sector);
const discoveryToken = discovery.id;
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && discoveryNoFieldEntryClean(state));
let markerShipment: { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game } | null = null;
let physicalShipment: { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game } | null = null;
let markerToken = '';
const shipMarker = () => {
const own = discoveryNoFieldEntryPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); markerToken = selected.id;
markerShipment = transition(() => {
act(owner, { type: 'ship', noField: markerToken, event: own.noFieldEvent, territory: parent.territory, sector: parent.sector });
reach(discoveryNoFieldEntryClean);
});
assert.equal(discoveryNoFieldEntryPlayer(markerShipment.before, owner).spice - discoveryNoFieldEntryPlayer(game, owner).spice, 2);
assert.equal(discoveryNoFieldEntryPlayer(markerShipment.before, owner).reserves, discoveryNoFieldEntryPlayer(game, owner).reserves);
};
let secondSpiceOrder: ReturnType<typeof selectLand>[] = [];
if (companions) {
physicalShipment = transition(() => {
act(owner, { type: 'ship', territory: parent.territory, sector: parent.sector, amount: 1, elite: 0, allyPayment: 0 });
reach(discoveryNoFieldEntryClean);
});
reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldEntryClean(state));
secondSpiceOrder = [selectLand(game, 0, parent.territory), ...(advanced ? [selectLand(game, 1, parent.territory)] : [])];
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldEntryClean(state));
}
shipMarker(); assert.ok(markerShipment);
const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game };
reach(state => state.phase === 7 && discoveryNoFieldEntryClean(state));
const beforeReveal = structuredClone(game), beforeRevealActions = structuredClone(actions);
assert.ok(viewGame(game, owner).discoveries!.canInspect.includes(discoveryToken));
const revelation = transition(() => {
act(owner, { type: 'discovery', token: discoveryToken, reveal: false });
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(discoveryToken));
act(owner, { type: 'discovery', token: discoveryToken, reveal: true });
});
const revealTurn = game.turn;
reach(state => state.turn === revealTurn + 1 && state.phase === 0 &&
state.decision?.kind === 'discoveryEntry' && state.decision.player === owner && !state.response && !state.phaseOpening);
const own = discoveryNoFieldEntryPlayer(game, owner), noFieldEvent = own.noFieldEvent; assert.ok(noFieldEvent);
assert.equal(noFieldEvent, discoveryNoFieldEntryPlayer(shipment.after, owner).noFieldEvent);
assert.equal(own.noField!.deployed!.tokenId, markerToken);
assert.deepEqual(own.noField!.deployed!.location, { territory: parent.territory, sector: parent.sector });
const offer = viewGame(game, owner).discoveryEntry; assert.ok(offer && !offer.blocked);
assert.equal(offer.token, discoveryToken); assert.equal(offer.destination, face);
const groups = companions ? [{ source: `${parent.territory}:${parent.sector}`, normal: 1, elite: 0 }] : [];
const action: Action = { type: 'decision', event: offer.event, accept: true, groups,
noField: markerToken, noFieldEvent };
const decline: Action = { type: 'decision', event: offer.event, accept: false };
assert.deepEqual(discoveryNoFieldEntryInventory(game), original);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, advanced, value, companions,
owner, actor: owner, observer, other, face, parent: parent.territory, parentSector: parent.sector,
source: `${parent.territory}:${parent.sector}`, destination: face, discoveryToken, markerToken,
event: offer.event, noFieldEvent, marker: structuredClone(own.noField), offer: structuredClone(offer), action, decline,
placement, markerShipment: shipment, physicalShipment, beforeReveal, beforeRevealActions, revelation,
original, dealOrder, blowSelection, followingSpiceOrder, secondSpiceOrder, setupActions, actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, placement: placement.steps.length, markerShipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, beforeReveal: beforeRevealActions.length,
revelation: revelation.steps.length, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck and public Discovery opt-in before readiness/start. ` +
'No audit initializer or other optional module. Select only original unopened starting/Spice positions and scalar lotteries; the actual first Discovery Hagga Basin draw samples the original supply location at GaraKulon8. ' +
(companions ? 'Richese actually ships physical1parent on turn1, keeps the location hidden through Collection1, then pays2 for its original marker there on turn2. '
: 'Richese actually pays2 for its original marker at the parent on turn1. ') +
'Actual Collection inspection/reveal, Mentat, turn end and next-turn original entry round produce this offer. No hand, purse, army, phase, marker, event, token, reveal receipt or free-entry frame is installed. Counts derive from recorded arrays. Native BG arrival reactions are outside this three-seat source.' };
}
export type DiscoveryNoFieldEntryFixture = ReturnType<typeof createDiscoveryNoFieldEntryFixture>;