Files
Dune/tests/fixture-discovery-paired-nexus.ts

316 lines
22 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializePairedNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { MOBILE_LOCATION, gameTerritories, territory, validGameLocation } from '../game/board';
import { isAuditorLeader, type Card } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nextPairedIxNexusModulesStep } from './fixture-paired-ix-nexus-modules';
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
import {
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
} from './fixture-discovery-classic-nexus';
export type PairedDiscoveryNexusStep = { actor: string; action: Action };
export type PairedDiscoveryNexusOptions = {
/** Authenticated original ready lobby or original unassigned turn-one setup. */
initial?: Game;
family?: 'ix' | 'choam';
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
};
export type PairedDiscoveryNexusTransition = { before: Game; step: PairedDiscoveryNexusStep; after: Game };
export type PairedDiscoveryNexusFixture = {
initial: Game; setup: Game; afterSetup: Game; collection: Game;
firstMentat: PairedDiscoveryNexusTransition; game: Game;
family: 'ix' | 'choam'; owner: string; native: string; collector: string;
guild: string; fremen: string; token: string; source: string; claim: 'arrakeen' | 'carthag';
actions: PairedDiscoveryNexusStep[]; staging: string[];
};
export type PairedDiscoveryNexusClosing = {
entry: PairedDiscoveryNexusTransition; vote: Game; ride: PairedDiscoveryNexusTransition;
alliance: Game; drawing: Game; afterDraw: Game;
};
export type PairedDiscoveryNexusBattle = {
game: Game; beforeBattle: Game; battleStep: PairedDiscoveryNexusStep;
winner: string; loser: string; leader: string; enemy: string;
weapon: string; defense: string; source: string; plans: PairedDiscoveryNexusStep[];
};
export const pairedDiscoveryNexusClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export function pairedDiscoveryNexusPlayer(game: Game, actor: string): Player {
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
}
/** Do not automate owned human entry, vote, ride, declarations or battle plans
* in the returned programmes. These defaults only cross unrelated native windows. */
export function nextPairedDiscoveryNexusStep(game: Game): PairedDiscoveryNexusStep | null {
if (!game.phaseOpening && !game.response && game.decision) {
const d = game.decision;
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d.kind === 'greatMakerVote')
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
if (d.kind === 'faceDance') return null;
if (d.kind === 'discoveryDiscard') {
const card = pairedDiscoveryNexusPlayer(game, d.player).hand[0]; assert.ok(card);
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
}
}
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
}
export function applyPairedDiscoveryNexusStep(game: Game, step: PairedDiscoveryNexusStep,
actions?: PairedDiscoveryNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advancePairedDiscoveryNexus(state: Game, until: (g: Game) => boolean,
actions?: PairedDiscoveryNexusStep[]): Game {
let game = structuredClone(state);
for (let n = 0; n < 2400; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextPairedDiscoveryNexusStep(game);
assert.ok(step, 'This original paired programme needs its human plan or Face Dance.');
game = applyPairedDiscoveryNexusStep(game, step, actions);
}
throw Error('Paired Discovery/Nexus continuation did not reach the requested original boundary');
}
/** Never restart the supplied setup, clear its original hands, replace its
* private offers, assign actors, clocks, training, or manufacture a held card. */
export function initializePairedDiscoveryNexusForFixture(options: PairedDiscoveryNexusOptions = {}): Game {
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
: ['richese', 'choam', 'guild', 'fremen'] as const;
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
let game = options.initial ? structuredClone(options.initial)
: createGame('PAIREDDISCOVERYNEXUS', newPlayer(factions[0], factions[0], factions[0]), advanced, [family]);
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.players.every(p => !p.traitors.length && p.leaders.every(l => !l.dead && !l.usedAt)),
'Use the original lobby or unassigned turn-one native setup, including any native starting hand.');
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
game = options.initial ? initializePairedNexusGameForAudit(game)
: withClassicDiscoveryNexusLottery(() => initializePairedNexusGameForAudit(game));
}
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries && game.nexusCards?.cards);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
return game;
}
function orderSpice(game: Game): void {
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
const at = game.spiceDeck.findIndex(matches); assert.ok(at >= 0);
return game.spiceDeck.splice(at, 1)[0];
};
const cards = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')];
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
cards.push(take(c => 'worm' in c && !!c.greatMaker));
cards.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
game.spiceDeck.unshift(...cards);
}
export function reservePairedDiscoveryNexusBoard(game: Game, kept: string[] = []): void {
for (const p of game.players) for (const [key, count] of Object.entries(p.forces)) {
if (kept.includes(key)) continue;
p.reserves += count; delete p.forces[key];
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
}
}
/** Explicitly conserved original subtype groups; elite is a subset of total. */
export function placePairedDiscoveryNexus(game: Game, actor: string, key: string, total: number, elite = 0): void {
const p = pairedDiscoveryNexusPlayer(game, actor);
assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite &&
(p.elites?.reserves ?? 0) >= elite);
p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total;
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; }
}
export function holdPairedDiscoveryNexusCard(game: Game, actor: string, matches: (c: Card) => boolean,
staging?: string[]): Card {
const p = pairedDiscoveryNexusPlayer(game, actor), held = p.hand.find(matches);
if (held) return held;
const at = game.deck.findIndex(matches); assert.ok(at >= 0 && p.hand.length < handLimit(p));
const card = game.deck.splice(at, 1)[0]; p.hand.push(card);
staging?.push(`Conserved unplayed ${card.id}: original undealt deck → ${actor} hand; retain original starting hands.`);
return card;
}
/** Original pair, printed blow and physical token lottery, actual paid typed
* collection, inspection/reveal and END Mentat. Stops BEFORE turn-two Storm
* at the authenticated collector's real free-entry control. */
export function createPairedDiscoveryNexusFixture(options: PairedDiscoveryNexusOptions = {}): PairedDiscoveryNexusFixture {
let game = initializePairedDiscoveryNexusForFixture(options);
const initial = structuredClone(game), setup = structuredClone(game);
const family = game.expansions[0] as 'ix' | 'choam';
const actor = (faction: string) => game.players.find(p => p.faction === faction)!.id;
const owner = actor(family === 'ix' ? 'ixians' : 'richese');
const native = actor(family === 'ix' ? 'tleilaxu' : 'choam'), guild = actor('guild'), fremen = actor('fremen');
const collector = family === 'ix' ? owner : guild;
const actions: PairedDiscoveryNexusStep[] = [], staging = [
'Conserved original unplayed Spice order: printed Hagga Basin, optional Advanced Broken Land; then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.',
'Selected actual Shrine supply-token and original scalar Storm/shuffle lotteries only; all UUIDs, actors, setup offers and starting hands remain original.',
];
game = options.initial ? advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions)
: withClassicDiscoveryNexusLottery(() => advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions));
const afterSetup = structuredClone(game);
orderSpice(game);
game = advancePairedDiscoveryNexus(game, g => g.phase === 1 && pairedDiscoveryNexusClean(g), actions);
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextPairedDiscoveryNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
}
const token = game.discoveries!.tokens.find(t => t.face === 'shrine')!; assert.equal(token.status, 'placed');
const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const source = `${printed.territory}:${printed.sector}`;
game = advancePairedDiscoveryNexus(game, g => g.phase === 5 && g.active === collector && pairedDiscoveryNexusClean(g), actions);
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'ship', territory: printed.territory,
sector: printed.sector, amount: 3, elite: family === 'ix' ? 1 : 0, allyPayment: 0 } }, actions);
game = advancePairedDiscoveryNexus(game, g => g.phase === 7 && pairedDiscoveryNexusClean(g), actions);
const collection = structuredClone(game);
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
game = advancePairedDiscoveryNexus(game, g => g.phase === 8 && pairedDiscoveryNexusClean(g), actions);
reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]);
const claim = family === 'ix' ? 'arrakeen' : 'carthag';
placePairedDiscoveryNexus(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1);
placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2);
staging.push(`Before actual END Mentat: conserve original unrelated board groups to reserves, keep paid parent/HMS counters, place one ${owner} ${claim} claim counter and two ${native} Hagga Basin worm-source counters. No custody, clocks, paid history or wallets assigned.`);
let firstMentat: PairedDiscoveryNexusTransition | undefined;
for (let n = 0; game.turn === 1 && n < 100; n++) {
const before = structuredClone(game), step = nextPairedDiscoveryNexusStep(game); assert.ok(step);
game = applyPairedDiscoveryNexusStep(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advancePairedDiscoveryNexus(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.phase, 0); assert.equal(game.turn, 2); assert.equal(game.decision!.player, collector);
return { initial, setup, afterSetup, collection, firstMentat, game, family, owner, native, collector,
guild, fremen, token: token.id, source, claim, actions, staging };
}
/** Native human entry, all storm-order votes, exact Fremen typed reserve ride,
* both Spice piles, reciprocal classic alliance and actual closing singletons. */
export function closePairedDiscoveryNexus(f: PairedDiscoveryNexusFixture): PairedDiscoveryNexusClosing {
const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!;
const step: PairedDiscoveryNexusStep = { actor: f.collector, action: { type: 'decision', event: offer.event,
accept: true, groups: [{ source: f.source, normal: 1, elite: f.family === 'ix' ? 1 : 0 }] } };
let game = applyPairedDiscoveryNexusStep(before, step, f.actions);
const entry = { before, step, after: structuredClone(game) };
game = advancePairedDiscoveryNexus(game, g => g.greatMaker?.stage === 'vote', f.actions);
const vote = structuredClone(game);
while (game.decision?.kind === 'greatMakerVote') game = applyPairedDiscoveryNexusStep(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen);
const rideBefore = structuredClone(game), action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0);
assert.ok(action);
const rideStep = { actor: f.fremen, action }; game = applyPairedDiscoveryNexusStep(game, rideStep, f.actions);
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
game = advancePairedDiscoveryNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedDiscoveryNexusClean(g), f.actions);
game = applyPairedDiscoveryNexusStep(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions);
game = applyPairedDiscoveryNexusStep(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions);
const alliance = structuredClone(game);
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions);
const cards = game.nexusCards!.cards!;
const faces = f.family === 'ix' ? ['ixians', 'tleilaxu'] as const : ['richese', 'choam'] as const;
for (const face of faces) assert.ok(cards.deck.includes(face));
cards.deck = [...faces, ...cards.deck.filter(c => !faces.some(face => face === c))];
f.staging.push('Conserve undealt all12 singleton order only; actual unallied closing draw actions deal the two native Cunning cards AFTER both Advanced piles and reciprocal alliance.');
const drawing = structuredClone(game);
for (const actor of [f.owner, f.native]) game = applyPairedDiscoveryNexusStep(game,
{ actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, f.actions);
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions);
return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) };
}
export function pairedDiscoveryRicheseRequest(f: PairedDiscoveryNexusFixture, game: Game): Action {
assert.equal(f.family, 'choam'); assert.equal(validGameLocation(game, 'shrine', 0), true);
const p = pairedDiscoveryNexusPlayer(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning!;
assert.ok(offer && !offer.blocked);
return { type: 'ship', territory: 'shrine', sector: 0, noField: p.noField!.tokens.find(t => t.value === 3)!.id,
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
}
export function settlePairedDiscoveryNexusShipment(game: Game): Game {
return advancePairedDiscoveryNexus(game, g => !g.pendingShipment && pairedDiscoveryNexusClean(g));
}
/** Conserved original private identities only, never revealed-stock/paid history.
* Select the genuine Face Dancer from its existing stock or exchange with the
* exact original unplayed Traitor/Face Dancer custody, before the native battle. */
function matchDancer(game: Game, actor: string, leader: string, staging: string[]): void {
const stock = pairedDiscoveryNexusPlayer(game, actor).faceDancers!;
if (stock.some(c => c.leader === leader && !c.revealed)) return;
const dancer = stock.find(c => !c.revealed); assert.ok(dancer);
const old = dancer.leader, index = game.traitorReserve!.indexOf(leader);
if (index >= 0) game.traitorReserve![index] = old;
else {
const holder = game.players.find(p => p.traitors.includes(leader)); assert.ok(holder);
holder.traitors[holder.traitors.indexOf(leader)] = old;
}
dancer.leader = leader;
staging.push(`Conserved original unplayed private identities: ${leader} ↔ ${old}; no revealed flag or replacement history assigned.`);
}
/** Native nested battle after actual entry and closing deal. E2 begins only
* AFTER real paid two-marker materialization/reveal, never mixed marker plans. */
export function preparePairedDiscoveryNexusBattle(f: PairedDiscoveryNexusFixture, state: Game): PairedDiscoveryNexusBattle {
let game = advancePairedDiscoveryNexus(state, g => g.phase === 5 && pairedDiscoveryNexusClean(g));
reservePairedDiscoveryNexusBoard(game, f.family === 'ix' ? [MOBILE_LOCATION] : ['shrine:0']);
const loser = f.family === 'ix' ? f.native : f.guild;
if (f.family === 'ix') {
placePairedDiscoveryNexus(game, f.owner, 'shrine:0', 6, 3);
placePairedDiscoveryNexus(game, f.native, f.source, 3);
} else {
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).forces['shrine:0'], 8);
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).noField!.deployed, null);
// The collector's earned entry counter is conserved back to its own reserve;
// only Richese's actually paid/materialized eight-counter group stays here.
const collector = pairedDiscoveryNexusPlayer(game, f.collector);
collector.reserves += collector.forces['shrine:0'] ?? 0;
delete collector.forces['shrine:0'];
}
placePairedDiscoveryNexus(game, loser, 'shrine:0', 6);
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== 'hagga_basin' && t.id !== 'gara_kulon' &&
t.sectors.some(s => s >= 9 && s !== game.storm)); assert.ok(other);
const key = `${other.id}:${other.sectors.find(s => s >= 9 && s !== game.storm)!}`;
for (const actor of [f.owner, loser]) placePairedDiscoveryNexus(game, actor, key, 1);
const leader = pairedDiscoveryNexusPlayer(game, f.owner).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => b.strength - a.strength)[0];
const enemy = pairedDiscoveryNexusPlayer(game, loser).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && enemy);
if (f.family === 'ix') matchDancer(game, f.native, leader.id, f.staging);
const weapon = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'projectile', f.staging).id;
const defense = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'shield', f.staging).id;
f.staging.push('Before actual Movement completion: conserve exact original subtype reserve/board groups into the REVEALED Shrine, parent replacement source and second native conflict; retain original hands, earned held cards, wallets and real Nexus deals.');
game = advancePairedDiscoveryNexus(game, g => g.phase === 6 && pairedDiscoveryNexusClean(g));
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === loser);
const battleStep: PairedDiscoveryNexusStep = { actor, action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? loser : f.owner } };
game = applyPairedDiscoveryNexusStep(game, battleStep);
game = advancePairedDiscoveryNexus(game, g => !!g.battle && !g.battle.revealed && !nextPairedDiscoveryNexusStep(g));
const plans: PairedDiscoveryNexusStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', leader: leader.id, dial: f.family === 'ix' ? 2 : 3,
support: f.family === 'choam' && game.advanced ? 3 : 0, weapon, defense } },
{ actor: loser, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
];
return { game, beforeBattle, battleStep, winner: f.owner, loser, leader: leader.id, enemy: enemy.id, weapon, defense, source: f.source, plans };
}
export function revealPairedDiscoveryNexusBattle(b: PairedDiscoveryNexusBattle, state = b.game): Game {
let game = structuredClone(state);
for (const step of b.plans) {
game = applyPairedDiscoveryNexusStep(game, step);
game = advancePairedDiscoveryNexus(game, g => !g.response && g.decision?.kind !== 'fullPlanOffer');
}
assert.ok(game.battle?.revealed); return game;
}