Files
Dune/tests/fixture-mixed-e3-moritani-nexus.ts

786 lines
47 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Record<MixedE3MoritaniFamily, FactionId>>;
/** 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<Record<FactionId, readonly (Card['kind'] | EcazTreacheryCardId)[]>>;
/** 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<Record<FactionId, string>>;
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<Record<string, true>> = {
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<Record<string, readonly string[]>> = {
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<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 (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<number>(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<Record<FactionId, LeaderSkillId>>): 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<MixedE3MoritaniFamily, FactionId> = {
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<Record<FactionId, LeaderSkillId>> = {};
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<Record<FactionId, string>> = {};
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<string>(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<string>();
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<Record<string, MixedE3MoritaniBoundary['kind']>> = {
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);
}