import { CHOAM_AUDITOR_ID, createAuditorLeader, leaders as printedLeaders, type Leader } from './cards'; import { FACTIONS, type FactionId } from './catalog'; import { DUKE_VIDAL_ID } from './duke-vidal'; import { CHEAP_HERO_TRAITOR } from './traitors'; export type MoritaniAssassinateReceipt = { event: string; turn: number; territory: string; owner: string; opponent: string; faction: FactionId; opposingLeader: string; stage: 'skills' | 'choice' | 'declined' | 'revealed' | 'replaced'; card: string | null; bounty: number; replacement: string | null; signature: string; }; export type MoritaniAssassinateState = { version: 1; owner: string; /** Authorized Advanced source: normal reveal forfeits this ability for the game. */ normalTraitorCall: boolean; opportunities: MoritaniAssassinateReceipt[]; }; export type MoritaniAssassinateTrigger = { advanced: boolean; owner: string; ownerFaction: FactionId; opponent: string; faction: FactionId; event: string; turn: number; territory: string; opposingLeader: string | null; opposingLeaderSurvived: boolean; normalBattle: boolean; lostBattle: boolean; traitorCalled: boolean; }; export type MoritaniAssassinateContext = { state: MoritaniAssassinateState; receipt: MoritaniAssassinateReceipt; held: readonly string[]; normallyRevealed: readonly string[]; leaders: readonly Leader[]; /** Native producer identities; never included in the pending player choices. */ custodians?: readonly { id: string; faction: FactionId }[]; }; /** The engine owns the full traitor universe and the actual Mentat draw. * These lists verify the still-reserved and permanently removed card zones. */ export type MoritaniAssassinateCustody = { turn: number; held: readonly string[]; removed: readonly string[]; }; export type MoritaniAssassinateChoice = { card: string; name: string; bounty: number; dead: boolean; }; export type MoritaniAssassinateProjectedChoice = { card: string; name: string; /** Printed potential only: actual life/capture/Ghola status is not disclosed. */ potentialBounty: number; }; const opponents: readonly FactionId[] = [ 'atreides', 'beneGesserit', 'guild', 'emperor', 'harkonnen', 'fremen', 'ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', ]; const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value); const exact = (value: unknown, keys: readonly string[]): boolean => !!value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value)) && Object.keys(value).sort().join(',') === [...keys].sort().join(','); const identities = (value: unknown): value is string[] => Array.isArray(value) && value.every(identifier) && new Set(value).size === value.length; function requireAssassinate(value: unknown, message: string): asserts value { if (!value) throw new Error(message); } const printedDiscs = new Map( [...FACTIONS.flatMap((faction) => printedLeaders(faction.id)), createAuditorLeader()] .map((leader) => [leader.id, leader] as const), ); function printed(faction: FactionId, id: string) { const leader = printedDiscs.get(id); return leader?.faction === faction ? leader : undefined; } /** Physical disc identity is distinct from the victorious player's faction. * The engine binds actual plan, effective control and survival before release. * Auditor is CHOAM-only: its printed capture/foreign-Ghola exclusions remain. */ function opposingDiscIdentity(faction: FactionId, id: string) { return id === DUKE_VIDAL_ID || (printedDiscs.has(id) && (id !== CHOAM_AUDITOR_ID || faction === 'choam')); } /** Consistency proof only; the engine separately binds the battle event and * phase continuation. No secret card identity enters this public trigger. */ export function moritaniAssassinateTrigger( input: MoritaniAssassinateTrigger, ): boolean { return ( !!input && input.advanced === true && input.ownerFaction === 'moritani' && identifier(input.owner) && identifier(input.opponent) && input.owner !== input.opponent && identifier(input.event) && identifier(input.territory) && Number.isSafeInteger(input.turn) && input.turn > 0 && opponents.includes(input.faction) && identifier(input.opposingLeader) && opposingDiscIdentity(input.faction, input.opposingLeader) && input.opposingLeaderSurvived === true && input.normalBattle === true && input.lostBattle === true && input.traitorCalled === false ); } export function moritaniAssassinateSignature( receipt: MoritaniAssassinateReceipt, ): string { return JSON.stringify([ 'moritaniAssassinate', receipt.event, receipt.turn, receipt.territory, receipt.owner, receipt.opponent, receipt.faction, receipt.opposingLeader, receipt.stage, receipt.card, receipt.bounty, receipt.replacement, ]); } export function createMoritaniAssassinateOpportunity( input: MoritaniAssassinateTrigger, ): MoritaniAssassinateReceipt { requireAssassinate( moritaniAssassinateTrigger(input), 'Assassinate needs an Advanced native Moritani normal battle loss against a surviving opposing leader, without a traitor call.', ); const receipt: MoritaniAssassinateReceipt = { event: input.event, turn: input.turn, territory: input.territory, owner: input.owner, opponent: input.opponent, faction: input.faction, opposingLeader: input.opposingLeader!, stage: 'choice', card: null, bounty: 0, replacement: null, signature: '', }; receipt.signature = moritaniAssassinateSignature(receipt); return receipt; } export function validateMoritaniAssassinate( state: MoritaniAssassinateState, custody?: MoritaniAssassinateCustody, ): void { requireAssassinate( exact(state, ['version', 'owner', 'normalTraitorCall', 'opportunities']) && state.version === 1 && identifier(state.owner) && typeof state.normalTraitorCall === 'boolean' && Array.isArray(state.opportunities), 'Invalid saved Moritani Assassinate state.', ); if (custody) requireAssassinate( Number.isSafeInteger(custody.turn) && custody.turn > 0 && identities(custody.held) && identities(custody.removed) && !custody.held.some((id) => custody.removed.includes(id)), 'Assassinate has invalid or overlapping physical traitor custody.', ); const events = new Set(), used = new Set(), cards = new Set(); for (const [index, r] of state.opportunities.entries()) { requireAssassinate( exact(r, [ 'event', 'turn', 'territory', 'owner', 'opponent', 'faction', 'opposingLeader', 'stage', 'card', 'bounty', 'replacement', 'signature', ]) && identifier(r.event) && !events.has(r.event) && identifier(r.territory) && r.owner === state.owner && identifier(r.opponent) && r.opponent !== r.owner && opponents.includes(r.faction) && Number.isSafeInteger(r.turn) && r.turn > 0 && (!custody || r.turn <= custody.turn) && (index === 0 || r.turn >= state.opportunities[index - 1].turn) && identifier(r.opposingLeader) && opposingDiscIdentity(r.faction, r.opposingLeader) && ['skills', 'choice', 'declined', 'revealed', 'replaced'].includes(r.stage) && r.signature === moritaniAssassinateSignature(r), 'Assassinate has a changed, duplicated or malformed battle opportunity.', ); events.add(r.event); if (r.stage === 'skills' || r.stage === 'choice' || r.stage === 'declined') { requireAssassinate( r.card === null && r.bounty === 0 && r.replacement === null && ((r.stage !== 'skills' && r.stage !== 'choice') || (index === state.opportunities.length - 1 && (!custody || r.turn === custody.turn))), 'An unspent Assassinate choice cannot contain a reveal, bounty or replacement.', ); continue; } const target = typeof r.card === 'string' ? printed(r.faction, r.card) : undefined; requireAssassinate( target && r.card !== r.opposingLeader && !used.has(r.faction) && !cards.has(r.card!) && Number.isSafeInteger(r.bounty) && (r.bounty === 0 || r.bounty === target.strength), 'Assassinate must use a different native traitor once per opposing faction for zero or printed bounty.', ); used.add(r.faction); cards.add(r.card!); if (r.stage === 'revealed') requireAssassinate( r.replacement === null && (!custody || (r.turn === custody.turn && custody.held.includes(r.card!) && !custody.removed.includes(r.card!))), 'The revealed Assassinate traitor must stay held until its Mentat replacement.', ); else requireAssassinate( identifier(r.replacement) && (printedDiscs.has(r.replacement) || r.replacement === CHEAP_HERO_TRAITOR) && r.replacement !== r.card && (!custody || (custody.removed.includes(r.card!) && !custody.held.includes(r.card!))), 'The replaced Assassinate traitor needs one distinct replacement and its face-up removed card.', ); } } /** Private diagnostics; callers must never gate a public pause on this result. */ export function moritaniAssassinateChoices( context: MoritaniAssassinateContext, ): { blocked: string | null; cards: MoritaniAssassinateChoice[]; unavailable: { card: string; reason: string }[]; } { const { state, receipt, held, normallyRevealed, leaders } = context; validateMoritaniAssassinate(state); requireAssassinate( identities(held) && identities(normallyRevealed) && Array.isArray(leaders), 'Assassinate needs distinct held/revealed traitors and the native leader pool.', ); const current = state.opportunities.find((r) => r.event === receipt?.event); requireAssassinate( current && exact(receipt, Object.keys(current)) && moritaniAssassinateSignature(current) === moritaniAssassinateSignature(receipt) && receipt.signature === current.signature, 'Choose the original saved Assassinate opportunity.', ); const unavailable: { card: string; reason: string }[] = []; const stopped = (blocked: string) => ({ blocked, cards: [], unavailable }); if (receipt.stage !== 'choice') return stopped('This Assassinate opportunity has already ended.'); if (state.normalTraitorCall) return stopped( 'Moritani forfeited Assassinate Leaders for the rest of this game after revealing its Traitor Card normally.', ); if ( state.opportunities.some( (r) => r.faction === receipt.faction && (r.stage === 'revealed' || r.stage === 'replaced'), ) ) return stopped('Assassinate has already been used against this faction.'); const reserved = new Set( state.opportunities .filter((r) => r.stage === 'revealed' || r.stage === 'replaced') .map((r) => r.card!), ); const cards: MoritaniAssassinateChoice[] = []; for (const card of held) { const native = printed(receipt.faction, card); if (!native || card === receipt.opposingLeader) continue; let reason: string | null = null; if (normallyRevealed.includes(card)) reason = 'Using an already normally revealed Traitor Card for Assassinate remains pending.'; else if (reserved.has(card)) reason = 'That Traitor Card is already reserved or removed by Assassinate.'; const matches = leaders.filter((l) => l.id === card), leader = matches[0]; const nativeCustodian = (id: string | undefined, faction: FactionId) => !!id && id !== receipt.opponent && context.custodians?.some(player => player.id === id && player.faction === faction); if ( !reason && (matches.length !== 1 || !leader || leader.name !== native.name || leader.strength !== native.strength || leader.faction !== receipt.faction || typeof leader.dead !== 'boolean' || (leader.capturedBy !== undefined && !nativeCustodian(leader.capturedBy, 'harkonnen')) || (leader.gholaBy !== undefined && !nativeCustodian(leader.gholaBy, 'tleilaxu')) || (leader.concealed && !nativeCustodian(leader.concealed.captor, 'harkonnen')) || (card === CHOAM_AUDITOR_ID && (leader.capturedBy || leader.gholaBy || leader.concealed)) || (leader.controller !== undefined && leader.controller !== receipt.opponent)) ) reason = 'Assassinate needs one canonical native target with valid original custody.'; if (reason) { unavailable.push({ card, reason }); continue; } cards.push({ card, name: native.name, bounty: leader.dead ? 0 : native.strength, dead: leader.dead, }); } return { blocked: cards.length ? null : (unavailable[0]?.reason ?? 'No different unrevealed native Traitor Card of this opposing faction is held.'), cards, unavailable, }; } /** Pending options do not reveal whether a secret captive has been executed. * Normal, captured, already-dead and foreign-ghola identities use the same shape. * Only a committed reveal resolves actual death and awarded bounty. */ export function projectedMoritaniAssassinateChoices(context: MoritaniAssassinateContext) { const choices = moritaniAssassinateChoices(context); const cards: MoritaniAssassinateProjectedChoice[] = choices.cards.map(choice => ({ card: choice.card, name: choice.name, potentialBounty: printed(context.receipt.faction, choice.card)!.strength, })); return { blocked: choices.blocked, cards }; } export function quoteMoritaniAssassinate( context: MoritaniAssassinateContext, card: string, ): { receipt: MoritaniAssassinateReceipt; leader: string; bounty: number; kill: boolean; } { const choices = moritaniAssassinateChoices(context), choice = choices.cards.find((c) => c.card === card); requireAssassinate( !choices.blocked && choice, choices.blocked ?? 'Choose one eligible held Traitor Card for Assassinate.', ); const receipt: MoritaniAssassinateReceipt = { ...context.receipt, stage: 'revealed', card: choice.card, bounty: choice.bounty, replacement: null, signature: '', }; receipt.signature = moritaniAssassinateSignature(receipt); return { receipt, leader: choice.card, bounty: choice.bounty, kill: !choice.dead, }; }