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.
This commit is contained in:
Quintine committed 2026-10-24 19:03:03 +11:00
1 parent 61828150b9
commit eff668b202
25 files changed
+1041 -57

No files matched your search

+1 -1
View File
@@ -1829,7 +1829,7 @@ function policyActions(g: GameView): Action[] {
if (d.kind === 'moritaniAssassinate') {
const pending = g.moritaniAssassinate?.pending;
if (!pending || pending.event !== d.event) return [];
const cards = [...pending.cards].sort((a,b) => b.bounty-a.bounty || a.card.localeCompare(b.card));
const cards = [...pending.cards].sort((a,b) => b.potentialBounty-a.potentialBounty || a.card.localeCompare(b.card));
return [cards.length && !pending.blocked
? {type:'decision',event:d.event,card:cards[0].card}
: {type:'decision',event:d.event,decline:true}];
+12 -5
View File
@@ -62,7 +62,7 @@ import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningR
import { fremenNexusModeSupported } from './fremen-nexus-profile';
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, projectedMoritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
import { moritaniTerrorAssassinationPool, moritaniTerrorAssassinationValue } from './moritani-terror-assassination';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
@@ -8845,7 +8845,7 @@ function moritaniAssassinateModeSupported(g: Game) {
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent);
return {state:g.moritaniAssassinate!,receipt,held:p.traitors,
normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders};
normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders,custodians:g.players};
}
function moritaniAssassinateIntegrity(g: Game) {
const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume;
@@ -8945,7 +8945,7 @@ function projectedMoritaniAssassinate(g: Game, viewer: string) {
if (!state) return null;
const pending = state.opportunities.find(r => r.stage === 'choice');
const own = viewer === state.owner;
const choices = pending && own ? nexusRule(() => moritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null;
const choices = pending && own ? nexusRule(() => projectedMoritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null;
return {owner:state.owner,blocked:own && state.normalTraitorCall
? 'Moritani forfeited Assassinate Leaders for the rest of this game after revealing its Traitor Card normally.' : null,
pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory,
@@ -8973,9 +8973,16 @@ function actMoritaniAssassinate(g: Game, decision: Extract<Decision,{kind:'morit
target.dead = true;
target.deaths++;
delete target.usedAt;
if (getPlayer(g, r.opponent).faction === 'harkonnen') returnCaptives(g, getPlayer(g, r.opponent));
finishLeaderSkillCustody(g);
}
// The selected public reveal settles this original disc even if a hidden
// execution already killed it. Ordinary foreign-ghola return custody stays.
delete target.capturedBy;
delete target.concealed;
if (quote.kill) {
const harkonnen = byFaction(g, 'harkonnen');
if (harkonnen) returnCaptives(g, harkonnen);
}
finishLeaderSkillCustody(g);
const owner = getPlayer(g,r.owner);
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
owner.spice += quote.bounty;
+29 -4
View File
@@ -45,6 +45,8 @@ export type MoritaniAssassinateContext = {
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. */
@@ -59,6 +61,12 @@ export type MoritaniAssassinateChoice = {
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',
@@ -335,6 +343,9 @@ export function moritaniAssassinateChoices(
'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 ||
@@ -343,14 +354,15 @@ export function moritaniAssassinateChoices(
leader.strength !== native.strength ||
leader.faction !== receipt.faction ||
typeof leader.dead !== 'boolean' ||
leader.capturedBy ||
leader.gholaBy ||
leader.concealed ||
(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 with missing, altered, captured or foreign-ghola target custody remains pending.';
'Assassinate needs one canonical native target with valid original custody.';
if (reason) {
unavailable.push({ card, reason });
continue;
@@ -371,6 +383,19 @@ export function moritaniAssassinateChoices(
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,
+6 -6
View File
@@ -3421,15 +3421,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
'A Mentat replacement is an ordinary physical Traitor Card, including the original Ix Hero identity when present. After original owned initialization, retire the victim permanently. The replacement itself may later be legally exchanged, returned and drawn again; keep its old receipt unchanged and count one live card, not one per historical reference.',
'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.',
'The surviving actual played disc need not be native to its victorious controller. Native Ecaz/Duke6, Tleilaxu/foreign Guild5, CHOAM/Auditor2 and Harkonnen captives use the winner’s printed-faction targets. Captivity or Ghola control never rewrites native identity. Auditor has a matching Traitor outside CHOAM’s ordinary five; it can be a different native target for two but not the disc just played. Duke has no Traitor, and Cheap Hero is not a physical opposing disc. Exceptional captured/Ghola targets remain separate.',
'The surviving played disc need not be native to its victorious controller. Captivity and Ghola control never rewrite native identity. A different printed-faction target may itself be captured or an ordinary foreign ghola. Pending choices show printed potential bounty without disclosing actual life/custody; the selected public reveal pays only if alive. Its capture/concealment ends, while ordinary ghola return custody remains. Revealing a selected concealed execution’s actual outcome is provisional. Auditor retains its explicit capture/Ghola exclusions; Duke has no Traitor and Cheap Hero is not a physical opposing disc.',
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Fresh supported plain/card Advanced Moritani starts initialize this printed advantage automatically; Basic never gains it. Standalone or paired Ecaz/Moritani/classic all14 Skills retains optional independent cards, original Tech3+ and Advanced Strongholds2+, with Advanced Harkonnen and mixed-family Skills guards. Played saves do not gain invented prior-call history.',
],
checklist: [
{area:'Implementation',status:'Partial',detail:'Canonical played-disc/native target semantics remain. Moritani replacement accepts physical Hero separately; permanent victim cards join the shared removed census, while historical replacement IDs may move/recur legally. Exchange, Face Dancer, Betrayal and Rihani consumers bind their own source zone. Current authorization/census, revealed-card reservation and original parent remain.',evidence:['game/moritani-assassinate.ts','game/nexus-traitor-exchange.ts','game/nexus-face-dancers.ts','game/nexus-harkonnen-betrayal.ts','game/rihani-decipherer.ts','game/engine.ts']},
{area:'Player controls',status:'Partial',detail:'Actual390px readiness reaches private Hero inspector. Original Secret Ally returns historical Hasimir6/Caid3, keeps Esmar3 and restores Mori placement; passing reaches next Storm. Later actual Esmar3 reveal draws one Hasimir6 again, while two victim markers remain public and out of play. No overflow/browser errors.',evidence:['components/moritani-assassinate.tsx','components/nexus-traitors.tsx','components/game-table.tsx']},
{area:'AI',status:'Partial',detail:'Four Ready policies preserve owned initialization before Hero draw; four mandatory-return policies legally finish the actual augmented hand and restore parent. Each return policy keeps Hasimir in this position; human/scripted old-card return is distinct. No strategic calibration claim.',evidence:['game/bots.ts','game/nexus-traitor-options.ts']},
{area:'Documentation',status:'Partial',detail:'Permanent victim markers, active physical cards and historical draw references are separate. Old absent-zone tuple grammar remains; later markers do not rewrite completed history. Receipt consistency does not authenticate every past save. Target exceptions, private-step UX, Skills/Harkonnen and broader modes remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/NEXUS_HARKONNEN_RULES.md','docs/NEXUS_TLEILAXU_RULES.md','docs/RULE_DECISIONS.md']},
{area:'Verification',status:'Partial',detail:'Affected15-file134/134, eleven original API/policy cases and three actual390px journeys pass. Native original Hero, old-card return, restored placement, second paid battle/retirement and repeated physical draw preserve source history and one live card. Older fixtures now consume actual borrowed Voice before native powers/plans; false ownership/uniqueness and wording/markup pins removed. No saved/deployed/full-mode acceptance.',evidence:['tests/moritani-hero-replacement.test.ts','tests/moritani-replacement-exchange.test.ts','tests/nexus-traitor-exchange.test.ts','tests/nexus-face-dancers.test.ts','tests/nexus-harkonnen-betrayal.test.ts','tests/rihani-face-dancers.test.ts']},
{area:'Implementation',status:'Partial',detail:'Canonical native target identity now admits valid captures and ordinary foreign Gholas. Uniform pending potential bounty keeps actual death/custody server-side until reveal; living targets die/pay once, dead targets yield zero. Selected capture/concealment clears, ordinary Ghola return custody and original physical retirement/replacement remain. Auditor/controller and mode guards stay.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
{area:'Player controls',status:'Partial',detail:'Persistent choices display printed potential bounty uniformly and submit the original event-bound reveal/decline. Actual outcome is public only after commitment; prior Hero/private replacement and public face-up history retain their controls.',evidence:['components/moritani-assassinate.tsx','components/nexus-traitors.tsx','components/game-table.tsx']},
{area:'AI',status:'Partial',detail:'All four profiles use only projected printed potential bounty and preserve original reveal/decline legality. Twelve current own-view capture/execution/Ghola proposals and continuations pass; strategy and calibration remain deferred.',evidence:['game/bots.ts','tests/moritani-assassinate-target.test.ts']},
{area:'Documentation',status:'Partial',detail:'Supplied native-target selection, selected concealed-outcome disclosure as provisional policy, retained Ghola loan and explicit controlled staging are recorded. Permanent victim markers and historical draws remain distinct. Wider modes and final assurance remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']},
{area:'Verification',status:'Partial',detail:'Frozen14-file100/100, types/lint/build, six actual choice/gameplay/table-SSR journeys and twelve legal profile paths pass. Native47-action capture branches and a140-action paid-Ghola prefix plus controlled seven-action battle remain scoped; no complete-mode, broad custody/recovery or browser/deployed certificate.',evidence:['tests/moritani-assassinate-target.test.ts','tests/fixture-moritani-assassinate-target.ts','tests/fixture-moritani-assassinate-ghola-target.ts','tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-controls.test.tsx','tests/moritani-hero-replacement.test.ts']},
],
related: [
'faction-moritani',