Files
Dune/tests/fixture-banker-world-default.ts

211 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nextSpiceBankerIncomeNativeStep, type SpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type BankerWorldStep = SpiceBankerIncomeNativeStep;
export type BankerWorldDefaultFixture = {
lobby: Game;
offered: Game;
setup: Game;
/** Control-ready original Guild turn; no invoice has been declared. */
game: Game;
owner: string;
payer: string;
trainer: string;
shipment: BankerWorldStep;
actions: BankerWorldStep[];
staging: string[];
};
export type BankerWorldMentatBoundary = {
beforeCollection: Game;
collectionAction: BankerWorldStep;
collection: Game;
/** Last real Collection acknowledgement, immediately before Mentat 1 opens. */
game: Game;
action: BankerWorldStep;
actions: BankerWorldStep[];
};
export const bankerWorldPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
};
export const bankerWorldClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard;
export const bankerWorldWallets = (game: Game) =>
Object.fromEntries(game.players.map(p => [p.id, p.spice]));
/** Native no-bid/no-move policy. All owner-labelled windows remain real actions;
* no armies enter another faction's territory, so sealed battle plans are never
* manufactured. The original shared Banker stepper owns ordinary continuation. */
export function nextBankerWorldDefaultStep(game: Game): BankerWorldStep {
if (!game.phaseOpening && !game.response) {
const d = game.decision, event = game.richeseBidding?.event;
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: true } };
if (d?.kind === 'richeseBlackMarket') return { actor: d.player, action: { type: 'decision', event, decline: true } };
if (d?.kind === 'richeseDeclaration') return { actor: d.player, action: { type: 'decision', event, position: 'first' } };
if (d?.kind === 'richeseCache') {
assert.ok(game.richeseCache?.[0]);
return { actor: d.player, action: { type: 'decision', event, card: game.richeseCache[0].id,
method: 'onceAround', direction: 'counterclockwise' } };
}
if (d?.kind === 'richeseUnbid') return { actor: d.player, action: { type: 'decision', event, keep: false } };
if (game.nexusCards?.phase?.stage === 'drawing' && !d) {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
const lot = game.richeseAuction;
if (!d && lot && !lot.outcome) {
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 } };
}
}
assert.ok(!game.battle, 'This no-invasion programme must not invent a human Battle Plan.');
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return next;
}
export function advanceBankerWorldDefault(state: Game, until: (game: Game) => boolean,
actions: BankerWorldStep[] = []): Game {
let game = state;
for (let n = 0; n < 1400; n++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const next = nextBankerWorldDefaultStep(game);
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
throw Error('Native World Banker continuation did not reach its requested control.');
}
/** Select only the original all14 shuffle before any offer is dealt. Subsequent
* native setup draws retain deterministic entropy without rewriting components. */
function withWorldOffers<T>(run: () => T): T {
const selected: LeaderSkillId[] = ['spice-banker', 'swordmaster-of-ginaz', 'warmaster'];
const cards = LEADER_SKILL_CARDS.map(c => c.id), rest = cards.filter(id => !selected.includes(id));
const desired = selected.flatMap(id => [id, rest.shift()!]).concat(rest);
const current = [...cards], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[current[i], current[j]] = [current[j], current[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) {
for (let i = 0; i < array.length; i++) array[i] = rolls[index++] ?? 0xffffffff;
} else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Executable ordinary all14 World smoke source. Nexus/Discovery are original
* unused lobby selections; Tech and Advanced Strongholds use real host actions.
* Only closed original Spice Card order is staged, with its full multiset kept.
* No forces, balances, phase clocks, ownership or receipts are assigned. */
export function createBankerWorldDefaultFixture(): BankerWorldDefaultFixture {
let game = createGame('BANKERWORLDDEFAULT', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
game.nexusCards = { cards: null, phase: null };
const lobby = structuredClone(game), actions: BankerWorldStep[] = [];
for (const player of game.players) {
const next: BankerWorldStep = { actor: player.id, action: { type: 'ready' } };
actions.push(next); game = applyAction(game, next.actor, next.action);
}
game = withWorldOffers(() => initializeLeaderSkillsGameForAudit(game));
const offered = structuredClone(game);
const skills: LeaderSkillId[] = ['spice-banker', 'swordmaster-of-ginaz', 'warmaster'];
for (let n = 0; game.status === 'setup' && n < 200; n++) {
let next: BankerWorldStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = skills[game.players.findIndex(p => p.id === actor)];
assert.ok(offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && own.eligibleLeaders[0]);
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } };
} else next = nextBankerWorldDefaultStep(game);
actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const setup = structuredClone(game);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
// Two genuine ordinary blows close the first Advanced Spice phase without a
// worm/Nexus alliance or a Discovery entry changing this payment experiment.
for (let position = 0; position < 2; position++) {
const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(at >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
game = advanceBankerWorldDefault(game,
g => g.turn === 1 && g.phase === 5 && g.active === 'guild' && bankerWorldClean(g), actions);
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`))) &&
!Object.keys(game.spice).some(key => key.startsWith(`${t.id}:`) && game.spice[key] > 0));
assert.ok(destination);
const homeworldSources = nativeShipmentSources(viewGame(game, 'guild'), 4, 0);
assert.ok(homeworldSources);
const shipment: BankerWorldStep = { actor: 'guild', action: { type: 'ship', territory: destination.id,
sector: destination.sectors.find(s => s !== game.storm)!, amount: 4, elite: 0, allyPayment: 0, homeworldSources } };
const assignment = game.leaderSkills!.assignments.find(a => a.owner === 'richese' && a.skill === 'spice-banker');
assert.ok(assignment);
return { lobby, offered, setup, game, owner: 'richese', payer: 'guild', trainer: assignment.leader,
shipment, actions, staging: ['Original undealt all14 lottery; original unused Nexus/Discovery lobby selection; conserved first two closed ordinary Spice Cards.'] };
}
export function payBankerWorldDefault(fixture: BankerWorldDefaultFixture, action: Action = fixture.shipment.action): Game {
const paid = applyAction(fixture.game, fixture.shipment.actor, action);
return advanceBankerWorldDefault(paid, g => bankerWorldClean(g) && !g.pendingShipment);
}
/** Preserve the actual last Collection-ready action, not a synthetic phase
* write or an inferred wallet from a later turn containing unrelated income. */
export function bankerWorldBeforeMentat(state: Game): BankerWorldMentatBoundary {
const actions: BankerWorldStep[] = [];
let game = state, beforeCollection: Game | null = null, collectionAction: BankerWorldStep | null = null;
for (let n = 0; (game.phase !== 7 || !bankerWorldClean(game)) && n < 1000; n++) {
const before = game, next = nextBankerWorldDefaultStep(game);
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
if (before.phase !== 7 && game.phase === 7) {
beforeCollection = before;
collectionAction = next;
}
}
assert.ok(beforeCollection && collectionAction);
assert.equal(game.phase, 7);
assert.equal(game.turn, 1);
const collection = structuredClone(game);
for (let n = 0; n < game.players.length; n++) {
const next = nextBankerWorldDefaultStep(game);
assert.equal(next.action.type, 'ready');
if (game.ready.length === game.players.length - 1)
return { beforeCollection, collectionAction, collection, game, action: next, actions };
actions.push(next); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Collection phase did not expose its final Mentat acknowledgement.');
}