Files
Dune/game/moritani-assassinate.ts
T
Quintine eff668b202 Support Moritani assassination of captured and foreign-ghola targets
Retain printed native identity and valid original custody while projecting only
uniform potential bounty before commitment. Resolve living/dead targets once,
clear selected capture/concealment and retain ordinary Ghola return custody.
Selected concealed-outcome disclosure is explicitly provisional.

Verified: 14 affected offline files100/100, types/lint and production build;
six actual choice/gameplay/GameTable SSR journeys and twelve profile paths.
Native capture source47 actions; actual paid Ghola prefix140 actions followed
by explicit conserved seven-action battle. No saved-room/D1/reset/restart or
deployment operation. Runtime-only driver fixes reused unchanged passed checks.
2026-10-24 19:03:03 +11:00

430 lines
15 KiB
TypeScript

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<string>(),
used = new Set<FactionId>(),
cards = new Set<string>();
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,
};
}