import assert from 'node:assert/strict'; import { applyAction, createGame, initializeCombinedNexusGameForAudit, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { leaders, spiceDeck, treacheryDeck, type Card } from '../game/cards'; import { ecazTreacheryCards, type EcazTreacheryCardId } from '../game/ecaz-cards'; import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import { isDefenseCard, isWeaponCard } from '../game/battle-cards'; import { territory } from '../game/board'; import { CHOAM_AUDITOR_ID } from '../game/choam-auditor'; import { richeseCards } from '../game/richese-cards'; import { chooseEcazLoyalty } from '../game/ecaz-loyalty'; import { validateAmbassadors } from '../game/ecaz-ambassadors'; import { DUKE_VIDAL_ID } from '../game/duke-vidal'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror'; import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors'; import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries'; import { homeworldGameIntegrity } from '../game/homeworld-game'; import { homeworldBattleLocation } from '../game/combat-location'; import { nativeShipmentSources } from '../game/homeworld-options'; import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; import type { NativeReserveSelections } from '../game/homeworld-native-reserves'; import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote'; import type { Difficulty } from '../game/bot-profiles'; import { botActions } from '../game/bots'; import { frontNativeHomeworldSpice, nativeHomeworldInventory, nativeHomeworldLeader, } from './fixture-homeworld-native-e1e2-modules'; import { nextMixedNexusStep } from './fixture-mixed-nexus-modules'; /** Original mixed E3 Nexus with native Moritani. The Ecaz family seats Moritani * only; a separate native Ecaz programme owns the Ecaz side and the paired * rosters. Each selected E1/E2 family seats exactly one of its natives and the * ordinary deck stays original: Ix47 when Ix is selected, otherwise CHOAM35, * with the ten-card Richese cache separate whenever Richese is seated. */ export type MixedE3MoritaniFamily = 'ix' | 'choam'; export type MixedE3MoritaniEntry = 'nexus' | 'leaderSkills' | 'combined'; export type MixedE3MoritaniStep = { actor: string; action: Action }; export type MixedE3MoritaniTransition = { before: Game; step: MixedE3MoritaniStep; after: Game }; export type MixedE3MoritaniOptions = { /** An original fresh lobby or its undealt original first-turn setup; never a played save. */ initial?: Game; rules?: 'basic' | 'advanced'; families?: readonly MixedE3MoritaniFamily[]; /** One native per selected family; defaults to Tleilaxu and CHOAM. */ natives?: Partial>; /** Explicit full seat order; overrides natives/classics. */ roster?: readonly FactionId[]; classics?: readonly FactionId[]; skills?: boolean; /** Original Nexus remains selected unless explicitly disabled for all14. */ nexus?: boolean; /** Independent three-card variant; only the no-Nexus all14 route selects it. */ independent?: boolean; homeworlds?: boolean; discovery?: boolean; tech?: boolean; strongholds?: boolean; /** Requested skill for the trained opposing disc of the Advanced loss. */ opponentSkill?: LeaderSkillId; /** Select original starting-deal entropy; never replace a played hand. */ startingCards?: Partial>; /** Desired physical opposing-faction card in the original undealt Traitor offer. */ traitorTarget?: string; /** Original undealt reserve prefix, before any Face Dancer reshuffle. */ initialReserveTraitors?: readonly string[]; /** Actual Moritani closing Nexus draw, conserving the original undealt deck. */ nexusCard?: FactionId; /** Reveal Moritani's own held Traitor normally during the loss battle. */ normalCall?: boolean; seatIds?: readonly string[]; entry?: MixedE3MoritaniEntry; }; export type MixedE3MoritaniSetup = { initial: Game; offered: Game; afterSetup: Game; entry: MixedE3MoritaniEntry; families: readonly MixedE3MoritaniFamily[]; moritani: string; seats: Partial>; native: string; nativeFaction: FactionId; secondNative: string | null; secondFaction: FactionId | null; partner: string | null; classics: string[]; location: string; /** The original held Traitor Card of the winning faction, chosen at setup. */ target: string; targetSkillOwner: string | null; actions: MixedE3MoritaniStep[]; staging: string[]; }; export type MixedE3MoritaniProgramme = MixedE3MoritaniSetup & { game: Game; firstStorm: Game; firstMentat: MixedE3MoritaniTransition | null; alliance: Game | null; drawing: Game | null; draws: MixedE3MoritaniTransition[]; }; export type MixedE3MoritaniBattle = { arrangements: Game; location: string; arrival: MixedE3MoritaniTransition | null; plans: MixedE3MoritaniStep[]; revealed: Game; ownLeader: string; enemyLeader: string; target: string; ownCards: string[]; enemyCards: string[]; }; export type MixedE3MoritaniBoundary = { game: Game; kind: 'losses' | 'rescue' | 'substitution' | 'assassination' | 'cards' | 'tech' | 'faceDance' | 'settled'; }; export const mixedE3MoritaniReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; export const mixedE3MoritaniClean = (g: Game): boolean => !g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance; export function mixedE3MoritaniPlayer(g: Game, actor: string): Player { const player = g.players.find(p => p.id === actor); assert.ok(player, `Missing original seat ${actor}`); return player; } export const mixedE3MoritaniLeader = nativeHomeworldLeader; /** Genuine public/own-seat policy; never inspects hidden rival state. */ export function mixedE3MoritaniPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] { const view = viewGame(mixedE3MoritaniReload(g), actor); view.players.find(p => p.id === actor)!.bot = difficulty; return botActions(view); } /** Physical/private aftermath choices always belong to the real owner. */ const MIXED_E3_MORITANI_HUMAN: Readonly> = { battleLosses: true, sukRescue: true, ixSubstitution: true, battleCards: true, faceDance: true, strongholdCopy: true, moritaniAssassinate: true, }; /** Original response queues take priority; a negative result is a real human * boundary (physical casualties, rescue, private cards, assassination, Terror). */ export function nextMixedE3MoritaniStep(g: Game): MixedE3MoritaniStep | null { if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.decision) { const d = g.decision; if (MIXED_E3_MORITANI_HUMAN[d.kind]) return null; if (d.kind === 'techToken' && !!g.battle) return null; if (d.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } }; if (d.kind === 'leaderSkillVisibility') return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: true } }; if (d.kind === 'moritaniTerror' && ['select', 'offer'].includes(g.pendingTerrorEntry!.stage)) return { actor: d.player, action: { type: 'decision', decline: true } }; if (d.kind === 'richeseUnbid') return { actor: d.player, action: { type: 'decision', event: g.richeseBidding!.event, keep: false } }; } const next = nextMixedNexusStep(g); if (!next && g.phase === 5 && g.active && mixedE3MoritaniClean(g) && !g.pendingShipment && !g.pendingHomeworldShipment) return { actor: g.active, action: { type: 'endMovement' } }; return next; } export function stepMixedE3Moritani(g: Game, step: MixedE3MoritaniStep, actions?: MixedE3MoritaniStep[]): Game { actions?.push(structuredClone(step)); return applyAction(mixedE3MoritaniReload(g), step.actor, step.action); } export function advanceMixedE3Moritani(g: Game, until: (state: Game) => boolean, actions?: MixedE3MoritaniStep[]): Game { for (let n = 0; n < 3200; n++) { if (until(g)) return g; assert.notEqual(g.status, 'finished'); const next = nextMixedE3MoritaniStep(g); assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`); g = stepMixedE3Moritani(g, next, actions); } throw Error('Original mixed E3 Moritani chronology did not reach its owned boundary.'); } export function settleMixedE3Moritani(g: Game, actions?: MixedE3MoritaniStep[]): Game { return advanceMixedE3Moritani(g, s => mixedE3MoritaniClean(s) && !s.pendingShipment && !s.pendingHomeworldShipment && !s.pendingChoamWorthless, actions); } /** Printed stocks only: the selected ordinary deck, the separate ten-card * Richese cache, twelve Nexus cards, fourteen skills, twenty forces per faction, * original elite supply and the six original Terror tokens. */ export function mixedE3MoritaniInventory(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, p.faction); } if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players); if (g.discoveries) validateDiscoveryState(g.discoveries); if (g.ecazAmbassadors) validateAmbassadors(g.ecazAmbassadors); const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? []; const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []), ...auction]; assert.deepEqual(cards.filter(c => !c.id.startsWith('richese-')).map(c => c.id).sort(), [...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : [])].map(c => c.id).sort()); if (g.players.some(p => p.faction === 'richese')) { const owned = [...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...cards.filter(c => c.id.startsWith('richese-'))]; assert.deepEqual(owned.map(c => c.id).sort(), richeseCards().map(c => c.id).sort()); } if (g.nexusCards) { validateNexusCards(g.nexusCards.cards!, g.players); const nexus = g.nexusCards.cards!; assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); } if (g.moritaniTerror) { assert.deepEqual(g.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); assert.equal(new Set(g.moritaniTerror.tokens.map(t => t.id)).size, TERROR_KINDS.length); } } /** Physical Traitor custody in every zone, including face-up set-asides. */ export function mixedE3MoritaniTraitorCustody(g: Game): string[] { const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? []; return [...(g.traitorReserve ?? []), ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(d => d.leader)]), ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : []), ...retired].sort(); } export function assertMixedE3MoritaniPhysical(g: Game): void { mixedE3MoritaniInventory(g); assert.deepEqual(mixedE3MoritaniTraitorCustody(g), traitorDeck(g.players, g.expansions.includes('ix')).sort()); if (g.homeworlds) homeworldGameIntegrity(g); if (g.dukeVidal) assert.equal(g.dukeVidal.leader.id, DUKE_VIDAL_ID); } const MORITANI_START_CANDIDATES = ['habbanya_ridge_sietch', 'sietch_tabr', 'carthag', 'tueks_sietch', 'arrakeen'] as const; /** Printed starting strongholds of the classic factions. */ const CLASSIC_START_STRONGHOLDS: Readonly> = { atreides: ['arrakeen', 'carthag'], emperor: ['carthag'], harkonnen: ['carthag'], fremen: ['sietch_tabr', 'false_wall_south', 'false_wall_west'], guild: ['tueks_sietch'], }; /** Moritani must start in an unoccupied territory; a printed stronghold keeps * the later genuine shipment destination legal. Never a seated faction's own start. */ function moritaniStart(g: Game): string { const taken = new Set(g.players.flatMap(player => CLASSIC_START_STRONGHOLDS[player.faction] ?? [])); const free = MORITANI_START_CANDIDATES.find(id => !taken.has(id) && g.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${id}:`)) && !(player.advisors && id in player.advisors))); assert.ok(free, 'Moritani needs an unoccupied printed stronghold to start in.'); return `${free}:${territory(free).sectors[0]}`; } 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; } /** Scalar entropy for named original shuffles only; every other draw is native. */ function lottery(rolls: readonly number[], operation: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const native = crypto.getRandomValues.bind(crypto); let cursor = 0; crypto.getRandomValues = (array: V): V => { if (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++]; else Reflect.apply(native, crypto, [array]); return array; }; try { return operation(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } const identity = (count: number): number[] => Array(count).fill(0xffffffff); /** The original physical Traitor pool as dealt: five printed discs per faction, * the Advanced CHOAM Auditor and the Ix cheap-hero identity. */ function traitorPool(g: Game): string[] { return [...g.players.flatMap(p => [ ...leaders(p.faction).map(l => l.id), ...(p.faction === 'choam' && g.advanced ? [CHOAM_AUDITOR_ID] : []), ]), ...(g.expansions.includes('ix') ? [CHEAP_HERO_TRAITOR] : [])]; } /** Deal position of a player's four Traitor choices; Tleilaxu receives none. */ function traitorBlockStart(g: Game, actor: string): number { let start = 0; for (const p of g.players) { if (p.id === actor) return start; if (p.faction !== 'tleilaxu') start += 4; } throw Error('The requested Traitor owner is not seated.'); } /** Only the original Traitor shuffle is scoped, including the pre-deal Ecaz * Loyalty draw when Ecaz itself is seated (five/six-native combined roster). */ function traitorRolls(g: Game, target: string, moritani: string, reserveCards?: readonly string[]): number[] { const pool = traitorPool(g); const ecazSeated = g.players.some(p => p.faction === 'ecaz'); const loyal = ecazSeated && g.advanced ? chooseEcazLoyalty(pool, 0xffffffff / 0x100000000) : null; const source = pool.filter(c => c !== loyal); const desired = [...source]; const [at, to] = [desired.indexOf(target), traitorBlockStart(g, moritani)]; assert.ok(at >= 0 && to >= 0); [desired[at], desired[to]] = [desired[to], desired[at]]; if (reserveCards?.length) { const reserveAt = g.players.filter(p => p.faction !== 'tleilaxu').length * 4; assert.equal(new Set(reserveCards).size, reserveCards.length); for (const [offset, card] of reserveCards.entries()) { const from = desired.indexOf(card), slot = reserveAt + offset; assert.ok(from >= 0 && slot < desired.length && card !== target); [desired[from], desired[slot]] = [desired[slot], desired[from]]; } } return [...(loyal ? [0xffffffff] : []), ...permutation(source, desired)]; } /** One requested skill per faction, each genuinely offered; the rest is the * original all14 order. The requested card is placed first in its own offer. */ function skillOrderFor(g: Game, requested: Partial>): string[] { const source = LEADER_SKILL_CARDS.map(c => c.id); const taken = new Set(Object.values(requested)); const remaining = source.filter(s => !taken.has(s as LeaderSkillId)); const desired: string[] = []; for (const p of g.players) { const wanted = requested[p.faction]; desired.push(wanted ?? remaining.shift()!); desired.push(remaining.shift()!); } desired.push(...remaining); assert.equal(desired.length, source.length); return desired; } export function createMixedE3MoritaniLobby(options: MixedE3MoritaniOptions = {}): Game { const families = options.families ?? ['ix', 'choam']; assert.ok(families.length >= 1 && families.length <= 2 && new Set(families).size === families.length); const natives: Record = { ix: options.natives?.ix ?? 'tleilaxu', choam: options.natives?.choam ?? 'choam', }; const roster: readonly FactionId[] = options.roster ?? ['moritani', ...families.map(f => natives[f]), ...(options.classics ?? ['guild', 'emperor'])]; const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true; const expansions = ['ecaz', ...families] as string[]; let g = options.initial ? mixedE3MoritaniReload(options.initial) : createGame('MIXEDE3MORITANI', newPlayer(options.seatIds?.[0] ?? roster[0], roster[0], roster[0]), advanced, expansions); if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(options.seatIds?.[g.players.length] ?? faction, faction, faction)); assert.equal(g.advanced, advanced); assert.deepEqual([...g.expansions].sort(), [...expansions].sort()); assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length); assert.ok(g.players.length >= 2 && g.players.length <= 6); assert.equal(g.players.filter(p => p.faction === 'moritani').length, 1); const nativesOnly = g.players.every(p => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(p.faction)); assert.ok(g.players.filter(p => p.faction === 'ecaz').length === 0 || (g.expansions.length === 3 && nativesOnly && g.players.length >= 5), 'The native Ecaz side belongs to the separate Ecaz programme except in the original combined five/six-native roster.'); for (const family of families) assert.ok(g.players.some(p => p.faction === natives[family] || (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(p.faction)), `The selected ${family} family must actually seat one of its natives.`); const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds, techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards }; assert.ok(!modules.strongholdCards || advanced); const discovery = options.discovery ?? !!g.discoveryEnabled; const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : false); const nexus = options.nexus ?? true, independent = options.independent ?? false; assert.ok(nexus || skills, 'The no-Nexus mixed route requires the original all14 entry.'); assert.ok(!independent || !nexus && skills, 'Independent cards belong to the no-Nexus all14 route.'); assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0, 'Continue only an original fresh lobby or its undealt first-turn setup.'); if (g.status === 'lobby') { for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled) g = applyAction(g, g.host, { type, enabled }); g.discoveryEnabled = discovery; if (nexus) { g.nexusCards ??= { cards: null, phase: null }; assert.deepEqual(g.nexusCards, { cards: null, phase: null }); } else assert.ok(!g.nexusCards, 'Never erase an already selected Nexus module.'); assert.ok(!g.advancedPreview && !g.semutaPreview && !g.leaderSkills && !g.ecazTreachery, 'A fresh lobby selects neither Skills nor independent cards before its real initializer.'); } 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.equal(!!g.nexusCards?.cards, nexus, 'A supplied original deal keeps its selected Nexus deck or real absence.'); assert.equal(!!g.ecazTreachery, independent); } return g; } /** The original combined five/six-native entry stays for the bare all-three-family * roster; every other mixed E3 shape uses the original generic Nexus or all14 * Leader Skills entry. */ function mixedE3MoritaniEntry(options: MixedE3MoritaniOptions, g: Game, skills: boolean): MixedE3MoritaniEntry { if (options.entry) return options.entry; const nativesOnly = g.players.every(p => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(p.faction)); if (g.expansions.length === 3 && nativesOnly && g.players.length >= 5 && !skills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled) return 'combined'; return skills ? 'leaderSkills' : 'nexus'; } /** Original mixed E3 native Moritani setup. Only undealt physical shuffles are * scoped: the fourteen skill cards, the twelve Nexus cards, the selected * ordinary deck and the original Traitor dealing. No hand, army, wallet or * reward is staged, and a supplied lobby or setup is never redealt. */ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}): MixedE3MoritaniSetup { let g = createMixedE3MoritaniLobby(options); const actions: MixedE3MoritaniStep[] = [], staging: string[] = []; const skills = g.status === 'lobby' ? options.skills ?? false : !!g.leaderSkills; const independent = options.independent ?? false; const entry = mixedE3MoritaniEntry(options, g, skills); assert.ok(options.nexus !== false || entry === 'leaderSkills', 'The no-Nexus route uses the all14 initializer.'); const families = (options.families ?? ['ix', 'choam']).filter(f => g.expansions.includes(f)); const moritani = g.players.find(p => p.faction === 'moritani')!.id; const nativeFaction = options.natives?.[families[0]] ?? (families[0] === 'ix' ? 'tleilaxu' : 'choam'); const native = g.players.find(p => p.faction === nativeFaction); assert.ok(native, `The selected ${families[0]} native must be seated.`); const secondFaction = families.length > 1 ? (options.natives?.[families[1]] ?? (families[1] === 'ix' ? 'tleilaxu' : 'choam')) : null; const secondNative = secondFaction ? g.players.find(p => p.faction === secondFaction)?.id ?? null : null; let location = ''; const target = options.traitorTarget ?? leaders(nativeFaction)[0].id; const requested: Partial> = {}; if (skills) { requested[nativeFaction] = options.opponentSkill ?? 'suk-graduate'; const pool: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz']; for (const p of g.players) if (!requested[p.faction] && p.faction !== 'moritani') requested[p.faction] = pool.find(s => !Object.values(requested).includes(s))!; } const initial = mixedE3MoritaniReload(g); let ixSeatCard: string | null = null; if (g.status === 'lobby') { for (const p of g.players) if (!p.ready) g = applyAction(g, p.id, { type: 'ready' }); // The real initializer is the only selector of the independent variant. if (independent) { assert.ok(!g.ecazTreachery); g.ecazTreachery = true; } const initialize = () => entry === 'combined' ? initializeCombinedNexusGameForAudit(g, !!g.homeworlds) : skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g); if (options.initial) g = initialize(); else { assert.ok(!g.players.some(p => p.faction === 'beneGesserit'), 'A fresh crafted deal keeps the original prediction stage out of its named shuffle list; supply a Bene Gesserit lobby as `initial`.'); const deck = [...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : [])], remaining = [...deck]; const take = (kind: Card['kind'] | EcazTreacheryCardId) => { const at = remaining.findIndex(c => c.id === kind || c.kind === kind); assert.ok(at >= 0, `The original deck must hold a ${kind}.`); return remaining.splice(at, 1)[0].id; }; const ixPlayer = g.players.find(p => p.faction === 'ixians'); // With Ixians the physical starting row is drawn per seat and the chosen // card is kept by Ixians; the remainder is dealt to the others in seat // order, so the row is ordered Ixians first. Without Ixians each seat // draws its own starting card directly, Harkonnen drawing two. const order = ixPlayer ? [ixPlayer, ...g.players.filter(p => p.id !== ixPlayer.id)] : g.players; const row: string[] = []; for (const p of order) { const quantity = ixPlayer ? 1 : p.faction === 'harkonnen' ? 2 : 1; for (let i = 0; i < quantity; i++) row.push(take(options.startingCards?.[p.faction]?.[i] ?? (p.id === native.id ? 'projectile' : 'worthless'))); } if (ixPlayer && g.players.some(p => p.faction === 'harkonnen')) row.push(take('worthless')); if (ixPlayer) ixSeatCard = row[0]; const rolls: number[] = []; if (skills) rolls.push(...permutation(LEADER_SKILL_CARDS.map(c => c.id), skillOrderFor(g, requested))); if (g.discoveryEnabled) rolls.push(...identity(7)); if (g.nexusCards) rolls.push(...identity(NEXUS_FACTIONS.length - 1)); rolls.push(...permutation(deck.map(c => c.id), [...row, ...remaining.map(c => c.id)])); const spiceCount = spiceDeck(g.expansions.includes('ix')).length + (g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0); rolls.push(...identity(spiceCount - 1)); if (!skills) rolls.push(...traitorRolls(g, target, moritani, options.initialReserveTraitors)); g = lottery(rolls, initialize); } } assert.equal(!!g.leaderSkills, skills); const offered = mixedE3MoritaniReload(g); for (let n = 0; g.status === 'setup' && n < 400; n++) { const decision = g.decision; if (decision?.kind === 'ixSetup') { const offer = viewGame(g, decision.player).ixTechnology!.setup!; const wanted = offer.find(c => c.id === ixSeatCard) ?? offer.find(c => c.kind === 'projectile') ?? offer[0]; assert.ok(wanted, 'The original Ix pre-training row must offer a physical card.'); const rolls = identity(Math.max(g.players.length - 2, 0)); actions.push({ actor: decision.player, action: { type: 'decision', card: wanted.id } }); g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), decision.player, { type: 'decision', card: wanted.id })); continue; } if (g.setupStage === 'leaderSkills' && !g.decision) { const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!; const offer = own.offer!; const wanted = requested[mixedE3MoritaniPlayer(g, actor).faction]; const skill = wanted && offer.cards.includes(wanted) && !own.unavailableSkills?.[wanted] ? wanted : offer.cards.find(c => !own.unavailableSkills?.[c])!; assert.ok(skill); const leader = own.eligibleLeaders.find(l => l.id === target) ?? own.eligibleLeaders[0]; assert.ok(leader); const step = { actor, action: { type: 'leaderSkill' as const, event: offer.event, skill, leader: leader.id } }; if (Object.keys(g.leaderSkills!.offers).length === 1) { // Original order: the chosen card returns to the physical deck, then the // real Traitor deal (and pre-deal Ecaz Loyalty when Ecaz is seated). const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani, options.initialReserveTraitors)]; actions.push(structuredClone(step)); g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), step.actor, step.action)); } else g = stepMixedE3Moritani(g, step, actions); continue; } if (decision?.kind === 'moritaniSetup') { location = moritaniStart(g); const [start, sector] = location.split(':'); g = stepMixedE3Moritani(g, { actor: decision.player, action: { type: 'decision', territory: start, sector: Number(sector) } }, actions); continue; } if (g.status === 'setup' && g.setupStage === 'forces' && !g.decision) { const fremen = g.players.find(p => p.faction === 'fremen'); const ecaz = g.players.find(p => p.faction === 'ecaz'); const bg = g.players.find(p => p.faction === 'beneGesserit'); if (fremen && fremen.reserves === 20) { g = stepMixedE3Moritani(g, { actor: fremen.id, action: { type: 'fremenSetup', placements: { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 } } }, actions); continue; } if (ecaz && ecaz.reserves === 20) { const key = `imperial_basin:${territory('imperial_basin').sectors[0]}`; g = stepMixedE3Moritani(g, { actor: ecaz.id, action: { type: 'ecazSetup', placements: { [key]: 6 } } }, actions); continue; } if (g.advanced && bg && !bg.advisorSetup) { // A printed non-stronghold keeps every stronghold free for Moritani's start. g = stepMixedE3Moritani(g, { actor: bg.id, action: { type: 'advisorSetup', territory: 'imperial_basin', sector: territory('imperial_basin').sectors[0] } }, actions); continue; } } const next = nextMixedE3MoritaniStep(g); assert.ok(next, `Original setup requires the owner of ${decision?.kind ?? g.setupStage}.`); if (next.action.type === 'traitor') { const player = mixedE3MoritaniPlayer(g, next.actor), choices = player.traitorChoices; const nativeCard = choices.find(c => leaders(nativeFaction).some(l => l.id === c)); next.action.leader = player.faction === 'moritani' ? choices.find(c => c === target) ?? nativeCard ?? choices[0] : player.faction === nativeFaction ? nativeCard ?? choices[0] : choices[0]; } g = stepMixedE3Moritani(g, next, actions); } assert.equal(g.status, 'playing', 'Original mixed E3 setup must complete into play.'); const own = mixedE3MoritaniPlayer(g, moritani); assert.equal(own.forces[location], 6, 'Moritani placed its real six starting counters.'); const held = own.traitors.find(c => c === target) ?? own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c)); assert.ok(held, 'The original Traitor deal must place one selected-family card in Moritani\'s hand.'); const seats: Partial> = {}; for (const p of g.players) seats[p.faction] = p.id; mixedE3MoritaniInventory(g); return { initial, offered, afterSetup: mixedE3MoritaniReload(g), entry, families, moritani, seats, native: native.id, nativeFaction, secondNative, secondFaction, partner: g.players.find(p => p.id !== moritani && p.id !== native.id)?.id ?? null, classics: g.players.filter(p => p.id !== moritani && p.id !== native.id && p.id !== secondNative).map(p => p.id), location, target: held, targetSkillOwner: g.leaderSkills?.assignments.find(a => a.owner === native.id)?.leader ?? null, actions, staging }; } /** Only unplayed original Spice Cards are positioned, never invented. */ export function orderMixedE3MoritaniSpice(g: Game, worm = false): void { const kinds = [...(worm ? ['worm'] : []), ...Array(g.advanced ? 2 : 1).fill('land')]; for (const [position, kind] of kinds.entries()) frontNativeHomeworldSpice(g, c => kind === 'worm' ? 'worm' in c && (g.discoveries ? !!c.greatMaker : !c.greatMaker && !c.suppressed) : 'territory' in c && !c.discovery && !['arrakeen', 'tueks_sietch'].includes(c.territory), position); } /** Real alliance change by two unallied seats, then Moritani's own genuine * closing Nexus draw after both Advanced Spice piles have been dealt. */ export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game, card: FactionId = 'moritani'): { alliance: Game; drawing: Game; draw: MixedE3MoritaniTransition; game: Game } | null { let g = advanceMixedE3Moritani(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3MoritaniClean(s), f.actions); const others = g.players.filter(p => p.id !== f.moritani && !p.ally); assert.ok(others.length >= 2); for (const [actor, ally] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) g = stepMixedE3Moritani(g, { actor, action: { type: 'alliance', target: ally } }, f.actions); const alliance = mixedE3MoritaniReload(g); g = advanceMixedE3Moritani(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3MoritaniClean(s), f.actions); if (!g.nexusCards!.phase!.eligible.includes(f.moritani)) return null; const cards = g.nexusCards!.cards!, at = cards.deck.indexOf(card); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]); f.staging.push(`Conserved undealt original ${card} Nexus singleton ordered before its real unallied closing draw.`); const drawing = mixedE3MoritaniReload(g), step: MixedE3MoritaniStep = { actor: f.moritani, action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[f.moritani] ?? null, choice: 'draw', ownRedraws: 0 } }; g = stepMixedE3Moritani(g, step, f.actions); return { alliance, drawing, draw: { before: drawing, step: structuredClone(step), after: mixedE3MoritaniReload(g) }, game: g }; } /** Turn-one storm/collection/Mentat and the turn-two storm, then the original * closing deal. Two-seat rosters have no legal unallied closing qualification. */ export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions = {}): MixedE3MoritaniProgramme { const f = createMixedE3MoritaniSetup(options); const result: MixedE3MoritaniProgramme = { ...f, game: f.afterSetup, firstStorm: f.afterSetup, firstMentat: null, alliance: null, drawing: null, draws: [] }; if (!f.partner) return result; let g = mixedE3MoritaniReload(f.afterSetup); orderMixedE3MoritaniSpice(g); g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions); result.firstStorm = mixedE3MoritaniReload(g); let firstMentat: MixedE3MoritaniTransition | null = null; for (let n = 0; g.turn === 1 && n < 700; n++) { const before = mixedE3MoritaniReload(g), step = nextMixedE3MoritaniStep(g); assert.ok(step); g = stepMixedE3Moritani(g, step, f.actions); if (g.turn === 2) firstMentat = { before, step: structuredClone(step), after: mixedE3MoritaniReload(g) }; } assert.ok(firstMentat, 'The original first Mentat must hand over to turn two.'); result.firstMentat = firstMentat; g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions); orderMixedE3MoritaniSpice(g, true); const closed = g.nexusCards ? closeMixedE3Moritani(f, g, options.nexusCard) : null; if (closed) { g = closed.game; result.alliance = closed.alliance; result.drawing = closed.drawing; result.draws = [closed.draw]; } g = advanceMixedE3Moritani(g, s => s.phase === 5 && mixedE3MoritaniClean(s), f.actions); mixedE3MoritaniInventory(g); result.game = g; return result; } /** A real phase-five shipment, including the original paid Homeworld entry. */ export function shipMixedE3Moritani(g: Game, actor: string, destination: string, amount: number, elite = 0, actions?: MixedE3MoritaniStep[]): MixedE3MoritaniTransition & { settled: Game } { g = advanceMixedE3Moritani(g, s => s.phase === 5 && s.active === actor && mixedE3MoritaniClean(s), actions); const before = mixedE3MoritaniReload(g), view = viewGame(g, actor); const sources = g.homeworlds ? nativeShipmentSources(view, amount, elite) : undefined; let action: Action; if (destination.startsWith('homeworld:')) { assert.ok(sources); const quote = homeworldShipmentChoice(view, destination, Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0))); assert.ok(quote.action, quote.blocked ?? 'Original paid native entry unavailable.'); action = quote.action; } else { const [site, sector] = destination.split(':'); action = { type: 'ship', territory: site, sector: Number(sector), amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) }; } const step = { actor, action }; g = stepMixedE3Moritani(g, step, actions); const after = mixedE3MoritaniReload(g); g = advanceMixedE3Moritani(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && mixedE3MoritaniClean(s), actions); return { before, step, after, settled: g }; } /** Real phase-six battle choice for the pair created by genuine movement. */ export function openMixedE3MoritaniBattle(g: Game, first: string, second: string, actions?: MixedE3MoritaniStep[]): Game { g = advanceMixedE3Moritani(g, s => s.phase === 6 && mixedE3MoritaniClean(s), actions); const choice = viewGame(g, g.active!).battleChoices.find(b => [first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice, 'The real battle pair must be choosable.'); return stepMixedE3Moritani(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: choice.territory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions); } export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3MoritaniStep[], actions?: MixedE3MoritaniStep[]): Game { for (const plan of plans) { g = stepMixedE3Moritani(g, plan, actions); if (!g.battle?.revealed) g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions); } assert.ok(g.battle?.revealed); return g; } /** The genuine Advanced loss: Moritani's real movement position is attacked by * the selected native's real shipment, and the winning disc survives. With * `disc: 'held'` the native opposes with the very disc Moritani holds, which is * the only legal normal own-card Traitor declaration. */ /** Highest- or lowest-strength legal commitment for one combatant, chosen by * trying the engine's own plan validator. Never assumes a fixed dial or budget. */ function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min', extra: { weapon?: string; defense?: string; reinforcementsSources?: NativeReserveSelections } = {}): Action { const spice = mixedE3MoritaniPlayer(g, actor).spice; const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0]; const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0]; const reasons = new Set(); for (const support of supports) for (const dial of dials) { for (const card of [extra, {}]) { const action: Action = { type: 'battlePlan', leader, dial, support, ...card }; try { applyAction(mixedE3MoritaniReload(g), actor, action); return action; } catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); } } } throw Error(`No legal battle plan for ${actor} (spice ${spice}): ${[...reasons].join(' | ')}`); } /** The genuine Advanced loss: Moritani's real movement position is attacked by * the selected native's real shipment, and the winning disc survives. With * `disc: 'held'` the native opposes with the very disc Moritani holds, which is * the only legal normal own-card Traitor declaration. */ export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game, options: { disc?: 'strongest' | 'held'; enemyCard?: string } = {}): MixedE3MoritaniBattle { const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(state), f.native, f.location, 1, 0, f.actions); const ready = advanceMixedE3Moritani(arrival.settled, s => !s.decision, f.actions); let g = openMixedE3MoritaniBattle(ready, f.moritani, f.native, f.actions); // Finish original Voice, Mentat/posture and preparation acknowledgements // before either validator probe; never validate against an undispatched clone. g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, f.actions); const arrangements = mixedE3MoritaniReload(g); const trained = new Set((g.leaderSkills?.assignments ?? []).map(a => a.leader)); const ownLeader = mixedE3MoritaniPlayer(g, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id)) .sort((a, b) => a.strength - b.strength)[0]; const enemyLeader = (options.disc ?? 'strongest') === 'held' ? mixedE3MoritaniPlayer(g, f.native).leaders.find(l => l.id === f.target)! : mixedE3MoritaniPlayer(g, f.native).leaders .filter(l => !l.dead && l.id !== f.target && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; const ownCard = mixedE3MoritaniPlayer(g, f.moritani).hand.find(c => isDefenseCard(c)); const enemyCard = options.enemyCard ? mixedE3MoritaniPlayer(g, f.native).hand.find(c => c.id === options.enemyCard) : mixedE3MoritaniPlayer(g, f.native).hand.find(c => isWeaponCard(c)); assert.ok(!options.enemyCard || enemyCard, 'An explicitly selected card must be in the native’s original hand.'); const feeSources = enemyCard?.id === REINFORCEMENTS_CARD && g.homeworlds ? viewGame(g, f.native).battle?.reinforcements?.homeworldSources?.[0] : undefined; if (enemyCard?.id === REINFORCEMENTS_CARD && g.homeworlds) assert.ok(feeSources, 'The native must pay from its actual offered World source.'); assert.ok(ownLeader && enemyLeader, 'The original deal must supply the loss discs.'); const plans: MixedE3MoritaniStep[] = [ { actor: f.moritani, action: legalPlan(g, f.moritani, ownLeader.id, 'min', ownCard ? { defense: ownCard.id } : {}) }, { actor: f.native, action: legalPlan(g, f.native, enemyLeader.id, 'max', enemyCard ? { weapon: enemyCard.id, ...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } : {}) }, ]; if (options.enemyCard) { const plan = plans[1].action; assert.ok(plan.type === 'battlePlan'); assert.equal(plan.weapon, options.enemyCard, 'Never silently drop the selected consumer card.'); } const revealed = revealMixedE3MoritaniPlans(mixedE3MoritaniReload(g), plans, f.actions); return { arrangements, location: f.location, arrival, plans, revealed, ownLeader: ownLeader.id, enemyLeader: enemyLeader.id, target: f.target, ownCards: ownCard ? [ownCard.id] : [], enemyCards: enemyCard ? [enemyCard.id] : [] }; } /** Resolve to the next real human boundary; never consumes a physical choice. * `normalCallOwner` is the only seat allowed to call its own Traitor normally. */ export function settleMixedE3MoritaniBoundary(g: Game, normalCallOwner: string | null = null, actions?: MixedE3MoritaniStep[], normalCallCard: string | null = null): MixedE3MoritaniBoundary { const kinds: Readonly> = { battleLosses: 'losses', sukRescue: 'rescue', ixSubstitution: 'substitution', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech', faceDance: 'faceDance', }; for (let n = 0; n < 240; n++) { const kind = g.decision && !g.response && !g.phaseOpening ? kinds[g.decision.kind] : undefined; if (kind) return { game: g, kind }; if (!g.battle && mixedE3MoritaniClean(g) && !g.pendingTreacheryDiscard) return { game: g, kind: 'settled' }; const next = nextMixedE3MoritaniStep(g); assert.ok(next, `Original suffix stalled at ${g.decision?.kind ?? g.phase}.`); if (next.action.type === 'traitorCall') { const battle = g.battle; const opposing = battle && (battle.attacker === next.actor || battle.defender === next.actor) ? battle.plans[battle.attacker === next.actor ? battle.defender : battle.attacker].leader : null; // Only the fixture's own held matching card is a legal normal call. next.action.call = next.actor === normalCallOwner && !!normalCallCard && opposing === normalCallCard && mixedE3MoritaniPlayer(g, next.actor).traitors.includes(normalCallCard); } g = stepMixedE3Moritani(g, next, actions); } throw Error('Original battle suffix did not reach its next physical boundary.'); } export function finishMixedE3MoritaniBattle(g: Game, actions?: MixedE3MoritaniStep[]): Game { return advanceMixedE3Moritani(g, s => !s.battle && mixedE3MoritaniClean(s) && !s.pendingTreacheryDiscard, actions); } /** Same public/held inputs as the sibling Nexus quotes, plus native Moritani. */ export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote { const b = g.battle; assert.ok(b?.revealed); const side = (actor: string): ResolutionCombatant => { const p = mixedE3MoritaniPlayer(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 })), ...(view.reinforcements ? { reinforcementsReserves: { normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0 } } : {}) }; }; const publicBattle = viewGame(g, g.host).battle!; const home = homeworldBattleLocation(g, b.territory); return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn, territory: b.territory, ...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } } : {}), aggressor: publicBattle.aggressor, attacker: side(b.attacker), defender: side(b.defender), voters: publicBattle.traitorVoters.map(actor => ({ id: actor, beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3MoritaniPlayer(g, actor).ally!, called: b.traitorCalls[actor] ?? false, traitors: mixedE3MoritaniPlayer(g, actor).traitors })), participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)], pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: !!g.nexusCards }); } /** True printed Horror/Extortion/… token identities from the actual supply. */ export function mixedE3MoritaniToken(g: Game, kind: TerrorKind) { const token = g.moritaniTerror!.tokens.find(t => t.kind === kind); assert.ok(token); return token; } /** Supply-only native Cunning declaration of an actual unplaced token. */ export function mixedE3MoritaniCunningRequest(g: Game, actor: string, kind: TerrorKind = 'robbery', site = 'red_chasm'): Action { const offer = viewGame(g, actor).nexusMoritani; assert.ok(offer); return { type: 'decision', token: mixedE3MoritaniToken(g, kind).id, territory: site, nexus: offer.event }; } /** Native Terror arrival on a real shipment; the token identity stays private. */ export function mixedE3MoritaniTerrorReveal(g: Game, actor: string): Game { return stepMixedE3Moritani(stepMixedE3Moritani(g, { actor, action: { type: 'decision', reveal: true } }), { actor, action: { type: 'decision', choice: 'spice' } }); } export function assertMixedE3MoritaniRejects(g: Game, actor: string, action: Action): void { const before = mixedE3MoritaniReload(g); assert.throws(() => applyAction(g, actor, action), 'Rejected consumers must not spend original stock.'); assert.deepEqual(g, before); }