Files
Dune/tests/fixture-nexus-module-battles.ts

260 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import { type Card } from '../game/cards';
import { validateNexusCards, nexusCardMode } from '../game/nexus-cards';
import { ownedTech } from '../game/tech-tokens';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nexusInventory, orderNexusSpice } from './fixture-nexus-cards';
export type NexusModuleBattleOptions = {
initial?: Game;
family?: 'emperor' | 'retention';
tech?: boolean;
strongholds?: boolean;
advanced?: boolean;
};
export type NexusModuleBattleFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game;
beforeBattle: Game;
battleStep: StrongholdFactionsNativeStep;
game: Game;
owner: string;
opponent: string;
observer: string;
location: string;
family: 'emperor' | 'retention';
played: Card;
unused: Card;
defense: Card;
planActions: StrongholdFactionsNativeStep[];
actions: StrongholdFactionsNativeStep[];
staging: string[];
};
export const nexusModuleBattlePlayer = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing classic Nexus seat ${actor}`);
return player;
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Extend the original native continuation only for actual Nexus aftermath.
* Unsealed private plans remain a human boundary, not bot-invented fixture state. */
export function nextNexusModuleBattleStep(game: Game): StrongholdFactionsNativeStep | null {
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'wormRide')
return { actor: d.player, action: { type: 'decision', accept: false } };
if (d?.kind === 'nexusFremenCunningOffer' || d?.kind === 'nexusFremenCunningRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d?.kind === 'moritaniRetention' && d.source === 'nexus')
return { actor: d.player, action: { type: 'decision', event: d.event, keep: null } };
if (d?.kind === 'nexusChoamInspection')
return { actor: d.player, action: { type: 'decision', event: d.event, inspect: false } };
if (d?.kind === 'techToken')
return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
}
return nextStrongholdFactionsNativeStep(game);
}
function entropy<T>(fn: () => 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 fn(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
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');
const step = nextNexusModuleBattleStep(game);
assert.ok(step, `Human plan boundary before requested continuation at ${game.turn}/${game.phase}`);
// Real dials, not a staged Storm position; both printed sites survive.
if (step.action.type === 'stormDial' && game.turn === 1) step.action.amount = 0;
game = run(game, step, actions);
}
throw Error('Classic Nexus battle continuation did not reach its boundary');
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, location: string, count: number): void {
const player = nexusModuleBattlePlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= count);
player.reserves -= count;
player.forces[location] = count;
}
function hold(game: Game, actor: string, kind: Card['kind']): Card {
const index = game.deck.findIndex(card => card.kind === kind);
assert.ok(index >= 0, `Missing canonical ${kind}`);
const card = game.deck.splice(index, 1)[0];
nexusModuleBattlePlayer(game, actor).hand.push(card);
return card;
}
/** Genuine all-twelve setup, actual turn-two worm, reciprocal observer alliance
* and applyAction closing Nexus deals. Conserved physical deck order is explicit.
* Phases, wallets, first-Storm Tech and first END Mentat custody are never staged. */
export function createNexusModuleBattleFixture(options: NexusModuleBattleOptions = {}): NexusModuleBattleFixture {
const family = options.family ?? 'retention', tech = options.tech ?? true;
const strongholds = options.strongholds ?? true, advanced = options.advanced ?? true;
assert.ok(tech || strongholds);
if (strongholds || family === 'emperor') assert.equal(advanced, true);
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
let game = options.initial ? structuredClone(options.initial)
: createGame('NEXUSMODULEBATTLE', newPlayer('emperor', 'Emperor', 'emperor'), advanced, []);
if (!options.initial) {
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
}
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'fremen', 'guild']);
assert.deepEqual(game.expansions, []);
assert.equal(game.advanced, advanced);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length));
if (game.status === 'lobby') {
// Offline audit configuration only; no public Nexus start/toggle is added.
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
if (!!game.techTokens !== tech) game = run(game, { actor: game.host, action: { type: 'techTokens', enabled: tech } }, actions);
if (!!game.strongholdCards !== strongholds) game = run(game, { actor: game.host, action: { type: 'strongholdCards', enabled: strongholds } }, actions);
for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeNexusGameForAudit(game));
}
assert.equal(!!game.techTokens, tech);
assert.equal(!!game.strongholdCards, strongholds);
const initial = structuredClone(game);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const observer = game.players.find(p => p.faction === 'atreides')!.id;
const partner = game.players.find(p => p.faction === 'fremen')!.id;
const location = `arrakeen:${territory('arrakeen').sectors[0]}`;
validateNexusCards(game.nexusCards!.cards!, game.players);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderNexusSpice(game, ['land', 'land']);
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
game = advance(game, state => state.phase === 8 && clean(state), actions);
place(game, owner, location, 1);
staging.push('Before real first END Mentat, conserved one ordinary Emperor counter controls Arrakeen; subtype reserves, real banks and first-Storm Tech retained.');
let beforeFirstMentat!: Game, firstMentatStep!: StrongholdFactionsNativeStep;
while (game.turn === 1) {
const step = nextNexusModuleBattleStep(game); assert.ok(step);
const before = structuredClone(game);
game = run(game, step, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
const afterFirstMentat = structuredClone(game);
if (strongholds) assert.equal(game.strongholdCards!.owners.arrakeen, owner);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderNexusSpice(game, ['worm', 'land', 'land']);
game = advance(game, state => state.nexus === true && !state.spiceWindow, actions);
game = run(game, { actor: observer, action: { type: 'alliance', target: partner } }, actions);
game = run(game, { actor: partner, action: { type: 'alliance', target: observer } }, actions);
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const deals = [
{ actor: owner, card: family === 'emperor' ? 'emperor' as const : 'choam' as const },
{ actor: opponent, card: 'moritani' as const },
];
for (const deal of deals) {
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes(deal.card));
cards.deck = [deal.card, ...cards.deck.filter(card => card !== deal.card)];
game = run(game, { actor: deal.actor, action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0,
} }, actions);
}
assert.equal(nexusCardMode(family === 'emperor' ? 'emperor' : 'choam', 'emperor', game.players.map(p => p.faction)), family === 'emperor' ? 'cunning' : 'secretAlly');
assert.equal(nexusCardMode('moritani', 'guild', game.players.map(p => p.faction)), 'secretAlly');
staging.push('Conserved all12 Nexus deck order; actual turn-two worm and Atreides/Fremen alliance open original applyAction closing deals for unallied Emperor/Guild.');
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
// Return genuine setup hands before selecting canonical controlled battle cards.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
place(game, owner, location, 7);
place(game, opponent, location, 6);
const played = hold(game, opponent, 'projectile');
const unused = hold(game, opponent, 'worthless');
const defense = hold(game, owner, 'shield');
staging.push('Turn-two movement: conserved seven ordinary Emperor and six Guild board counters; original projectile/worthless/shield cards relocated from physical deck, no bank/owner/phase writes.');
if (tech) assert.equal(ownedTech(game.techTokens, opponent).length, 1);
game = advance(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game);
const battleStep: StrongholdFactionsNativeStep = { actor: game.active!, action: { type: 'chooseBattle', territory: 'arrakeen', target: game.active === owner ? opponent : owner } };
assert.ok([owner, opponent].includes(battleStep.actor));
game = run(game, battleStep, actions);
game = advance(game, state => clean(state) && !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation, actions);
const best = nexusModuleBattlePlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const weakest = nexusModuleBattlePlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(best && weakest);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: best.id, dial: 6, support: advanced ? 6 : 0, defense: defense.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: weakest.id, dial: 0, support: 0, weapon: played.id } },
];
nexusInventory(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleStep, game, owner, opponent, observer, location, family, played, unused, defense, planActions, actions, staging };
}
export function revealNexusModuleBattle(fixture: NexusModuleBattleFixture, game = fixture.game, cunning = false): Game {
if (cunning) {
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = applyAction(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
game = advance(game, clean, []);
}
for (const step of fixture.planActions) {
const action: Action = { ...step.action };
if (cunning && step.actor === fixture.owner) action.support = 3;
game = applyAction(game, step.actor, action);
}
assert.ok(game.battle?.revealed);
return game;
}
/** Quote actual holders, committed plans and physical ordinary/elite pools. */
export function quoteNexusModuleBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = nexusModuleBattlePlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor] };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, beneficiary: actor,
called: battle.traitorCalls[actor] ?? false, traitors: nexusModuleBattlePlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: !!game.moritaniRetention,
nexusMoritani: true });
}
export function advanceNexusModuleBattle(game: Game, until: (state: Game) => boolean): Game {
return advance(game, until, []);
}