Files
Dune/tests/fixture-paired-e3-nexus-modules.ts

408 lines
27 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit,
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 { botActions } from '../game/bots';
import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { traitorDeck } from '../game/traitors';
import { chooseEcazLoyalty } from '../game/ecaz-loyalty';
import { TERROR_KINDS } from '../game/moritani-terror';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldCombatLocation } from '../game/homeworld-combat';
import { quoteBattleResolution } from '../game/battle-resolution-quote';
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextE3NexusEcazStep } from './fixture-e3-nexus-ecaz';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export interface PairedE3NexusStep { actor: string; action: Action }
export interface PairedE3NexusOptions {
initial?: Game;
roster?: readonly FactionId[];
advanced?: boolean;
nexus?: boolean;
/** Additional ordinary decks selected before setup; no extra faction seat. */
families?: readonly ('ix' | 'choam')[];
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
assassinationFaction?: 'guild' | 'ecaz';
}
export interface PairedE3NexusTransition { before: Game; step: PairedE3NexusStep; after: Game }
export interface PairedE3NexusSetup {
initial: Game; offered: Game; afterSetup: Game;
ecaz: string; moritani: string; others: string[]; actions: PairedE3NexusStep[];
}
export interface PairedE3NexusProgramme extends PairedE3NexusSetup {
game: Game; alliance: Game | null; drawing: Game | null;
draws: PairedE3NexusTransition[]; maker: Game | null;
claim: PairedE3NexusTransition | null; firstMentat: PairedE3NexusTransition | null;
}
export interface PairedE3NexusBattle {
game: Game; beforeBattle: Game; location: string; winner: string; loser: string;
arrivals: PairedE3NexusTransition[]; plans: PairedE3NexusStep[];
}
export const pairedE3NexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const pairedE3NexusClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export function pairedE3NexusPlayer(g: Game, actor: string): Player {
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
}
export function pairedE3NexusPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = pairedE3NexusReload(g); pairedE3NexusPlayer(saved, actor).bot = difficulty;
return botActions(viewGame(pairedE3NexusReload(saved), actor));
}
/** Original response queues precede owned native choices. A human selects the
* coalition lead, posture, assassination, Terror, physical losses and rewards. */
export function nextPairedE3NexusStep(g: Game): PairedE3NexusStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.setupStage === 'leaderSkills') return null;
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.decision) {
if (['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement', 'moritaniAssassinate',
'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses', 'battleCards',
'techToken', 'sukRescue', 'strongholdCopy', 'discoveryEntry', 'greatMakerVote',
'greatMakerRide', 'homeworldDefense', 'homeworldRevivalDeployment', 'moritaniRetention'].includes(g.decision.kind)) return null;
if (g.decision.kind === 'moritaniSetup') {
const action = pairedE3NexusPolicy(g, g.decision.player, 'Easy')[0]; assert.ok(action);
return { actor: g.decision.player, action };
}
}
return nextE3NexusEcazStep(g);
}
export function stepPairedE3Nexus(g: Game, step: PairedE3NexusStep, actions?: PairedE3NexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(pairedE3NexusReload(g), step.actor, step.action));
}
export function advancePairedE3Nexus(g: Game, until: (g: Game) => boolean,
actions?: PairedE3NexusStep[], choose?: (g: Game) => PairedE3NexusStep | null): Game {
for (let n = 0; n < 3200; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextPairedE3NexusStep(g) ?? choose?.(g);
assert.ok(next, `The owner must choose ${g.decision?.kind ?? 'a sealed plan'} at ${g.turn}/${g.phase}`);
g = stepPairedE3Nexus(g, next, actions);
}
throw Error('Original paired E3 programme did not reach its boundary');
}
/** Explicit quiet programme choices: notably decline Grumman so supply remains
* available. Battle choices are not supplied by this traversal. */
export function quietPairedE3NexusChoice(g: Game): PairedE3NexusStep | null {
const d = g.decision; if (!d || g.response || g.phaseOpening) return null;
if (d.kind === 'ecazAmbassador') return { actor: d.player,
action: { type: 'decision', event: g.pendingAmbassador!.event, decline: true } };
if (['moritaniPlacement', 'ecazPlacement', 'moritaniAssassinate',
'homeworldRevivalDeployment'].includes(d.kind))
return { actor: d.player, action: { type: 'decision', decline: true,
...('event' in d ? { event: d.event } : {}) } };
if (d.kind === 'leaderSkillVisibility')
return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
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: true } };
if (d.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d.kind === 'moritaniTerror') return { actor: d.player, action: { type: 'decision', decline: true } };
return null;
}
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 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');
}
}
function selectedSkills(g: Game, training?: PairedE3NexusOptions['training']): LeaderSkillId[] {
const preferred: Partial<Record<FactionId, LeaderSkillId>> = { ecaz: 'warmaster', moritani: 'master-of-assassins',
guild: 'suk-graduate', emperor: 'swordmaster-of-ginaz', atreides: 'bureaucrat',
fremen: 'killer-medic', beneGesserit: 'prana-bindu-adept', harkonnen: 'smuggler' };
return g.players.map(p => training?.[p.faction] ?? preferred[p.faction]!);
}
function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] {
const original = traitorDeck(g.players, g.expansions.includes('ix')), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null;
const source = original.filter(c => c !== loyal), desired = [...source];
const target = faction === 'ecaz' ? source.find(card => leaders('ecaz').some(l => l.id === card))
: leaders('guild').find(l => l.name === 'Master Bewt')?.id;
if (target && g.players.some(p => p.faction === faction)) {
const destination = g.players.findIndex(p => p.faction === 'moritani') * 4;
const at = desired.indexOf(target); [desired[at], desired[destination]] = [desired[destination], desired[at]];
}
return [...(g.advanced ? [0xffffffff] : []), ...permutation(source, desired)];
}
/** Determinism selects only original undealt lotteries. Supplied lobby entropy
* and partially assigned original setup are authoritative and never redealt. */
export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): PairedE3NexusSetup {
const roster = options.roster ?? options.initial?.players.map(p => p.faction) ?? ['ecaz', 'moritani', 'guild', 'emperor'];
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const nexus = options.nexus ?? true;
const families = options.families ?? options.initial?.expansions.filter((e): e is 'ix' | 'choam' => e === 'ix' || e === 'choam') ?? [];
let g = options.initial ? pairedE3NexusReload(options.initial)
: createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.equal(g.advanced, advanced); assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort());
assert.equal(new Set(families).size, families.length);
assert.deepEqual(g.players.map(p => p.faction), roster);
assert.ok(g.players.length >= 2 && g.players.length <= 6 && new Set(roster).size === roster.length);
assert.ok(roster.includes('ecaz') && roster.includes('moritani'));
assert.ok(roster.every(f => ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(f)));
const skills = options.skills ?? (options.initial ? !!g.leaderSkills : false);
assert.ok(nexus || skills, 'The no-Nexus route keeps the original all14 Skills module');
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.techTokens || g.players.length >= 3); assert.ok(!modules.strongholdCards || advanced);
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
'Continue only an original lobby or original first-turn setup');
assert.ok(!g.ecazTreachery);
const initial = pairedE3NexusReload(g), actions: PairedE3NexusStep[] = [];
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
g = stepPairedE3Nexus(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = discovery;
if (nexus) g.nexusCards ??= { cards: null, phase: null };
else {
assert.equal(g.nexusCards, undefined, 'Never erase a selected or played Nexus component');
g = stepPairedE3Nexus(g, { actor: g.host,
action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }, actions);
}
for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
const initialize = () => {
if (nexus) return skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g);
const step: PairedE3NexusStep = { actor: g.host,
action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
actions.push(structuredClone(step));
return applyAction(pairedE3NexusReload(g), step.actor, step.action);
};
if (options.initial) g = initialize();
else {
const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g, options.training);
const rest = sourceSkills.filter(c => !selected.includes(c));
const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
const cards = treacheryDeck(g.expansions), remaining = [...cards];
const prefix = g.players.flatMap(p => Array.from({ length: p.faction === 'harkonnen' ? 2 : 1 }, () => {
const worthless = remaining.findIndex(c => c.kind === 'worthless');
const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield'); assert.ok(at >= 0);
return remaining.splice(at, 1)[0].id;
}));
const rolls = [...(skills ? permutation(sourceSkills, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...(nexus ? Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff) : []),
...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)]),
...Array<number>(spiceDeck(g.expansions.includes('ix')).length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff),
...(!skills && !roster.includes('beneGesserit') ? traitorRolls(g, options.assassinationFaction) : [])];
g = lottery(rolls, initialize);
}
}
assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery);
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
if (nexus) assert.ok(g.nexusCards?.cards);
else assert.equal(g.nexusCards, undefined);
const offered = pairedE3NexusReload(g), selected = selectedSkills(g, options.training);
for (let n = 0; g.status === 'setup' && n < 300; n++) {
let next: PairedE3NexusStep;
if (g.setupStage === 'leaderSkills') {
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
const skill = options.initial ? own.offer!.cards.find(c => !own.unavailableSkills?.[c]) : selected[g.players.findIndex(p => p.id === actor)];
const eligible = own.eligibleLeaders.map(l => pairedE3NexusPlayer(g, actor).leaders.find(d => d.id === l.id)!);
const leader = pairedE3NexusPlayer(g, actor).faction === 'guild'
? eligible.find(l => l.name === 'Master Bewt') ?? eligible[0] : eligible.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
next = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
if (!options.initial && Object.keys(g.leaderSkills!.offers).length === 1) {
actions.push(structuredClone(next));
g = lottery([...Array<number>(g.leaderSkills!.deck.length).fill(0xffffffff), ...traitorRolls(g, options.assassinationFaction)],
() => applyAction(pairedE3NexusReload(g), next.actor, next.action)); continue;
}
} else {
const proposed = nextPairedE3NexusStep(g) ?? quietPairedE3NexusChoice(g); assert.ok(proposed); next = proposed;
if (next.action.type === 'traitor' && pairedE3NexusPlayer(g, next.actor).faction === 'moritani') {
const choices = pairedE3NexusPlayer(g, next.actor).traitorChoices;
const target = options.assassinationFaction === 'ecaz'
? choices.find(card => leaders('ecaz').some(l => l.id === card))
: leaders('guild').find(l => l.name === 'Master Bewt')!.id;
next.action.leader = target && choices.includes(target) ? target : choices[0];
}
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
if (options.initial) {
actions.push(structuredClone(next));
g = applyAction(pairedE3NexusReload(g), next.actor, next.action);
} else g = stepPairedE3Nexus(g, next, actions);
}
assert.equal(g.status, 'playing');
const ecaz = g.players.find(p => p.faction === 'ecaz')!.id, moritani = g.players.find(p => p.faction === 'moritani')!.id;
pairedE3NexusInventory(g);
return { initial, offered, afterSetup: g, ecaz, moritani, others: g.players.filter(p => ![ecaz, moritani].includes(p.id)).map(p => p.id), actions };
}
function frontSpice(g: Game, match: (c: Game['spiceDeck'][number]) => boolean, position: number): void {
const at = g.spiceDeck.findIndex((c, i) => i >= position && match(c)); assert.ok(at >= position);
g.spiceDeck.splice(position, 0, g.spiceDeck.splice(at, 1)[0]);
}
/** Both natives stay unallied while two real other seats ally. Two/three-seat
* programmes return native setup: there cannot be two unallied native draws. */
export function createPairedE3NexusProgramme(options: PairedE3NexusOptions = {}): PairedE3NexusProgramme {
const f = createPairedE3NexusSetup(options);
let g = pairedE3NexusReload(f.afterSetup);
if (f.others.length < 2) return { ...f, game: g, alliance: null, drawing: null, draws: [], maker: null, claim: null, firstMentat: null };
for (let i = 0; i < (g.advanced ? 2 : 1); i++)
frontSpice(g, c => 'territory' in c && !c.discovery && !['arrakeen', 'tueks_sietch'].includes(c.territory), i);
g = advancePairedE3Nexus(g, s => s.phase === 5 && s.active === f.ecaz && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
const claim = shipPairedE3Nexus(g, f.ecaz, 'sietch_tabr:14', 1, f.actions); g = claim.after;
let firstMentat: PairedE3NexusTransition | null = null;
for (let n = 0; g.turn === 1 && n < 600; n++) {
const before = pairedE3NexusReload(g), next = nextPairedE3NexusStep(g) ?? quietPairedE3NexusChoice(g); assert.ok(next);
g = stepPairedE3Nexus(g, next, f.actions);
if (g.turn === 2) firstMentat = { before, step: next, after: pairedE3NexusReload(g) };
}
assert.ok(firstMentat);
g = advancePairedE3Nexus(g, s => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
frontSpice(g, c => 'worm' in c && (g.discoveries ? !!c.greatMaker : !c.greatMaker && !c.suppressed), 0);
for (let i = 1; i <= (g.advanced ? 2 : 1); i++) frontSpice(g, c => 'territory' in c && !c.discovery, i);
let maker: Game | null = null;
g = advancePairedE3Nexus(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && pairedE3NexusClean(s), f.actions, s => {
if (s.decision?.kind === 'greatMakerVote') maker ??= pairedE3NexusReload(s);
if (s.decision?.kind === 'greatMakerRide') {
const action = greatMakerRideAction(viewGame(s, s.decision.player), 'polar_sink', 0, 2, s.advanced ? 1 : 0);
assert.ok(action); return { actor: s.decision.player, action };
}
return quietPairedE3NexusChoice(s);
});
for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]])
g = stepPairedE3Nexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = pairedE3NexusReload(g);
if (!g.nexusCards) {
g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
pairedE3NexusInventory(g);
return { ...f, game: g, alliance, drawing: null, draws: [], maker, claim, firstMentat };
}
g = advancePairedE3Nexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
const cards = g.nexusCards!.cards!;
cards.deck = ['ecaz', 'moritani', ...cards.deck.filter(c => c !== 'ecaz' && c !== 'moritani')];
const drawing = pairedE3NexusReload(g), draws: PairedE3NexusTransition[] = [];
for (const actor of [f.ecaz, f.moritani]) {
const before = pairedE3NexusReload(g), step: PairedE3NexusStep = { actor,
action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } };
g = stepPairedE3Nexus(g, step, f.actions); draws.push({ before, step, after: pairedE3NexusReload(g) });
}
g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
pairedE3NexusInventory(g); return { ...f, game: g, alliance, drawing, draws, maker, claim, firstMentat };
}
export function shipPairedE3Nexus(g: Game, actor: string, location: string, amount: number,
actions?: PairedE3NexusStep[]): PairedE3NexusTransition {
assert.ok(g.phase === 5 && g.active === actor && pairedE3NexusClean(g));
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (location.startsWith('homeworld:')) {
assert.ok(sources); const choice = homeworldShipmentChoice(viewGame(g, actor), location,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else {
const [site, sector] = location.split(':');
action = { type: 'ship', territory: site, sector: Number(sector), amount, elite: 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
const before = pairedE3NexusReload(g), step = { actor, action };
g = stepPairedE3Nexus(g, step, actions);
g = advancePairedE3Nexus(g, s => pairedE3NexusClean(s) && !s.pendingShipment && !s.pendingHomeworldShipment,
actions, quietPairedE3NexusChoice);
return { before, step, after: g };
}
export function openPairedE3NexusBattle(g: Game, location: string, actions?: PairedE3NexusStep[]): Game {
g = advancePairedE3Nexus(g, s => s.phase === 6 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === location); assert.ok(choice);
return stepPairedE3Nexus(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
}
export function revealPairedE3Nexus(g: Game, plans: readonly PairedE3NexusStep[], actions?: PairedE3NexusStep[]): Game {
g = advancePairedE3Nexus(g, s => !!s.battle && pairedE3NexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
actions, quietPairedE3NexusChoice);
for (const plan of plans) {
g = stepPairedE3Nexus(g, plan, actions);
g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening && s.decision?.kind !== 'fullPlanOffer', actions, quietPairedE3NexusChoice);
}
assert.ok(g.battle?.revealed); return g;
}
export function quotePairedE3NexusBattle(g: Game): BattleResolutionQuote {
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedE3NexusPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: b.plans[actor],
leader: p.leaders.find(l => l.id === b.plans[actor].leader) ?? (g.dukeVidal?.controller === actor ? g.dukeVidal.leader : undefined),
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
leaderSkills: g.leaderSkills?.assignments.filter(a => a.owner === actor).map(a => ({ skill: a.skill, leader: a.leader,
faceUp: !b.leaderSkillHidden?.[actor], captured: false })) };
};
const context = homeworldContext(g), home = b.territory.startsWith('homeworld:')
? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p, ally: pairedE3NexusPlayer(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn,
territory: b.territory, aggressor: publicBattle.aggressor,
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
attacker: side(b.attacker), defender: side(b.defender), voters: publicBattle.traitorVoters.map(actor => ({ id: actor,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : pairedE3NexusPlayer(g, actor).ally!,
called: b.traitorCalls[actor] ?? false, traitors: pairedE3NexusPlayer(g, actor).traitors })),
participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true });
}
export function pairedE3NexusInventory(g: Game): void {
if (g.nexusCards) {
const nx = g.nexusCards.cards!; validateNexusCards(nx, g.players);
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(g.expansions).map(c => c.id).sort());
assert.equal(g.richeseCache?.length ?? 0, 0);
if (g.leaderSkills) {
validateLeaderSkills(g.leaderSkills, g.players);
assert.deepEqual([...g.leaderSkills.deck, ...Object.values(g.leaderSkills.offers).flatMap(o => o.cards),
...g.leaderSkills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.ok(g.leaderSkills.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
}
if (g.homeworlds) homeworldGameIntegrity(g);
if (g.discoveries) validateDiscoveryState(g.discoveries);
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
for (const p of g.players) {
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) + visitors.reduce((a, f) => a + f.normal + f.elite, 0), 20);
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) + visitors.reduce((a, f) => a + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
}
const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired,
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players, g.expansions.includes('ix')).sort());
assert.equal(pairedE3NexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID); validateAmbassadors(g.ecazAmbassadors!);
assert.deepEqual(g.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
}