Files
Dune/tests/fixture-single-nexus-e2.ts
T

351 lines
23 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame,
} from '../game/engine';
import type { Action, Game, Player } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Difficulty } from '../game/bot-profiles';
import type { Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import { nativeHomeworldInventory, nativeHomeworldPlayer, nativeHomeworldLeader, frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
import { nextHomeworldPairedNexusStep } from './fixture-homeworld-paired-nexus';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type SingleNexusE2Native = 'choam' | 'richese';
export type SingleNexusE2Step = { actor: string; action: Action };
export type SingleNexusE2Transition = { before: Game; step: SingleNexusE2Step; after: Game };
export type SingleNexusE2Options = {
/** Only an original undealt lobby or its original, unassigned turn-one setup. */
initial?: Game;
native?: SingleNexusE2Native;
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
/** Original first deal selection only; never replaces a supplied deal. */
fuelKind?: 'special' | 'shield';
};
export type SingleNexusE2Setup = {
initial: Game; setup: Game; offered: Game | null; afterSetup: Game; game: Game;
owner: string; others: string[]; trainer: string | null; actions: SingleNexusE2Step[];
};
export type SingleNexusE2Programme = SingleNexusE2Setup & {
firstMarker: SingleNexusE2Transition | null;
entry: SingleNexusE2Transition | null;
ride: SingleNexusE2Transition | null;
vote: Game | null;
alliance: Game; draw: SingleNexusE2Transition;
};
export const singleE2Reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const singleE2Player = nativeHomeworldPlayer;
export const singleE2Leader = nativeHomeworldLeader;
export const singleE2Clean = (game: Game): boolean =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
/** Persist minimal profiles on the real owning seat before projecting its window. */
export function singleE2Policy(game: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = singleE2Reload(game); singleE2Player(saved, actor).bot = difficulty;
return botActions(viewGame(singleE2Reload(saved), actor));
}
export function singleE2Inventory(game: Game): void {
if (game.homeworlds) nativeHomeworldInventory(game);
else for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0), p.faction === 'emperor' ? 5 : 3);
}
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
if (game.discoveries) validateDiscoveryState(game.discoveries);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['choam']),
...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const nx = game.nexusCards!.cards!;
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
export function singleE2Step(game: Game, step: SingleNexusE2Step, actions?: SingleNexusE2Step[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(singleE2Reload(game), step.actor, step.action));
}
export function nextSingleE2Step(game: Game): SingleNexusE2Step | null {
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision;
if (['sukRescue', 'battleCards', 'techToken'].includes(d.kind)) return null;
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild')
return { actor: d.player, action: { type: 'decision', allow: true,
...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
const step = nextHomeworldPairedNexusStep(game);
if (step?.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1;
return step;
}
export function advanceSingleE2(game: Game, until: (g: Game) => boolean, actions?: SingleNexusE2Step[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSingleE2Step(game);
assert.ok(step, `Choose the actual single E2 ${game.decision?.kind ?? 'sealed plan'} at ${game.turn}/${game.phase}.`);
game = singleE2Step(game, step, actions);
}
throw Error('Original single E2 chronology did not reach its owned boundary.');
}
export function settleSingleE2(game: Game, actions?: SingleNexusE2Step[]): Game {
return advanceSingleE2(game, g => singleE2Clean(g) && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingChoamWorthless, actions);
}
function transition(game: Game, step: SingleNexusE2Step, actions: SingleNexusE2Step[]): SingleNexusE2Transition {
return { before: singleE2Reload(game), step: structuredClone(step), after: singleE2Step(game, step, actions) };
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[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 index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 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 createSingleE2Setup(options: SingleNexusE2Options = {}): SingleNexusE2Setup {
const native = options.native ?? options.initial?.players.find(p => p.faction === 'choam' || p.faction === 'richese')?.faction ?? 'richese';
const roster = options.roster ?? [native, 'guild', 'fremen'] as readonly FactionId[];
const advanced = options.advanced ?? options.initial?.advanced ?? true;
let game = options.initial ? singleE2Reload(options.initial)
: createGame('SINGLENEXUSE2', newPlayer(roster[0], roster[0], roster[0]), advanced, ['choam']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
assert.equal(game.players.filter(p => p.faction === native).length, 1);
assert.ok(game.players.every(p => p.faction === native || ['atreides', 'harkonnen', 'guild', 'fremen', 'beneGesserit', 'emperor'].includes(p.faction)));
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
(!game.leaderSkills || game.leaderSkills.assignments.length === 0));
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!game.leaderSkills : true);
const discovery = options.discovery ?? !!game.discoveryEnabled;
const modules = { homeworlds: options.homeworlds ?? !!game.homeworlds,
techTokens: options.tech ?? !!game.techTokens, strongholdCards: options.strongholds ?? !!game.strongholdCards };
assert.ok(!modules.strongholdCards || advanced);
const owner = game.players.find(p => p.faction === native)!.id, actions: SingleNexusE2Step[] = [];
const requested = options.skill ?? 'suk-graduate';
if (game.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!game[type as keyof typeof modules] !== enabled)
game = singleE2Step(game, { actor: game.host, action: { type, enabled } }, actions);
game.discoveryEnabled = discovery; game.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!game[type as keyof typeof modules], enabled);
assert.equal(!!game.discoveryEnabled, discovery); assert.equal(!!game.leaderSkills, skills);
assert.ok(game.nexusCards?.cards, 'Never attach Nexus to a played deal.');
}
const initial = singleE2Reload(game);
if (game.status === 'lobby') {
for (const p of game.players) if (!p.ready) game = singleE2Step(game, { actor: p.id, action: { type: 'ready' } }, actions);
const passive: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz'].filter(s => s !== requested) as LeaderSkillId[];
const selected = game.players.map(p => p.id === owner ? requested : passive.shift()!);
const skillsSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillsSource.filter(s => !selected.includes(s));
const skillsOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const deck = treacheryDeck(['choam']), remaining = [...deck], front: Card[] = [];
for (const p of game.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) {
const kind = p.faction === 'choam' ? options.fuelKind ?? 'special' : 'worthless';
const i = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); assert.ok(i >= 0);
front.push(remaining.splice(i, 1)[0]);
}
const rolls = [...(skills ? permutation(skillsSource, skillsOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(deck.map(c => c.id), [...front, ...remaining].map(c => c.id))];
game = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeNexusGameForAudit(game));
}
const setup = singleE2Reload(game); let offered: Game | null = null, trainer: string | null = null;
for (let n = 0; game.status === 'setup' && n < 250; n++) {
let step: SingleNexusE2Step;
if (game.setupStage === 'leaderSkills' && !game.decision) {
offered ??= singleE2Reload(game);
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const skill = actor === owner && options.skill ? options.skill
: view.offer!.cards.find(s => !view.unavailableSkills?.[s]);
assert.ok(skill && view.offer!.cards.includes(skill) && !view.unavailableSkills?.[skill],
'A requested skill must exist in the authoritative original private offer.');
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = singleE2Player(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader); if (actor === owner) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
} else {
const next = nextSingleE2Step(game); assert.ok(next); step = next;
if (step.action.type === 'traitor' && trainer) {
const choices = singleE2Player(game, step.actor).traitorChoices;
step.action.leader = choices.find(id => id !== trainer) ?? choices[0];
}
if (step.action.type === 'fremenSetup') step.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
game = singleE2Step(game, step, actions);
}
assert.equal(game.status, 'playing'); singleE2Inventory(game);
return { initial, setup, offered, afterSetup: singleE2Reload(game), game, owner,
others: game.players.filter(p => p.id !== owner).map(p => p.id), trainer, actions };
}
export function shipSingleE2(game: Game, actor: string, destination: string, amount: number,
actions?: SingleNexusE2Step[], sector?: number): Game {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, 0) : undefined;
if (game.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else action = { type: 'ship', territory: destination, sector: sector ?? (destination === 'shrine' ? 0 : territory(destination).sectors[0]),
amount, elite: 0, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) };
return settleSingleE2(singleE2Step(game, { actor, action }, actions), actions);
}
/** Native starts, real first marker/parent entry, END Mentat, both Spice piles,
* Maker or worm, settled alliance and a qualified original closing draw. */
export function createSingleE2Programme(options: SingleNexusE2Options = {}): SingleNexusE2Programme {
const f = createSingleE2Setup(options), actions = f.actions;
const guild = f.game.players.find(p => p.faction === 'guild')?.id;
const fremen = f.game.players.find(p => p.faction === 'fremen')?.id;
assert.ok(guild && fremen, 'The closing programme needs original Guild/Fremen alliance seats. Setup itself supports two through six.');
let game = f.game; const discovery = !!game.discoveries;
frontNativeHomeworldSpice(game, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const offset = game.advanced ? 2 : 1;
frontNativeHomeworldSpice(game, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), offset);
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', offset + 1);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', offset + 2);
game = advanceSingleE2(game, g => g.phase === 1 && singleE2Clean(g), actions);
if (discovery) {
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextSingleE2Step(game); assert.ok(step); actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(singleE2Reload(game), step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
}
let firstMarker: SingleNexusE2Transition | null = null;
const needed = new Set([...(discovery ? [guild] : []), ...(singleE2Player(game, f.owner).faction === 'richese' ? [f.owner] : [])]);
while (needed.size) {
game = advanceSingleE2(game, g => g.turn === 1 && g.phase === 5 && singleE2Clean(g) && needed.has(g.active!), actions);
const actor = game.active!;
if (actor === f.owner) {
const p = singleE2Player(game, actor), token = p.noField!.tokens.find(t => t.value === 0)!;
firstMarker = transition(game, { actor, action: { type: 'ship', territory: 'habbanya_ridge_sietch',
sector: territory('habbanya_ridge_sietch').sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
game = settleSingleE2(firstMarker.after, actions); firstMarker.after = singleE2Reload(game);
game = singleE2Step(game, { actor, action: { type: 'revealNoField', token: token.id, event: singleE2Player(game, actor).noFieldEvent } }, actions);
} else {
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
game = shipSingleE2(game, actor, placement.territory, 3, actions, placement.sector);
}
needed.delete(actor);
if (needed.size) game = singleE2Step(game, { actor, action: { type: 'endMovement' } }, actions);
}
if (discovery) {
game = advanceSingleE2(game, g => g.phase === 7 && singleE2Clean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
for (const reveal of [false, true]) game = singleE2Step(game, { actor: guild, action: { type: 'discovery', token: token.id, reveal } }, actions);
}
game = advanceSingleE2(game, g => g.turn === 2, actions);
let entry: SingleNexusE2Transition | null = null, ride: SingleNexusE2Transition | null = null, vote: Game | null = null;
if (discovery) {
game = advanceSingleE2(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === guild, actions);
const view = viewGame(game, guild), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
entry = transition(game, { actor: guild, action }, actions); game = entry.after;
game = advanceSingleE2(game, g => g.decision?.kind === 'greatMakerVote', actions); vote = singleE2Reload(game);
while (game.decision?.kind === 'greatMakerVote') game = singleE2Step(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, actions);
assert.ok(game.decision?.kind === 'greatMakerRide' && game.decision.player === fremen);
const actionRide = greatMakerRideAction(viewGame(game, fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(actionRide);
ride = transition(game, { actor: fremen, action: actionRide }, actions); game = ride.after;
}
game = advanceSingleE2(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && singleE2Clean(g), actions);
for (const [actor, target] of [[guild, fremen], [fremen, guild]])
game = singleE2Step(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = singleE2Reload(game);
game = advanceSingleE2(game, g => g.nexusCards?.phase?.stage === 'drawing' && singleE2Clean(g), actions);
assert.ok(game.nexusCards!.phase!.eligible.includes(f.owner));
const cards = game.nexusCards!.cards!, face = singleE2Player(game, f.owner).faction;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(game, { actor: f.owner, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[f.owner] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
game = advanceSingleE2(draw.after, g => g.phase === 5 && singleE2Clean(g), actions);
return { ...f, game, firstMarker, entry, ride, vote, alliance, draw };
}
export function singleE2Cunning(game: Game, actor: string, fuel?: string, destination = 'shrine'): Action {
const p: Player = singleE2Player(game, actor), view = viewGame(game, actor);
if (p.faction === 'choam') {
const play = choamPowerPlays(view, 'kulon').find(p => p.source === 'nexus' && p.card.id === fuel); assert.ok(play);
const action = choamPowerAction(view, play); assert.ok(action); return action;
}
assert.equal(p.faction, 'richese'); const offer = view.nexusRicheseCunning; assert.ok(offer && !offer.blocked);
return { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[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 openSingleE2Battle(game: Game, owner: string, opponent: string, location: string, hidden = false): Game {
game = advanceSingleE2(game, g => g.phase === 6 && singleE2Clean(g) && !!g.active);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === location &&
[owner, opponent].includes(b.attacker) && [owner, opponent].includes(b.defender)); assert.ok(choice);
game = singleE2Step(game, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } });
for (let n = 0; n < 200; n++) {
if (game.battle && singleE2Clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
const next = nextSingleE2Step(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === owner && hidden;
game = singleE2Step(game, next);
}
throw Error('Original single E2 battle did not reach sealed plans.');
}
export function revealSingleE2Plans(game: Game, plans: readonly SingleNexusE2Step[]): Game {
for (const plan of plans) {
game = singleE2Step(game, plan);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextSingleE2Step(game); assert.ok(next); game = singleE2Step(game, next);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishSingleE2Battle(game: Game): Game {
for (let n = 0; n < 600; n++) {
if (!game.battle && singleE2Clean(game)) return game;
let next = nextSingleE2Step(game);
if (!next && !game.response && !game.phaseOpening) {
const d = game.decision; assert.ok(d);
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else throw Error(`Choose real ${d.kind} before cleanup.`);
}
assert.ok(next); game = singleE2Step(game, next);
}
throw Error('Original single E2 aftermath did not finish.');
}