Files
Dune/tests/fixture-nexus-skills-modules-payments.ts
T

316 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame,
type Action, type Game } from '../game/engine';
import { distance, TERRITORIES, territory } from '../game/board';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import type { Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
import { ownedTech, TECH_TOKENS, type TechId } from '../game/tech-tokens';
import { classicNexusSkillsPaymentsInventory, classicNexusSkillsPaymentsPlayer,
initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep,
type ClassicNexusSkillsPaymentStep } from './fixture-classic-nexus-skills-payments';
export type NexusSkillsModulesPaymentKind = 'emperor-revival' | 'fremen-revival' | 'emperor-purchase' | 'native-shipment';
export type NexusSkillsModulesPaymentStep = ClassicNexusSkillsPaymentStep;
export type NexusSkillsModulesPaymentsOptions = {
/** Original lobby or turn-one, phase-zero unassigned Skills setup; never redealt. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
seatIds?: string[];
ownerFaction?: FactionId;
kind?: NexusSkillsModulesPaymentKind;
skill?: LeaderSkillId;
nativeGuild?: boolean;
tanks?: number;
eliteTanks?: number;
amount?: number;
};
export type NexusSkillsModulesPaymentsFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: NexusSkillsModulesPaymentStep;
afterFirstMentat: Game;
beforeClosingDraw: Game;
closingDraw: NexusSkillsModulesPaymentStep;
afterClosingDraw: Game;
beforePayment: Game;
game: Game;
actor: string;
opponent: string;
kind: NexusSkillsModulesPaymentKind;
paymentAction: Action;
revivalAction?: Action;
amount: number;
cost: number;
token: TechId;
tokenOwner: string | null;
tokenCount: number;
destination?: string;
actions: NexusSkillsModulesPaymentStep[];
staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
export const nexusSkillsModulesPaymentsPlayer = classicNexusSkillsPaymentsPlayer;
/** Scalar rolls stabilize physical deck/Storm order; UUID byte arrays stay native. */
function originalRandom<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 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');
}
}
export function nextNexusSkillsModulesPaymentsNativeStep(game: Game): NexusSkillsModulesPaymentStep | null {
if (clean(game) && game.status === 'finished') return null;
if (game.truthtrance && !game.phaseOpening && !game.response) {
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) return { actor: player.id, action };
}
throw Error('The actual Truthtrance must expose its native priority/question/answer action');
}
if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
return nextClassicNexusSkillsPaymentsNativeStep(game);
}
export function advanceNexusSkillsModulesPaymentsStep(state: Game, actions?: NexusSkillsModulesPaymentStep[]): Game {
const next = nextNexusSkillsModulesPaymentsNativeStep(state);
assert.ok(next, 'The native continuation stopped for a human battle plan');
actions?.push(structuredClone(next));
return applyAction(state, next.actor, next.action);
}
export function advanceNexusSkillsModulesPayments(state: Game, predicate: (game: Game) => boolean,
actions?: NexusSkillsModulesPaymentStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished');
game = advanceNexusSkillsModulesPaymentsStep(game, actions);
}
throw Error('Original Nexus/Skills/module continuation did not reach its boundary');
}
export function advanceNexusSkillsModulesPaymentsToPhase(state: Game, phase: number,
actions?: NexusSkillsModulesPaymentStep[]): Game {
const turn = state.turn;
return advanceNexusSkillsModulesPayments(state, game => {
assert.equal(game.turn, turn);
return game.phase >= phase && clean(game);
}, actions);
}
export function initializeNexusSkillsModulesPaymentsSetup(options: NexusSkillsModulesPaymentsOptions = {}): Game {
const kind = options.kind ?? 'emperor-revival';
const ownerFaction = options.ownerFaction ?? 'atreides';
let game = options.initial ? structuredClone(options.initial)
: createGame('NEXUSSKILLSMODULEPAYMENTS', newPlayer(options.seatIds?.[0] ?? 'payer', ownerFaction, ownerFaction),
options.advanced ?? !!options.strongholds);
if (!options.initial) {
const forbidden = kind === 'fremen-revival' ? 'fremen' : 'emperor';
const count = options.seatIds?.length ?? 3;
assert.ok(count >= 3 && count <= 6);
const factions: FactionId[] = options.nativeGuild
? ['guild', 'harkonnen', 'atreides', 'beneGesserit', 'fremen', 'emperor']
: ['harkonnen', 'atreides', 'beneGesserit', 'guild', 'fremen', 'emperor'];
for (const faction of factions) if (game.players.length < count && faction !== forbidden && faction !== ownerFaction)
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
assert.equal(game.players.length, count);
}
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const tech = options.tech ?? (options.initial ? !!game.techTokens : true);
const strongholds = options.strongholds ?? !!game.strongholdCards;
assert.ok(tech || strongholds, 'Select original Tech and/or Advanced Strongholds');
if (game.status === 'lobby') {
if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
}
assert.equal(!!game.techTokens, tech);
assert.equal(!!game.strongholdCards, strongholds);
if (options.nativeGuild) assert.ok(game.players.some(player => player.faction === 'guild'));
return initializeClassicNexusSkillsPaymentsSetup({ initial: game, ownerFaction,
kind: kind === 'native-shipment' ? 'emperor-revival' : kind, advanced: options.advanced, skill: options.skill });
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
staging.push('Conserved every original board counter into its own reserves; no wallet, phase, module owner or earned receipt assigned.');
}
function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const front: Game['spiceDeck'] = [];
for (const kind of kinds) {
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0, 'Only actual still-undealt physical Spice Cards may be ordered');
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
staging.push(`Ordered original undealt Spice Card singletons: ${kinds.join(', ')}; discard custody unchanged.`);
}
/** Relocates an actual original card, retaining its identity and the 33-card inventory. */
export function holdNexusSkillsModulesPaymentsCard(game: Game, actor: string, kind: Card['kind'] | 'karama' | 'truthtrance', staging: string[]): Card {
const player = nexusSkillsModulesPaymentsPlayer(game, actor);
const matches = (card: Card) => card.kind === kind || card.effect === kind;
const held = player.hand.find(matches);
if (held) return held;
assert.ok(player.hand.length < handLimit(player));
const index = game.deck.findIndex(matches);
const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined;
assert.ok(index >= 0 || donor, `An original ${kind} card must remain in real deck/hand custody`);
const card = index >= 0 ? game.deck.splice(index, 1)[0]
: donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0];
player.hand.push(card);
staging.push(`Conserved original ${card.id}: ${donor?.id ?? 'undealt deck'} custody to ${actor}'s hand.`);
return card;
}
/** Full original setup, first Storm assignment, first END Mentat claim and real
* unallied closing draw precede the payment. Only physical positioning/order is staged. */
export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModulesPaymentsOptions = {}): NexusSkillsModulesPaymentsFixture {
const initial = initializeNexusSkillsModulesPaymentsSetup(options);
return originalRandom(() => {
const kind = options.kind ?? 'emperor-revival';
const actor = initial.players.find(p => p.faction === (options.ownerFaction ?? 'atreides'))!.id;
const opponent = initial.players.find(p => p.id !== actor)!.id;
const actions: NexusSkillsModulesPaymentStep[] = [], staging: string[] = [];
let game = initial;
while (game.status === 'setup') {
let next = nextNexusSkillsModulesPaymentsNativeStep(game); assert.ok(next);
if (game.setupStage === 'leaderSkills' && next.actor === actor && options.skill) {
const view = viewGame(game, actor).leaderSkills!;
assert.ok(view.offer!.cards.includes(options.skill), 'Explicit skill must exist in the saved original offer');
assert.ok(!view.unavailableSkills?.[options.skill]);
next = { actor, action: { ...next.action, skill: options.skill } };
}
actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
const afterSetup = structuredClone(game);
reserveBoard(game, staging);
if (game.strongholdCards) {
const player = nexusSkillsModulesPaymentsPlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= 1);
player.reserves--; player.forces['arrakeen:10'] = 1;
staging.push(`Conserved one ordinary ${actor} reserve counter to Arrakeen before the real first END Mentat claim.`);
}
game = advanceNexusSkillsModulesPaymentsToPhase(game, 1, actions);
orderUndealtSpice(game, ['land', 'land'], staging);
game = advanceNexusSkillsModulesPaymentsToPhase(game, 8, actions);
let beforeFirstMentat: Game | undefined, firstMentatStep: NexusSkillsModulesPaymentStep | undefined;
while (game.turn === 1) {
const before = structuredClone(game), next = nextNexusSkillsModulesPaymentsNativeStep(game); assert.ok(next);
game = advanceNexusSkillsModulesPaymentsStep(game, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
if (game.strongholdCards) assert.equal(game.strongholdCards.owners.arrakeen, actor);
game = advanceNexusSkillsModulesPaymentsToPhase(game, 1, actions);
reserveBoard(game, staging);
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(p => p.id !== actor); assert.ok(others.length >= 2);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
const action: Action = { type: 'alliance', target: to };
actions.push({ actor: from, action }); game = applyAction(game, from, action);
}
allied = true;
}
game = advanceNexusSkillsModulesPaymentsStep(game, actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const card = kind === 'fremen-revival' ? 'fremen' : 'emperor';
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Ordered original undealt ${card} Nexus singleton before a genuine closing draw; no card grant.`);
const beforeClosingDraw = structuredClone(game);
const closingDraw: NexusSkillsModulesPaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
assert.equal(game.nexusCards!.cards!.hands[actor], card);
const afterClosingDraw = structuredClone(game);
game = advanceNexusSkillsModulesPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
game = advanceNexusSkillsModulesPaymentsToPhase(game, kind === 'native-shipment' ? 5 : kind === 'emperor-purchase' ? 3 : 4, actions);
let paymentAction: Action, revivalAction: Action | undefined, destination: string | undefined, cost = 0;
const amount = kind === 'native-shipment' ? options.amount ?? 3 : kind === 'emperor-purchase' ? 1 : 3;
if (kind === 'emperor-purchase') {
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
if (game.auction?.active === actor && !game.auction.bid) {
const action: Action = { type: 'bid', amount: 2 };
actions.push({ actor, action }); game = applyAction(game, actor, action);
} else game = advanceNexusSkillsModulesPaymentsStep(game, actions);
}
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision!.player, actor);
assert.equal(game.auction!.bid, 2); assert.equal(game.auction!.bidder, actor);
cost = game.auction!.bid;
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
} else if (kind === 'native-shipment') {
game = advanceNexusSkillsModulesPayments(game, state => state.active === actor && clean(state), actions);
const player = nexusSkillsModulesPaymentsPlayer(game, actor);
const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm)
&& game.players.every(p => Object.keys(p.forces).every(key => !key.startsWith(`${t.id}:`)))
&& (player.faction !== 'fremen' || territory('the_great_flat').sectors.some(from =>
t.sectors.some(to => distance(`the_great_flat:${from}`, `${t.id}:${to}`) <= 2))));
assert.ok(target, 'An empty native-legal stronghold destination must exist');
destination = `${target.id}:${target.sectors[0]}`;
cost = player.faction === 'fremen' ? 0 : player.faction === 'guild' ? Math.ceil(amount / 2) : amount;
assert.ok(player.spice >= cost);
paymentAction = { type: 'ship', territory: target.id, sector: target.sectors[0], amount, smuggler: false };
} else {
const player = nexusSkillsModulesPaymentsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks);
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
player.reserves -= tanks; player.tanks += tanks;
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${actor}: ${tanks} original reserve counters to Tanks, including ${elite} elites; no revival receipt staged.`);
const view = viewGame(game, actor);
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive',
event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
else {
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
}
}
nexusSkillsModulesPaymentsInventory(game);
const token: TechId = kind === 'native-shipment' ? 'heighliners' : kind === 'emperor-purchase' ? 'production' : 'axlotl';
const tokenOwner = game.techTokens?.[token].owner ?? null;
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw, closingDraw,
afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, revivalAction, amount, cost,
token, tokenOwner, tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, destination, actions, staging };
});
}
/** Commits the payment and optional native return, exposing actual settlement responses. */
export function payNexusSkillsModulesPaymentsFixture(fixture: NexusSkillsModulesPaymentsFixture, state = fixture.game): Game {
let game = applyAction(state, fixture.actor, fixture.paymentAction);
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
return game;
}
export function finishNexusSkillsModulesPaymentsFixture(_fixture: NexusSkillsModulesPaymentsFixture, state: Game,
actions?: NexusSkillsModulesPaymentStep[]): Game {
return advanceNexusSkillsModulesPayments(state, game => clean(game) && !game.pendingRevival && !game.pendingShipment, actions);
}
export function nexusSkillsModulesPaymentsInventory(game: Game): void {
classicNexusSkillsPaymentsInventory(game);
if (game.techTokens) assert.deepEqual(Object.keys(game.techTokens).sort(), TECH_TOKENS.map(t => t.id).sort());
if (game.strongholdCards) assert.deepEqual(Object.keys(game.strongholdCards.owners).sort(), STRONGHOLD_CARDS.map(c => c.id).sort());
}