Files
Dune/tests/fixture-mixed-nexus-modules.ts

334 lines
21 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame,
} from '../game/engine';
import type { Action, Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { treacheryDeck } from '../game/cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { richeseCards } from '../game/richese-cards';
import { territory } from '../game/board';
import { traitorDeck } from '../game/traitors';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nextSingleNexusE1Step, shipSingleNexusE1, quoteSingleNexusE1Battle,
openSingleNexusE1Battle, revealSingleNexusE1Plans } from './fixture-single-nexus-e1';
import { nativeHomeworldInventory, nativeHomeworldPlayer, nativeHomeworldLeader,
frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export interface MixedNexusStep { actor: string; action: Action }
export interface MixedNexusTransition { before: Game; step: MixedNexusStep; after: Game }
export interface MixedNexusOptions {
initial?: Game;
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
roster?: readonly FactionId[];
/** Selects only an original undealt offer, never changes a supplied offer. */
skillOwner?: FactionId;
skill?: LeaderSkillId;
fuelKind?: 'special' | 'shield';
}
export interface MixedNexusSetup {
initial: Game; setup: Game; offered: Game | null; ixOffer: MixedNexusTransition | null;
afterSetup: Game; trainers: Record<string, string>; dancerLeader: string | null;
seats: Partial<Record<FactionId, string>>; actions: MixedNexusStep[];
}
export interface MixedNexusProgramme extends MixedNexusSetup {
game: Game;
firstArrival: MixedNexusTransition | null;
firstMarker: MixedNexusTransition | null;
reveal: MixedNexusTransition | null;
entry: MixedNexusTransition | null;
vote: Game | null;
ride: MixedNexusTransition | null;
firstMentat: MixedNexusTransition | null;
alliance: Game | null;
drawing: Game | null;
draws: MixedNexusTransition[];
}
export const mixedNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const mixedNexusPlayer = nativeHomeworldPlayer;
export const mixedNexusLeader = nativeHomeworldLeader;
export const mixedNexusQuote = quoteSingleNexusE1Battle;
export const mixedNexusShip = shipSingleNexusE1;
export const mixedNexusOpenBattle = openSingleNexusE1Battle;
export const mixedNexusRevealPlans = revealSingleNexusE1Plans;
export const mixedNexusClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
/** Response queues have priority. Physical choices belong to the human, not to
* this scheduler. In particular a quoted casualty menu is not a default dial. */
export function nextMixedNexusStep(g: Game): MixedNexusStep | null {
if (!g.response && !g.phaseOpening && g.decision) {
const d = g.decision;
if (['battleLosses', 'sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
d.kind === 'techToken' && !!g.battle) return null;
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
}
return nextSingleNexusE1Step(g);
}
export function stepMixedNexus(g: Game, step: MixedNexusStep, actions?: MixedNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(mixedNexusReload(g), step.actor, step.action));
}
export function advanceMixedNexus(g: Game, until: (g: Game) => boolean, actions?: MixedNexusStep[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextMixedNexusStep(g);
assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`);
g = stepMixedNexus(g, next, actions);
}
throw Error('Original mixed E1/E2 chronology did not reach its owned boundary.');
}
export function settleMixedNexus(g: Game, actions?: MixedNexusStep[]): Game {
return advanceMixedNexus(g, q => mixedNexusClean(q) && !q.pendingShipment &&
!q.pendingHomeworldShipment && !q.pendingChoamWorthless, actions);
}
function transition(g: Game, step: MixedNexusStep, actions: MixedNexusStep[]): MixedNexusTransition {
return { before: mixedNexusReload(g), step: structuredClone(step), after: stepMixedNexus(g, step, actions) };
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const current = [...source], 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) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
return rolls;
}
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function mixedNexusInventory(g: Game): void {
if (g.homeworlds) nativeHomeworldInventory(g);
else for (const p of g.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((n, f) => n + f, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
}
if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players);
if (g.discoveries) validateDiscoveryState(g.discoveries);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []),
...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['ix', 'choam']),
...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
validateNexusCards(g.nexusCards!.cards!, g.players);
}
/** Original rules sources: Nexus closing, E1/E2 native powers, Homeworlds and
* Discovery composition; no companion is implied by another seated family.
* Assigned original training is valid input: pending offers alone continue. */
export function createMixedNexusSetup(options: MixedNexusOptions = {}): MixedNexusSetup {
const roster: readonly FactionId[] = options.roster ?? ['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'];
let g = options.initial ? mixedNexusReload(options.initial)
: createGame('MIXEDNEXUS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ix', 'choam']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.deepEqual([...g.expansions].sort(), ['choam', 'ix']);
assert.equal(g.advanced, options.advanced ?? g.advanced);
assert.ok(g.players.length >= 2 && g.players.length <= 6);
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
assert.ok(g.players.some(p => ['ixians', 'tleilaxu'].includes(p.faction)) &&
g.players.some(p => ['choam', 'richese'].includes(p.faction)));
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
'Continue only an original lobby or its original setup, never a played save.');
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : true);
const discovery = options.discovery ?? !!g.discoveryEnabled;
const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds,
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
assert.ok(!modules.strongholdCards || g.advanced);
const actions: MixedNexusStep[] = [], seats: Partial<Record<FactionId, string>> = {};
for (const p of g.players) seats[p.faction] = p.id;
const skillOwner = seats[options.skillOwner ?? 'ixians'] ?? g.players[0].id;
const passives: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz']
.filter(s => s !== (options.skill ?? 'suk-graduate')) as LeaderSkillId[];
const selected = g.players.map(p => p.id === skillOwner ? options.skill ?? 'suk-graduate' : passives.shift()!);
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
g = stepMixedNexus(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
assert.ok(g.nexusCards?.cards, 'A supplied original deal cannot acquire a new deck.');
}
const initial = mixedNexusReload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = stepMixedNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
const source = LEADER_SKILL_CARDS.map(c => c.id), rest = source.filter(s => !selected.includes(s));
const skillOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const deck = treacheryDeck(g.expansions), remaining = [...deck], prefix = [];
const dealOrder = seats.ixians ? [mixedNexusPlayer(g, seats.ixians), ...g.players.filter(p => p.id !== seats.ixians)] : g.players;
for (const p of dealOrder) {
const kind = p.faction === 'choam' ? options.fuelKind ?? 'special' : 'worthless';
const at = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama'));
assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
}
const rolls = [...(skills ? permutation(source, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(deck.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
const initialize = () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
g = options.initial ? initialize() : lottery(rolls, initialize);
}
const setup = mixedNexusReload(g); let offered: Game | null = null, ixOffer: MixedNexusTransition | null = null;
const trainers: Record<string, string> = {};
for (const a of g.leaderSkills?.assignments ?? []) trainers[a.owner] = a.leader;
const target = seats.ixians ? trainers[seats.ixians] ?? mixedNexusPlayer(g, seats.ixians).leaders.slice()
.sort((a, b) => b.strength - a.strength)[0].id : null;
let dancerLeader = target;
for (let n = 0; g.status === 'setup' && n < 250; n++) {
let next: MixedNexusStep;
if (g.decision?.kind === 'ixSetup') {
const offer = viewGame(g, g.decision.player).ixTechnology!.setup!;
const card = offer.find(c => c.kind === 'worthless') ?? offer[0]; assert.ok(card);
ixOffer = transition(g, { actor: g.decision.player, action: { type: 'decision', card: card.id } }, actions);
g = ixOffer.after; continue;
}
if (g.setupStage === 'leaderSkills' && !g.decision) {
offered ??= mixedNexusReload(g);
const actor = Object.keys(g.leaderSkills!.offers)[0], view = viewGame(g, actor).leaderSkills!, offer = view.offer!;
const wanted = selected[g.players.findIndex(p => p.id === actor)];
const skill = options.initial ? offer.cards.find(s => !view.unavailableSkills?.[s])
: offer.cards.find(s => s === wanted && !view.unavailableSkills?.[s]); assert.ok(skill);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = mixedNexusPlayer(g, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader); trainers[actor] = leader.id;
if (actor === seats.ixians) dancerLeader = leader.id;
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const candidate = nextMixedNexusStep(g); assert.ok(candidate); next = candidate;
if (next.action.type === 'traitor') {
const choices = mixedNexusPlayer(g, next.actor).traitorChoices;
next.action.leader = choices.find(id => id !== dancerLeader) ?? choices[0];
}
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
if (!options.initial && seats.tleilaxu && dancerLeader && g.setupStage === 'forces' && next.action.type === 'fremenSetup') {
const held = new Set(g.players.flatMap(p => p.traitors)), source = traitorDeck(g.players, true).filter(id => !held.has(id));
assert.ok(source.includes(dancerLeader)); actions.push(structuredClone(next));
g = lottery(permutation(source, [dancerLeader, ...source.filter(id => id !== dancerLeader)]),
() => applyAction(mixedNexusReload(g), next.actor, next.action));
} else g = stepMixedNexus(g, next, actions);
}
assert.equal(g.status, 'playing'); mixedNexusInventory(g);
return { initial, setup, offered, ixOffer, afterSetup: mixedNexusReload(g), trainers, dancerLeader, seats, actions };
}
/** Only original unplayed source-order permutations and unused lotteries are
* selected. Every army, invoice, token, alliance and reward is action-produced.
* Two seats have no legal closing qualification; their endpoint is native setup. */
export function createMixedNexusProgramme(options: MixedNexusOptions = {}): MixedNexusProgramme {
const s = createMixedNexusSetup(options), actions = s.actions;
let g = mixedNexusReload(s.afterSetup);
const result: MixedNexusProgramme = { ...s, game: g, firstArrival: null, firstMarker: null, reveal: null,
entry: null, vote: null, ride: null, firstMentat: null, alliance: null, drawing: null, draws: [] };
if (g.players.length === 2) return result;
const guild = s.seats.guild, fremen = s.seats.fremen;
const discovery = !!g.discoveries;
const collector = s.seats.ixians ?? guild ?? g.players[0].id;
assert.ok(!discovery || guild && fremen, 'Maker programme uses original Guild and Fremen seats.');
frontNativeHomeworldSpice(g, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const offset = g.advanced ? 2 : 1;
frontNativeHomeworldSpice(g, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), offset);
frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', offset + 1);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', offset + 2);
g = advanceMixedNexus(g, q => q.phase === 1 && mixedNexusClean(q), actions);
if (discovery) {
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const next = nextMixedNexusStep(g); assert.ok(next); actions.push(structuredClone(next));
g = withClassicDiscoveryNexusToken(g, 'shrine', () => applyAction(mixedNexusReload(g), next.actor, next.action));
}
assert.ok(g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
}
const needed = new Set([...(discovery ? [collector] : []), ...(s.seats.richese ? [s.seats.richese] : [])]);
while (needed.size) {
g = advanceMixedNexus(g, q => q.phase === 5 && mixedNexusClean(q) && needed.has(q.active!), actions);
const actor = g.active!;
if (actor === s.seats.richese) {
const p = mixedNexusPlayer(g, actor), token = p.noField!.tokens.find(t => t.value === 0)!;
result.firstMarker = transition(g, { actor, action: { type: 'ship', territory: 'habbanya_ridge_sietch',
sector: territory('habbanya_ridge_sietch').sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
g = settleMixedNexus(result.firstMarker.after, actions); result.firstMarker.after = mixedNexusReload(g);
g = stepMixedNexus(g, { actor, action: { type: 'revealNoField', token: token.id, event: mixedNexusPlayer(g, actor).noFieldEvent } }, actions);
} else {
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const elite = mixedNexusPlayer(g, actor).faction === 'ixians' ? 1 : 0;
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), 3, elite) : undefined;
if (g.homeworlds) assert.ok(sources);
result.firstArrival = transition(g, { actor, action: { type: 'ship', territory: placement.territory,
sector: placement.sector, amount: 3, elite, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) } }, actions);
g = settleMixedNexus(result.firstArrival.after, actions); result.firstArrival.after = mixedNexusReload(g);
}
needed.delete(actor);
if (needed.size) g = stepMixedNexus(g, { actor, action: { type: 'endMovement' } }, actions);
}
if (discovery) {
g = advanceMixedNexus(g, q => q.phase === 7 && mixedNexusClean(q), actions);
const token = g.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
g = stepMixedNexus(g, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
result.reveal = transition(g, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = result.reveal.after;
}
g = advanceMixedNexus(g, q => q.phase === 8 && mixedNexusClean(q), actions);
while (g.turn === 1) {
const next = nextMixedNexusStep(g); assert.ok(next); const t = transition(g, next, actions); g = t.after;
if (g.turn === 2) result.firstMentat = t;
}
if (discovery) {
g = advanceMixedNexus(g, q => q.decision?.kind === 'discoveryEntry' && q.decision.player === collector, actions);
const view = viewGame(g, collector), sources = view.discoveryEntry!.sources;
const action = discoveryEntryMoveAction(view, mixedNexusPlayer(g, collector).faction === 'ixians'
? sources.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : sources); assert.ok(action);
result.entry = transition(g, { actor: collector, action }, actions); g = result.entry.after;
g = advanceMixedNexus(g, q => q.decision?.kind === 'greatMakerVote', actions); result.vote = mixedNexusReload(g);
while (g.decision?.kind === 'greatMakerVote') g = stepMixedNexus(g,
{ actor: g.decision.player, action: { type: 'decision', event: g.decision.event, yes: true } }, actions);
assert.ok(g.decision?.kind === 'greatMakerRide' && g.decision.player === fremen);
const actionRide = greatMakerRideAction(viewGame(g, fremen!), 'polar_sink', 0, 2, g.advanced ? 1 : 0); assert.ok(actionRide);
result.ride = transition(g, { actor: fremen!, action: actionRide }, actions); g = result.ride.after;
}
g = advanceMixedNexus(g, q => !!q.nexus && !q.spiceWindow && !q.spiceResolution && mixedNexusClean(q), actions);
const pair = guild && fremen ? [guild, fremen] : g.players.slice(-2).map(p => p.id);
for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]])
g = stepMixedNexus(g, { actor, action: { type: 'alliance', target } }, actions);
result.alliance = mixedNexusReload(g);
g = advanceMixedNexus(g, q => q.nexusCards?.phase?.stage === 'drawing' && mixedNexusClean(q), actions);
result.drawing = mixedNexusReload(g);
for (const actor of g.nexusCards!.phase!.eligible) {
const face = mixedNexusPlayer(g, actor).faction, cards = g.nexusCards!.cards!;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(g, { actor, action: { type: 'nexusCardChoice', turn: g.turn,
card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
result.draws.push(draw); g = draw.after;
}
g = advanceMixedNexus(g, q => q.phase === 5 && mixedNexusClean(q), actions);
mixedNexusInventory(g); result.game = g; return result;
}