159 lines
8.6 KiB
TypeScript
159 lines
8.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
|
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
|
|
|
export type DiscoveryNativeMarkerStep = { actor: string; action: Action };
|
|
export type DiscoveryNativeMarkerFixture = {
|
|
initial: Game;
|
|
setup: Game;
|
|
game: Game;
|
|
actions: DiscoveryNativeMarkerStep[];
|
|
staging: string[];
|
|
collector: string;
|
|
owner: string;
|
|
token: string;
|
|
parent: string;
|
|
parentSector: number;
|
|
face: DiscoveryTokenFace;
|
|
};
|
|
|
|
/** Use the existing native no-spend policy, with explicit physical cache choices. */
|
|
export function nextDiscoveryNativeMarkerStep(game: Game): DiscoveryNativeMarkerStep {
|
|
if (!game.response && !game.phaseOpening && game.decision) {
|
|
const decision = game.decision, actor = decision.player, event = game.richeseBidding?.event;
|
|
if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
|
|
if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
|
|
if (decision.kind === 'richeseCache') return { actor, action: {
|
|
type: 'decision', event, card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise',
|
|
} };
|
|
if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: false } };
|
|
if (decision.kind === 'discoveryEntry') return { actor, action: { type: 'decision', event: decision.event, accept: false } };
|
|
}
|
|
if (!game.response && !game.phaseOpening && !game.decision && game.richeseAuction && !game.richeseAuction.outcome) {
|
|
const lot = game.richeseAuction;
|
|
const actor = lot.method === 'silent'
|
|
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))
|
|
: lot.active;
|
|
assert.ok(actor);
|
|
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
|
}
|
|
const next = nextSpiceBankerIncomeNativeStep(game);
|
|
assert.ok(next);
|
|
return next;
|
|
}
|
|
|
|
export function advanceDiscoveryNativeMarker(
|
|
state: Game,
|
|
until: (game: Game) => boolean,
|
|
actions?: DiscoveryNativeMarkerStep[],
|
|
): Game {
|
|
let game = state;
|
|
for (let n = 0; !until(game) && n < 1600; n++) {
|
|
assert.equal(game.status === 'finished', false);
|
|
const next = nextDiscoveryNativeMarkerStep(game);
|
|
actions?.push(next);
|
|
game = applyAction(game, next.actor, next.action);
|
|
}
|
|
assert.ok(until(game), 'Native Discovery marker policy did not reach its requested original window.');
|
|
return game;
|
|
}
|
|
export const discoveryNativeMarkerClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
|
|
|
|
/** Control only the actual placement lottery, never token identities or custody. */
|
|
function withPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
|
|
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
|
const selected = eligible.findIndex(token => token.face === face);
|
|
assert.ok(selected >= 0);
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const original = crypto.getRandomValues.bind(crypto);
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((selected + 0.5) / eligible.length * 0x100000000);
|
|
else Reflect.apply(original, crypto, [array]);
|
|
return array;
|
|
};
|
|
try { return operation(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
/** Fresh E2 admission, original setup/Storm and phase producers. The only staging
|
|
* is the order of four original unplayed Spice Cards and the placement lottery.
|
|
* No clock, wallet, force, marker, Discovery receipt or card is manufactured.
|
|
* Stops at Collection BEFORE inspection/revelation for an authenticated recipe. */
|
|
export function createDiscoveryNativeMarkerFixture(options: {
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
tech?: boolean;
|
|
face?: DiscoveryTokenFace;
|
|
collector?: 'choam' | 'guild';
|
|
} = {}): DiscoveryNativeMarkerFixture {
|
|
const face = options.face ?? 'cistern';
|
|
let game = options.initial ? structuredClone(options.initial)
|
|
: createGame('DISCOVERYE2MARKER', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
|
|
if (!options.initial) {
|
|
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
|
|
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
|
}
|
|
assert.equal(game.status, 'lobby');
|
|
assert.deepEqual(game.expansions, ['choam']);
|
|
assert.ok(!game.homeworlds && !game.leaderSkills && !game.nexusCards && !game.strongholdCards);
|
|
const owner = game.players.find(player => player.faction === 'richese')!.id;
|
|
const collector = game.players.find(player => player.faction === (options.collector ?? 'guild'))!.id;
|
|
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
|
game.discoveryEnabled = true;
|
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
|
const initial = structuredClone(game), actions: DiscoveryNativeMarkerStep[] = [];
|
|
game = initializeDiscoveryGameForAudit(game);
|
|
const setup = structuredClone(game);
|
|
game = advanceDiscoveryNativeMarker(game, g => g.status === 'playing', actions);
|
|
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
|
|
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
|
|
assert.ok(index >= 0);
|
|
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
|
// Keep both real turns' remaining draws ordinary; never mint or replay a card.
|
|
for (let position = 1; position < 4; position++) {
|
|
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
|
assert.ok(ordinary >= position);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
|
|
}
|
|
const staging = ['Reordered four original unplayed Spice Cards; selected the actual Discovery placement lottery.'];
|
|
game = advanceDiscoveryNativeMarker(game, g => g.phase === 1 && discoveryNativeMarkerClean(g), actions);
|
|
game = withPlacement(game, face, () => advanceDiscoveryNativeMarker(game,
|
|
g => g.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
|
|
game = advanceDiscoveryNativeMarker(game, g => g.phase === 5 && discoveryNativeMarkerClean(g), actions);
|
|
const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!;
|
|
assert.equal(token.status, 'placed');
|
|
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
|
|
assert.equal(token.territory, placement.territory);
|
|
game = advanceDiscoveryNativeMarker(game, g => g.phase === 5 && g.active === collector && discoveryNativeMarkerClean(g), actions);
|
|
const shipment: DiscoveryNativeMarkerStep = { actor: collector, action: {
|
|
type: 'ship', territory: placement.territory, sector: placement.sector, amount: 1, elite: 0, allyPayment: 0,
|
|
} };
|
|
actions.push(shipment);
|
|
game = applyAction(game, shipment.actor, shipment.action);
|
|
game = advanceDiscoveryNativeMarker(game, g => g.phase === 7 && discoveryNativeMarkerClean(g), actions);
|
|
assert.equal(game.turn, 1);
|
|
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
|
|
return { initial, setup, game, actions, staging, owner, collector, token: token.id, parent: placement.territory, parentSector: placement.sector, face };
|
|
}
|
|
|
|
export function revealDiscoveryNativeMarker(fixture: DiscoveryNativeMarkerFixture): Game {
|
|
const game = applyAction(fixture.game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
|
|
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
|
|
}
|
|
|
|
export function discoveryNativeMarkerShipmentWindow(fixture: DiscoveryNativeMarkerFixture): Game {
|
|
return advanceDiscoveryNativeMarker(revealDiscoveryNativeMarker(fixture), game =>
|
|
game.turn === 2 && game.phase === 5 && game.active === fixture.owner && discoveryNativeMarkerClean(game));
|
|
}
|
|
|
|
export function settleDiscoveryNativeMarkerShipment(state: Game): Game {
|
|
return advanceDiscoveryNativeMarker(state, game => !game.pendingShipment && discoveryNativeMarkerClean(game));
|
|
}
|