Files
Dune/tests/fixture-nexus-advisor-fresh.ts
T

184 lines
9.8 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { type SpiceCard } from '../game/cards';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type NexusAdvisorFreshFixture = {
initial: Game;
afterSetup: Game;
game: Game;
owner: string;
foreign: string;
canceler: string;
card: string;
territory: string;
location: string;
event: string;
declaration: Action;
batchDeclaration: Action;
ordinaryAction: Action;
actions: StrongholdFactionsNativeStep[];
beforeForeignShipment: Game;
afterForeignShipment: Game;
beforeAccompaniment: Game;
afterAccompaniment: Game;
beforeReinforcement: Game;
scenario: {
turn: number;
storm: number;
lockedTurn: number;
advisors: number;
opposingFighters: number;
alliance: [string, string];
ordinaryTarget: string;
undealtOrder: string[];
};
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function nexusAdvisorFreshPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original advisor seat ${actor}`);
return player;
}
function entropy<T>(producer: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return producer(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function nativeStep(game: Game): StrongholdFactionsNativeStep {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } };
// Preserve original advisors through the earlier mandatory declaration.
if (decision?.kind === 'advisorBattle' || decision?.kind === 'advisor' || decision?.kind === 'intrusion')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'bg', action: { type: 'predict', faction: 'emperor', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' && !nexusAdvisorFreshPlayer(game, 'bg').advisorSetup)
return { actor: 'bg', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
}
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, 'The genuine advisor fixture reached an unexpected private battle plan');
return step;
}
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action));
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = run(game, nativeStep(game), actions);
}
throw Error('Native advisor progression did not reach its requested boundary');
}
/** Finish only original acknowledgement/decision windows, never another movement turn. */
export function settleNexusAdvisorFresh(game: Game): Game {
return advance(game, clean, []);
}
/** Reorder undealt Spice only. Previously blown cards retain their discard custody. */
function orderSpice(game: Game, kinds: ('land' | 'worm')[], order: string[]): void {
const front: SpiceCard[] = [];
for (const kind of kinds) {
const index = game.spiceDeck.findIndex(card => kind === 'worm' ? 'worm' in card :
'territory' in card && !['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
assert.ok(index >= 0, `Missing original undealt ${kind}`);
const card = game.spiceDeck.splice(index, 1)[0];
front.push(card);
order.push('territory' in card ? card.territory : 'worm');
}
game.spiceDeck.unshift(...front);
}
/** Every force, payment, lock, alliance, card deal and continuation is native.
* The only staged values are physical undealt deck order and producer entropy. */
export function createNexusAdvisorFreshFixture(): NexusAdvisorFreshFixture {
const owner = 'bg', foreign = 'guild', canceler = foreign;
const territory = 'shield_wall', location = 'shield_wall:9';
const actions: StrongholdFactionsNativeStep[] = [], undealtOrder: string[] = [];
let game = createGame('NEXUSADVISORFRESH', newPlayer(owner, 'Bene Gesserit', 'beneGesserit'), true, []);
joinGame(game, newPlayer(foreign, 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
game.nexusCards = { cards: null, phase: null };
for (const player of game.players) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeNexusGameForAudit(game));
assert.equal(game.status, 'setup');
assert.ok(game.players.every(p => p.hand.length === 0));
// The original starting deal consumes one card per seat, in roster order.
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
const card = game.deck[karamaIndex].id;
[game.deck[1], game.deck[karamaIndex]] = [game.deck[karamaIndex], game.deck[1]];
undealtOrder.push(`Starting deal ${card} to ${canceler}`);
const initial = structuredClone(game);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
assert.ok(nexusAdvisorFreshPlayer(game, canceler).hand.some(held => held.id === card));
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['land', 'land'], undealtOrder);
game = advance(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), actions);
// A genuine nearby occupied destination makes the later ordinary flip action
// geometrically legal; only its fresh advisor stance disallows fighter entry.
game = run(game, { actor: 'atreides', action: { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1 } }, actions);
game = advance(game, clean, actions);
game = advance(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
orderSpice(game, ['worm', 'land', 'land'], undealtOrder);
game = advance(game, state => state.nexus === true && !state.spiceWindow, actions);
game = run(game, { actor: 'emperor', action: { type: 'alliance', target: 'atreides' } }, actions);
game = run(game, { actor: 'atreides', action: { type: 'alliance', target: 'emperor' } }, actions);
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
for (const [actor, face] of [[owner, 'beneGesserit'], [foreign, 'guild']] as const) {
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
undealtOrder.push(`Closing Nexus ${face} to ${actor}`);
game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
}
game = advance(game, state => state.phase === 5 && state.active === foreign && clean(state), actions);
const beforeForeignShipment = structuredClone(game);
game = run(game, { actor: foreign, action: { type: 'ship', territory, sector: 9, amount: 1 } }, actions);
game = advance(game, state => state.decision?.kind === 'advisor' && !state.response && !state.phaseOpening, actions);
const afterForeignShipment = structuredClone(game), beforeAccompaniment = structuredClone(game);
game = run(game, { actor: owner, action: { type: 'decision', accept: true, accompany: true, amount: 1 } }, actions);
game = advance(game, clean, actions);
const afterAccompaniment = structuredClone(game);
game = run(game, { actor: foreign, action: { type: 'endMovement' } }, actions);
game = advance(game, state => state.phase === 5 && state.active === owner && clean(state), actions);
const beforeReinforcement = structuredClone(game);
game = run(game, { actor: owner, action: { type: 'ship', territory, sector: 9, amount: 1 } }, actions);
game = advance(game, clean, actions);
const player = nexusAdvisorFreshPlayer(game, owner);
assert.equal(player.advisors?.[territory]?.lockedTurn, game.turn);
assert.equal(player.forces[location], 2);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.equal(player.ally, null);
validateNexusCards(game.nexusCards!.cards!, game.players);
const event = viewGame(game, owner).nexusAdvisors!.offer!.event;
return {
initial, afterSetup, game, owner, foreign, canceler, card, territory, location, event,
declaration: { type: 'nexusAdvisors', event, territories: [territory] },
batchDeclaration: { type: 'nexusAdvisors', event, territories: ['arrakeen', territory] },
ordinaryAction: { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 1, fighters: true },
actions, beforeForeignShipment, afterForeignShipment, beforeAccompaniment, afterAccompaniment, beforeReinforcement,
scenario: { turn: game.turn, storm: game.storm, lockedTurn: player.advisors![territory].lockedTurn!,
advisors: player.forces[location], opposingFighters: nexusAdvisorFreshPlayer(game, foreign).forces[location],
alliance: ['emperor', 'atreides'], ordinaryTarget: 'imperial_basin:10', undealtOrder },
};
}