Files
Dune/tests/fixture-e3-nexus-ecaz.ts

323 lines
20 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import type { Difficulty } from '../game/bot-profiles';
import { territory } from '../game/board';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { nexusCardAction } from '../game/nexus-card-options';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nextNativeE3HomeworldStep, nativeE3HomeworldPlayer, nativeE3HomeworldPool } from './fixture-homeworld-native-e3-modules';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export { nativeE3HomeworldPlayer as e3NexusEcazPlayer, nativeE3HomeworldPool as e3NexusEcazPool };
export type E3NexusEcazStep = { actor: string; action: Action };
export type E3NexusEcazTransition = { before: Game; step: E3NexusEcazStep; after: Game };
export type E3NexusEcazOptions = {
/** An original undealt lobby or its native setup; never convert a played save. */
initial?: Game;
advanced?: boolean;
roster?: readonly FactionId[];
tech?: boolean;
strongholds?: boolean;
homeworlds?: boolean;
discovery?: boolean;
};
export type E3NexusEcazSetup = {
initial: Game; offered: Game; afterSetup: Game; owner: string; others: string[];
actions: E3NexusEcazStep[];
};
export type E3NexusEcazClosing = E3NexusEcazSetup & {
firstMentat: E3NexusEcazTransition; alliance: Game; draw: E3NexusEcazTransition; game: Game;
claim?: E3NexusEcazTransition;
};
export type E3NexusEcazCoalition = E3NexusEcazClosing & {
ally: string; opponent: string; location: string; ecazForces: number;
arrivals: (E3NexusEcazTransition & { settled: Game })[]; beforeBattle: Game; choice: E3NexusEcazStep; game: Game;
};
export const e3NexusEcazReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const e3NexusEcazClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
/** Minimal persisted bot profiles, projected only after genuine owner custody exists. */
export function e3NexusEcazPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = e3NexusEcazReload(g);
nativeE3HomeworldPlayer(saved, actor).bot = difficulty;
return botActions(viewGame(e3NexusEcazReload(saved), actor));
}
export function e3NexusEcazStep(g: Game, step: E3NexusEcazStep, actions?: E3NexusEcazStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(e3NexusEcazReload(g), step.actor, step.action));
}
export function nextE3NexusEcazStep(g: Game): E3NexusEcazStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
if (g.nexusCards?.phase?.stage === 'drawing' && !g.decision) {
const actor = g.nexusCards.phase.eligible.find(id => !g.nexusCards!.phase!.done.includes(id));
if (actor) {
const view = viewGame(g, actor), choices = view.nexusCards!.choices;
const action = nexusCardAction(view, choices.includes('keep') ? 'keep' : 'draw'); assert.ok(action);
return { actor, action };
}
}
if (g.decision?.kind === 'ecazBattleLead' || g.decision?.kind === 'techToken') return null;
if (g.decision?.kind === 'homeworldShipmentGuild') return { actor: g.decision.player,
action: { type: 'decision', event: g.decision.event, allow: true } };
if (g.decision && ['advisor', 'intrusion', 'advisorBattle'].includes(g.decision.kind))
return { actor: g.decision.player, action: { type: 'decision', accept: false } };
}
return nextNativeE3HomeworldStep(g);
}
export function advanceE3NexusEcaz(g: Game, until: (g: Game) => boolean, actions?: E3NexusEcazStep[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextE3NexusEcazStep(g);
assert.ok(next, 'The actual owner must choose coalition lead, seal a plan or select the winner Tech token.');
g = e3NexusEcazStep(g, next, actions);
}
throw Error('Original no-Skills Ecaz chronology did not reach its owned boundary.');
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
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]];
}
assert.deepEqual(current, desired); return rolls;
}
/** Select only ORIGINAL undealt Nexus/Treachery permutations. No hand is assigned. */
function initialize(g: Game): Game {
const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = [];
for (const p of g.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) {
const index = remaining.findIndex(c => c.kind === 'worthless' || c.kind === 'shield');
assert.ok(index >= 0); front.push(remaining.splice(index, 1)[0].id);
}
const rolls = [...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(c => c !== 'ecaz')]),
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])];
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return initializeNexusGameForAudit(g); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function createE3NexusEcazSetup(options: E3NexusEcazOptions = {}): E3NexusEcazSetup {
const roster = options.roster ?? ['ecaz', 'guild', 'emperor'];
let g = options.initial ? e3NexusEcazReload(options.initial)
: createGame('E3NEXUSECAZ', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ecaz']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0);
assert.equal(g.advanced, options.advanced ?? g.advanced); assert.deepEqual(g.expansions, ['ecaz']);
assert.equal(g.players.filter(p => p.faction === 'ecaz').length, 1);
assert.ok(g.players.every(p => ['ecaz', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', 'harkonnen'].includes(p.faction)));
assert.ok(!g.leaderSkills && !g.ecazTreachery);
const modules = { techTokens: options.tech ?? !!g.techTokens,
strongholdCards: options.strongholds ?? !!g.strongholdCards, homeworlds: options.homeworlds ?? !!g.homeworlds };
const actions: E3NexusEcazStep[] = [];
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
g = e3NexusEcazStep(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled;
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, options.discovery ?? !!g.discoveryEnabled);
assert.ok(g.nexusCards?.cards, 'Never graft Nexus onto a dealt setup.');
}
const initial = e3NexusEcazReload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = e3NexusEcazStep(g, { actor: p.id, action: { type: 'ready' } }, actions);
g = initialize(g);
}
const offered = e3NexusEcazReload(g), owner = g.players.find(p => p.faction === 'ecaz')!.id;
for (let n = 0; g.status === 'setup' && n < 200; n++) {
const next = nextE3NexusEcazStep(g); assert.ok(next);
g = e3NexusEcazStep(g, next, actions);
}
assert.equal(g.status, 'playing');
assert.equal(nativeE3HomeworldPlayer(g, owner).reserves, 14);
assert.equal(Object.values(nativeE3HomeworldPlayer(g, owner).forces).reduce((n, f) => n + f, 0), 6);
return { initial, offered, afterSetup: g, owner, others: g.players.filter(p => p.id !== owner).map(p => p.id), actions };
}
/** Only unplayed Spice Cards move within their original deck. */
export function orderE3NexusEcazSpice(g: Game, worm = false): void {
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
for (const [position, kind] of kinds.entries()) {
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
? 'worm' in c && !c.greatMaker && !c.suppressed : 'territory' in c && !c.discovery));
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
}
}
export function shipE3NexusEcaz(g: Game, actor: string, destination: string, amount: number,
actions?: E3NexusEcazStep[]): E3NexusEcazTransition & { settled: Game } {
g = advanceE3NexusEcaz(g, s => s.phase === 5 && s.active === actor && e3NexusEcazClean(s), actions);
const before = e3NexusEcazReload(g), sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(g, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? 'Original native invasion is unavailable.'); action = choice.action;
} else {
const [kind, sector] = destination.split(':');
action = { type: 'ship', territory: kind, sector: Number(sector), amount, elite: 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
const step = { actor, action }; g = e3NexusEcazStep(g, step, actions);
const after = e3NexusEcazReload(g);
g = advanceE3NexusEcaz(g, s => !s.pendingShipment && !s.pendingHomeworldShipment && e3NexusEcazClean(s), actions);
return { before, step, after, settled: g };
}
function finishMentat(g: Game, actions: E3NexusEcazStep[]): E3NexusEcazTransition {
const turn = g.turn;
for (let n = 0; n < 200; n++) {
const before = e3NexusEcazReload(g), step = nextE3NexusEcazStep(g); assert.ok(step);
g = e3NexusEcazStep(g, step, actions);
if (g.turn !== turn) return { before, step, after: g };
}
throw Error('Original Mentat did not finish.');
}
/** Two other real seats ally; only then can unallied Ecaz draw at Nexus closing. */
export function closeE3NexusEcaz(f: E3NexusEcazSetup, state: Game, firstMentat: E3NexusEcazTransition): E3NexusEcazClosing {
let g = advanceE3NexusEcaz(state, s => s.turn === 2 && s.phase === 1 && e3NexusEcazClean(s), f.actions);
orderE3NexusEcazSpice(g, true);
g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions);
for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]])
g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = e3NexusEcazReload(g);
g = advanceE3NexusEcaz(g, s => s.nexusCards?.phase?.stage === 'drawing' && e3NexusEcazClean(s), f.actions);
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
const before = e3NexusEcazReload(g), step: E3NexusEcazStep = { actor: f.owner,
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
g = e3NexusEcazStep(g, step, f.actions);
return { ...f, firstMentat, alliance, draw: { before, step, after: g }, game: g };
}
export function createE3NexusEcazClosing(options: E3NexusEcazOptions = {}, claimTabr = false): E3NexusEcazClosing {
const f = createE3NexusEcazSetup(options); assert.ok(f.others.length >= 2);
let g = e3NexusEcazReload(f.afterSetup); orderE3NexusEcazSpice(g);
let claim: E3NexusEcazTransition | undefined;
if (claimTabr) {
const arrival = shipE3NexusEcaz(g, f.owner, 'sietch_tabr:14', 1, f.actions);
g = arrival.settled; claim = arrival;
}
g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions);
const mentat = finishMentat(g, f.actions);
return { ...closeE3NexusEcaz(f, mentat.after, mentat), claim };
}
export function openE3NexusEcazBattle(g: Game, destination: string, first: string, second: string,
actions?: E3NexusEcazStep[]): Game {
g = advanceE3NexusEcaz(g, s => s.phase === 6 && e3NexusEcazClean(s) && !!s.active, actions);
const choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === destination &&
[first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice);
g = e3NexusEcazStep(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: destination,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
return advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
export function createE3NexusEcazCunning(options: E3NexusEcazOptions = {}) {
const f = createE3NexusEcazClosing({ ...options, advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'],
homeworlds: true, tech: true, strongholds: true });
const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id, location = 'homeworld:beneGesserit';
const arrival = shipE3NexusEcaz(f.game, f.owner, location, 1, f.actions);
let g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 6 && e3NexusEcazClean(s) && !s.battle, f.actions);
const before = e3NexusEcazReload(g), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions);
const cunning: E3NexusEcazTransition = { before, step, after: g };
g = openE3NexusEcazBattle(g, location, f.owner, opponent, f.actions);
const own = nativeE3HomeworldPlayer(g, f.owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const enemy = nativeE3HomeworldPlayer(g, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const plans: E3NexusEcazStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } },
];
return { ...f, opponent, location, arrival, cunning, game: g, plans };
}
/** Every army reaches Tabr by paid native shipment; no board/wallet/card staging. */
export function createE3NexusEcazCoalition(options: E3NexusEcazOptions = {}, ecazForces?: number): E3NexusEcazCoalition {
const f = createE3NexusEcazClosing({ ...options, roster: ['ecaz', 'guild', 'emperor'] }, true);
let g = advanceE3NexusEcaz(f.game, s => s.turn === 3 && s.phase === 1 && e3NexusEcazClean(s), f.actions);
orderE3NexusEcazSpice(g, true);
g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions);
const ally = g.players.find(p => p.faction === 'emperor')!.id, opponent = g.players.find(p => p.faction === 'guild')!.id;
g = e3NexusEcazStep(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions);
const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`;
const amount = ecazForces ?? (g.advanced ? 5 : 4);
const requested: Record<string, number> = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 };
const arrivals: (E3NexusEcazTransition & { settled: Game })[] = [];
const shipped = new Set<string>();
for (let n = 0; n < 300; n++) {
if (g.phase === 6 && e3NexusEcazClean(g)) break;
if (g.phase === 5 && e3NexusEcazClean(g) && g.active && !shipped.has(g.active)) {
const actor = g.active, arrival = shipE3NexusEcaz(g, actor, location, requested[actor], f.actions);
arrivals.push(arrival); shipped.add(actor); g = arrival.settled;
} else {
const next = nextE3NexusEcazStep(g); assert.ok(next); g = e3NexusEcazStep(g, next, f.actions);
}
}
assert.equal(shipped.size, 3);
const beforeBattle = e3NexusEcazReload(g), choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === 'sietch_tabr'); assert.ok(choice);
const owned: E3NexusEcazStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
if (g.advanced || amount % 2 === 0) g = e3NexusEcazStep(g, owned, f.actions);
return { ...f, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice: owned, game: g };
}
export function revealE3NexusEcaz(g: Game, plans: readonly E3NexusEcazStep[], actions?: E3NexusEcazStep[]): Game {
for (const plan of plans) {
g = e3NexusEcazStep(g, plan, actions);
if (!g.battle?.revealed) g = advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
assert.ok(g.battle?.revealed); return g;
}
/** Token placement uses its original supply lottery, not a preplaced token. */
export function createE3NexusEcazDiscovery(options: E3NexusEcazOptions = {}) {
const f = createE3NexusEcazSetup({ ...options, discovery: true });
let g = e3NexusEcazReload(f.afterSetup);
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
if (g.advanced) {
const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin'); assert.ok(second > 0);
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]);
}
g = advanceE3NexusEcaz(g, s => s.phase === 1 && e3NexusEcazClean(s), f.actions);
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
const step = nextE3NexusEcazStep(g); assert.ok(step); f.actions.push(structuredClone(step));
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(e3NexusEcazReload(g), step.actor, step.action));
}
const destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const arrival = shipE3NexusEcaz(g, f.owner, `${destination.territory}:${destination.sector}`, 2, f.actions);
g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 7 && e3NexusEcazClean(s), f.actions);
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
const beforeReveal = e3NexusEcazReload(g);
g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
const revealed = e3NexusEcazReload(g);
g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions);
const firstMentat = finishMentat(g, f.actions);
g = advanceE3NexusEcaz(firstMentat.after, s => !s.response && !s.phaseOpening &&
s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
const before = e3NexusEcazReload(g), view = viewGame(g, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions);
const entry: E3NexusEcazTransition = { before, step, after: e3NexusEcazReload(g) };
const closing = closeE3NexusEcaz(f, g, firstMentat);
return { ...closing, arrival, token, beforeReveal, revealed, entry };
}