diff --git a/components/moritani-assassinate.tsx b/components/moritani-assassinate.tsx
index 0f1afcbe..d03dd332 100644
--- a/components/moritani-assassinate.tsx
+++ b/components/moritani-assassinate.tsx
@@ -17,6 +17,22 @@ export function MoritaniAssassinate({
busy: boolean;
}) {
const [selected, setSelected] = useState('');
+ return ;
+}
+
+export function MoritaniAssassinateChoice({
+ game,
+ act,
+ busy,
+ selected,
+ onSelect,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+ selected: string;
+ onSelect: (card: string) => void;
+}) {
const offer = game.moritaniAssassinate;
const decision = game.decision;
if (
@@ -30,9 +46,9 @@ export function MoritaniAssassinate({
const pending = offer.pending;
const card = pending.cards.find((candidate) => candidate.card === selected) ?? pending.cards[0];
const blocked = busy || !!game.response || !!game.truthtrance;
+ const revealBlocked = blocked || !!pending.blocked;
const opponent = game.players.find((player) => player.id === pending.opponent);
const factionName = opponent ? faction(opponent.faction).name : 'the opposing faction';
- const selectedLeader = card && game.allLeaders.find((leader) => leader.id === card.card);
return (
Assassinate Leaders
@@ -43,7 +59,10 @@ export function MoritaniAssassinate({
This advantage is available once against each faction. It cannot be canceled
- with Karama. A dead target remains a legal choice, but yields no spice.
+ with Karama. The potential bounty is the leader’s printed strength and is
+ awarded only if the target is alive. An already-dead target remains a
+ legal choice and awards no spice. Revealing the physical Traitor Card
+ makes the actual outcome public.
{pending.blocked && {pending.blocked}
}
{pending.cards.length > 0 ? (
@@ -54,31 +73,32 @@ export function MoritaniAssassinate({
aria-label="Traitor Card to reveal"
className="min-h-11 w-full rounded border border-[#65644b] bg-[#171b17] px-3 py-2"
value={card?.card ?? ''}
- disabled={blocked}
- onChange={(event) => setSelected(event.currentTarget.value)}
+ disabled={revealBlocked}
+ onChange={(event) => onSelect(event.currentTarget.value)}
>
{pending.cards.map((candidate) => (
- {candidate.name}{candidate.dead ? ' · dead target · 0 spice' : ` · ${candidate.bounty} spice`}
+ {candidate.name} · {candidate.potentialBounty} spice potential bounty
))}
{card && (
-
{card.name} · {card.dead ? '0 spice; target already dead' : `${card.bounty} spice bounty`}
-
Printed leader strength: {selectedLeader?.strength ?? 'unknown'}
+
{card.name} · {card.potentialBounty} spice potential bounty
+
Printed leader strength: {card.potentialBounty}
)}
card && !blocked && act({ type: 'decision', event: pending.event, card: card.card })}
+ disabled={revealBlocked || !card}
+ onClick={() => card && !revealBlocked && act({ type: 'decision', event: pending.event, card: card.card })}
>
Reveal {card?.name ?? 'selected Traitor Card'} publicly
@@ -87,6 +107,7 @@ export function MoritaniAssassinate({
No eligible opposing-faction Traitor Card is available for this advantage.
)}
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}];
diff --git a/game/engine.ts b/game/engine.ts
index 944ca6e1..e82e5eff 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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 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,
diff --git a/game/reference.ts b/game/reference.ts
index 76023a22..5e877497 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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',
diff --git a/tests/advanced-moritani-skills-assassinate.test.ts b/tests/advanced-moritani-skills-assassinate.test.ts
index f58da6bf..7abdafa7 100644
--- a/tests/advanced-moritani-skills-assassinate.test.ts
+++ b/tests/advanced-moritani-skills-assassinate.test.ts
@@ -134,7 +134,7 @@ void test('Advanced all14 assassination kills only the held native trainer, retu
assert.equal(pending.phase, 6);
assert.ok(pending.moritaniAssassinateResume!.continuation.cards.includes(card));
assert.equal(guild.leaders.find(l => l.id === pending.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader)!.dead, false);
- assert.equal(viewGame(pending, moritani.id).moritaniAssassinate!.pending!.cards.find(c => c.card === target)!.bounty, killed.strength);
+ assert.equal(viewGame(pending, moritani.id).moritaniAssassinate!.pending!.cards.find(c => c.card === target)!.potentialBounty, killed.strength);
assert.deepEqual(viewGame(pending, guild.id).moritaniAssassinate!.pending!.cards, []);
reject(pending, guild.id, action);
reject(pending, moritani.id, { ...action, event: 'stale' });
diff --git a/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts b/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts
index fd319dd9..e74facb5 100644
--- a/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts
+++ b/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts
@@ -260,7 +260,7 @@ void test('Advanced Moritani after-loss assassination kills the actual unused tr
reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader });
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
- assert.equal(quote.bounty, 3);
+ assert.equal(quote.potentialBounty, 3);
let killed = step(pending, { actor: f.owner, action });
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
diff --git a/tests/fixture-moritani-assassinate-ghola-target.ts b/tests/fixture-moritani-assassinate-ghola-target.ts
new file mode 100644
index 00000000..dd01a1a9
--- /dev/null
+++ b/tests/fixture-moritani-assassinate-ghola-target.ts
@@ -0,0 +1,181 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { territory } from '../game/board';
+import {
+ FOREIGN_GHOLA_DISC,
+ createMoritaniForeignGholaWinnerFixture,
+} from './fixture-moritani-foreign-ghola-winner';
+import {
+ advanceMixedE3Moritani,
+ assertMixedE3MoritaniPhysical,
+ mixedE3MoritaniClean,
+ mixedE3MoritaniPlayer,
+ mixedE3MoritaniReload,
+ mixedE3MoritaniTraitorCustody,
+ openMixedE3MoritaniBattle,
+ revealMixedE3MoritaniPlans,
+ settleMixedE3MoritaniBoundary,
+ type MixedE3MoritaniStep,
+} from './fixture-mixed-e3-moritani-nexus';
+
+/** Stocks survive the explicitly controlled later position and its real loss. */
+export function moritaniAssassinateGholaTargetInventory(g: Game) {
+ return {
+ cards: [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)]
+ .map(card => card.id).sort(),
+ traitors: mixedE3MoritaniTraitorCustody(g),
+ forces: g.players.map(player => ({
+ id: player.id,
+ total: player.reserves + player.tanks +
+ Object.values(player.forces).reduce((sum, count) => sum + count, 0),
+ elite: (player.elites?.reserves ?? 0) + (player.elites?.tanks ?? 0) +
+ Object.values(player.elites?.forces ?? {}).reduce((sum, count) => sum + count, 0),
+ })),
+ };
+}
+
+/** One disclosed physical swap, including a possible original Face Dancer slot. */
+function swapHeldTraitor(g: Game, owner: string, target: string) {
+ const player = mixedE3MoritaniPlayer(g, owner);
+ const previous = player.traitors[0];
+ assert.ok(previous, 'Moritani needs one actual held card for the conserved swap.');
+ if (player.traitors.includes(target)) return { previous: target, zone: 'already held' };
+ const before = mixedE3MoritaniTraitorCustody(g);
+ const reserve = g.traitorReserve?.indexOf(target) ?? -1;
+ let zone: string;
+ if (reserve >= 0) {
+ g.traitorReserve![reserve] = previous;
+ zone = 'original Traitor reserve';
+ } else {
+ const holder = g.players.find(seat => seat.id !== owner && seat.traitors.includes(target));
+ if (holder) {
+ holder.traitors[holder.traitors.indexOf(target)] = previous;
+ zone = `${holder.faction} held Traitor Card`;
+ } else {
+ const dancerOwner = g.players.find(seat => seat.faceDancers?.some(card => card.leader === target));
+ const dancer = dancerOwner?.faceDancers?.find(card => card.leader === target);
+ assert.ok(dancer && !dancer.revealed,
+ 'The selected card must exist in one original unrevealed physical zone.');
+ dancer.leader = previous;
+ zone = `${dancerOwner!.faction} unrevealed Face Dancer slot`;
+ }
+ }
+ player.traitors[0] = target;
+ assert.deepEqual(mixedE3MoritaniTraitorCustody(g), before,
+ 'The controlled swap must conserve every physical Traitor Card.');
+ return { previous, zone };
+}
+
+/**
+ * Genuine native death and paid foreign revival, followed by a deliberately
+ * controlled conserved later battle. This is not a complete unstaged game.
+ * The target remains the engine-produced Guild disc in its original roster;
+ * no target life, death history, capture or Ghola field is assigned here.
+ */
+export function createMoritaniAssassinateGholaTargetFixture() {
+ const source = createMoritaniForeignGholaWinnerFixture();
+ const prefix = mixedE3MoritaniReload(source.revival.after);
+ const owner = source.moritani, opponent = source.guild, target = FOREIGN_GHOLA_DISC;
+ const originalTarget = structuredClone(mixedE3MoritaniPlayer(prefix, opponent)
+ .leaders.find(leader => leader.id === target)!);
+ assert.equal(originalTarget.dead, false);
+ assert.equal(originalTarget.gholaBy, source.tleilaxu);
+ assert.equal(originalTarget.faction, 'guild');
+ assert.ok(prefix.advanced && prefix.nexusCards && !prefix.leaderSkills);
+ assert.equal(prefix.phase, 4);
+ assert.ok(mixedE3MoritaniClean(prefix) && !prefix.pendingRevival && !prefix.battle);
+ assert.equal(prefix.moritaniAssassinate!.opportunities.length, 0);
+ assertMixedE3MoritaniPhysical(prefix);
+ const originalInventory = moritaniAssassinateGholaTargetInventory(prefix);
+
+ // The source factory continues farther for its own winning-disc witness.
+ // Keep only its actual action prefix through this paid revival and the
+ // immediately following native response passes, before later phase readiness.
+ const revivalIndex = source.actions.findIndex(step => step.actor === source.tleilaxu &&
+ step.action.type === 'reviveForeignGhola' && step.action.leader === target);
+ assert.ok(revivalIndex >= 0);
+ let sourceActionCount = revivalIndex + 1;
+ while (source.actions[sourceActionCount]?.action.type === 'passResponse') sourceActionCount++;
+ const sourceActions = structuredClone(source.actions.slice(0, sourceActionCount));
+
+ let game = mixedE3MoritaniReload(prefix);
+ const swap = swapHeldTraitor(game, owner, target);
+ game.phase = 6;
+ game.ready = [];
+ game.order = [opponent, owner, ...game.order.filter(id => id !== opponent && id !== owner)];
+ game.active = opponent;
+ for (const player of game.players) {
+ player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
+ player.forces = {};
+ if (player.elites) {
+ player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
+ player.elites.forces = {};
+ }
+ }
+ const destination = ['arrakeen', 'carthag', 'sietch_tabr']
+ .map(id => ({ id, sector: territory(id).sectors.find(sector => sector !== game.storm) }))
+ .find(candidate => candidate.sector !== undefined);
+ assert.ok(destination);
+ const location = `${destination.id}:${destination.sector}`;
+ for (const actor of [opponent, owner]) {
+ const player = mixedE3MoritaniPlayer(game, actor);
+ assert.ok(player.reserves >= 3);
+ player.reserves -= 3;
+ player.forces[location] = 3;
+ }
+ assert.deepEqual(moritaniAssassinateGholaTargetInventory(game), originalInventory);
+ assertMixedE3MoritaniPhysical(game);
+ const arranged = mixedE3MoritaniReload(game);
+
+ const winningDisc = mixedE3MoritaniPlayer(game, opponent).leaders
+ .filter(leader => leader.id !== target && !leader.dead && !leader.capturedBy &&
+ !leader.gholaBy && !leader.usedAt)
+ .sort((first, second) => second.strength - first.strength)[0];
+ const losingDisc = mixedE3MoritaniPlayer(game, owner).leaders
+ .filter(leader => !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt)
+ .sort((first, second) => first.strength - second.strength)[0];
+ assert.ok(winningDisc && losingDisc && winningDisc.strength > losingDisc.strength,
+ 'Different living native Guild and Moritani discs must produce the unarmed loss.');
+ const actions: MixedE3MoritaniStep[] = [];
+ game = openMixedE3MoritaniBattle(game, opponent, owner, actions);
+ game = advanceMixedE3Moritani(game, state => !!state.battle && mixedE3MoritaniClean(state) &&
+ !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
+ const plans: MixedE3MoritaniStep[] = [
+ { actor: opponent, action: { type: 'battlePlan', leader: winningDisc.id, dial: 0, support: 0 } },
+ { actor: owner, action: { type: 'battlePlan', leader: losingDisc.id, dial: 0, support: 0 } },
+ ];
+ game = revealMixedE3MoritaniPlans(game, plans, actions);
+ const boundary = settleMixedE3MoritaniBoundary(game, null, actions);
+ assert.equal(boundary.kind, 'assassination',
+ 'The real different-Guild-disc victory must open the original native target choice.');
+ game = boundary.game;
+ const decision = game.decision;
+ assert.ok(decision?.kind === 'moritaniAssassinate' && decision.player === owner);
+ const event = decision.event;
+ const receipt = game.moritaniAssassinate!.opportunities.find(candidate => candidate.event === event)!;
+ assert.equal(receipt.opponent, opponent);
+ assert.equal(receipt.faction, 'guild');
+ assert.equal(receipt.opposingLeader, winningDisc.id);
+ assert.notEqual(receipt.opposingLeader, target);
+ assert.deepEqual(mixedE3MoritaniPlayer(game, opponent).leaders.find(leader => leader.id === target),
+ originalTarget, 'The genuinely revived target must remain untouched through the later battle.');
+ const choice = viewGame(game, owner).moritaniAssassinate!.pending!.cards
+ .find(candidate => candidate.card === target);
+ assert.ok(choice, 'The actual held foreign-Ghola native identity must be selectable.');
+ assert.equal(choice.potentialBounty, originalTarget.strength);
+ assert.deepEqual(moritaniAssassinateGholaTargetInventory(game), originalInventory);
+ assertMixedE3MoritaniPhysical(game);
+ const action: Action = { type: 'decision', event, card: target };
+ const staging = [
+ ...source.staging,
+ 'Reuse only the source fixture’s genuine native Guild death and paid Tleilaxu foreign-revival prefix. The later source winning-Ghola battle is not used as this target witness.',
+ `Explicitly swap Moritani’s physical ${swap.previous} Traitor Card with ${target} from the ${swap.zone}; preserve the complete original Traitor/Face Dancer census. This is controlled card positioning, not a new deal or a natural draw.`,
+ 'Explicitly stage the later phase-six/order/army position: return every current board counter to its own reserves, retain all Tanks and elite stocks, then place three normal Guild and three normal Moritani counters together outside the unchanged storm. No wallet, hand, mode, target disc or custody field is assigned.',
+ 'Use actual chooseBattle, ordinary preparation, two unarmed private Battle Plans and normal Traitor declines. A different living native Guild disc wins; the original paid foreign Ghola remains unchanged in the Guild roster with Tleilaxu custody. Stop at the real Moritani Assassinate Leaders decision.',
+ ];
+ return { game, owner, opponent, target, event, potentialBounty: choice.potentialBounty,
+ action, originalInventory, sourceActions, sourceActionCount, actions,
+ actionCount: sourceActions.length + actions.length, staging,
+ source, prefix, arranged, originalTarget, tleilaxu: source.tleilaxu,
+ location, plans, winningDisc: winningDisc.id, losingDisc: losingDisc.id };
+}
diff --git a/tests/fixture-moritani-assassinate-target.ts b/tests/fixture-moritani-assassinate-target.ts
new file mode 100644
index 00000000..45af0a7a
--- /dev/null
+++ b/tests/fixture-moritani-assassinate-target.ts
@@ -0,0 +1,267 @@
+import assert from 'node:assert/strict';
+import { applyAction, captureCandidates, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
+
+export type AssassinateTargetStep = { actor: string; action: Action };
+export type AssassinateTargetTransition = { before: Game; steps: AssassinateTargetStep[]; after: Game };
+export const assassinateTargetPlayer = (game: Game, id: string) => {
+ const player = game.players.find(player => player.id === id); assert.ok(player); return player;
+};
+export const assassinateTargetClean = (game: Game) => !game.phaseOpening && !game.response &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
+export const assassinateTargetInventory = (game: Game) => {
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
+ const ids = cards.map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
+ return ids;
+};
+export const assassinateTargetTraitors = (game: Game) => {
+ const cards = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
+ [...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)]),
+ ...(game.moritaniAssassinate?.opportunities.filter(receipt => receipt.stage === 'replaced').map(receipt => receipt.card!) ?? [])];
+ assert.equal(new Set(cards).size, cards.length, 'Every original physical Traitor Card has one current slot');
+ return cards.sort();
+};
+
+/** Original scalar shuffle order; UUIDs and other native byte entropy remain
+ * unchanged. Selected values are used only by the actual capture permutation. */
+function originalLottery(run: () => T, selected?: readonly number[]): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1)
+ array[0] = selected && cursor < selected.length ? selected[cursor++] : 0xffffffff;
+ else Reflect.apply(original, crypto, [array]);
+ return array;
+ };
+ try {
+ const result = run();
+ if (selected) assert.equal(cursor, selected.length, 'The original capture consumes exactly its selected permutation');
+ return result;
+ } finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+export function actAssassinateTarget(game: Game, actor: string, action: Action,
+ actions: AssassinateTargetStep[] = []): Game {
+ const next = originalLottery(() => applyAction(game, actor, action));
+ actions.push({ actor, action: structuredClone(action) }); return next;
+}
+/** Native windows only; captures, assassination and private plans stay owned
+ * caller choices. Optional Terror placement and special Karama are declined. */
+export function nextAssassinateTarget(game: Game): AssassinateTargetStep | null {
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
+ const decision = game.decision;
+ if (decision?.kind === 'captureOffer' || decision?.kind === 'capturedLeader' ||
+ decision?.kind === 'moritaniAssassinate') return null;
+ if (game.status === 'setup' && game.setupStage === 'traitors' &&
+ assassinateTargetPlayer(game, 'moritani').traitorChoices.length)
+ return { actor: 'moritani', action: { type: 'traitor', leader: 'emperor-3' } };
+ if (decision?.kind === 'moritaniSetup')
+ return { actor: decision.player, action: { type: 'decision', territory: 'imperial_basin', sector: 11 } };
+ if (decision?.kind === 'moritaniPlacement')
+ return { actor: decision.player, action: { type: 'decision', decline: true } };
+ if (decision?.kind === 'moritaniSpecialKarama')
+ return { actor: decision.player, action: { type: 'decision', event: game.moritaniSpecialKarama!.source.event, decline: true } };
+ }
+ return nextRicheseStrongholdsNativeStep(game);
+}
+export function advanceAssassinateTarget(game: Game, until: (state: Game) => boolean,
+ actions: AssassinateTargetStep[] = []): Game {
+ for (let count = 0; count < 1800; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The genuine assassination-target chronology ended before its owned boundary');
+ const next = nextAssassinateTarget(game);
+ assert.ok(next, `Choose the actual capture, private plan or assassination at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actAssassinateTarget(game, next.actor, next.action, actions);
+ }
+ throw Error('The native assassination-target chronology did not reach its finite boundary');
+}
+export function passAssassinateTarget(game: Game, actions: AssassinateTargetStep[] = []): Game {
+ for (let count = 0; game.response && count < 60; count++) {
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ game = actAssassinateTarget(game, actor.id, { type: 'passResponse' }, actions);
+ }
+ assert.ok(!game.response); return game;
+}
+function capturePermutation(pool: readonly string[], target: string) {
+ const desired = [target, ...pool.filter(id => id !== target)], current = [...pool], rolls: number[] = [];
+ assert.equal(current.filter(id => id === target).length, 1);
+ for (let index = current.length - 1; index > 0; index--) {
+ const from = current.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
+ rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
+ [current[index], current[from]] = [current[from], current[index]];
+ }
+ assert.deepEqual(current, desired); return rolls;
+}
+
+/** Genuine original capture, including its own response if a card keeps it
+ * open. Select only this real permutation, never candidate bodies or custody. */
+function declareCapture(source: Game, target: string, actions: AssassinateTargetStep[]) {
+ assert.equal(source.decision?.kind, 'captureOffer');
+ const owner = source.decision!.player;
+ const pool = captureCandidates(source, 'emperor', 'carthag').map(leader => leader.id);
+ const rolls = capturePermutation(pool, target), before = structuredClone(source), start = actions.length;
+ let game = source, declared = structuredClone(source);
+ originalLottery(() => {
+ const step: AssassinateTargetStep = { actor: owner, action: { type: 'decision', accept: true } };
+ game = applyAction(game, step.actor, step.action); actions.push(structuredClone(step));
+ declared = structuredClone(game);
+ for (let count = 0; game.response && count < 60; count++) {
+ assert.equal(game.response.kind, 'capture');
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ const pass: AssassinateTargetStep = { actor: actor.id, action: { type: 'passResponse' } };
+ game = applyAction(game, pass.actor, pass.action); actions.push(pass);
+ }
+ }, rolls);
+ assert.equal(game.decision?.kind, 'capturedLeader');
+ assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader, target);
+ return { game, declared, transition: { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) },
+ pool, rolls };
+}
+
+/** Public Advanced Harkonnen/Emperor/Moritani. Emperor actually divides one
+ * paid shipment between Carthag and Mori's real setup position. Harkonnen wins
+ * the first original battle, captures Burseg through its native lottery and
+ * keeps or executes him. A different living Emperor disc then beats Moritani.
+ * Stop at that actual assassination opportunity, before any reveal/decline. */
+export function createMoritaniAssassinateTargetFixture(options: { execute?: boolean } = {}) {
+ const captor = 'harkonnen', opponent = 'emperor', owner = 'moritani', target = 'emperor-3';
+ let game = createGame('MORITANIASSASSINATETARGET', newPlayer(captor, 'Harkonnen', 'harkonnen'), true, ['ecaz']);
+ joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
+ joinGame(game, newPlayer(owner, 'Moritani', 'moritani'));
+ const initial = structuredClone(game), actions: AssassinateTargetStep[] = [];
+ const act = (actor: string, action: Action) => { game = actAssassinateTarget(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceAssassinateTarget(game, until, actions); };
+ const transition = (run: () => void): AssassinateTargetTransition => {
+ const before = structuredClone(game), start = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
+ assert.deepEqual(game.players.map(player => player.faction), [captor, opponent, owner]);
+ assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ assert.ok(assassinateTargetPlayer(game, owner).traitorChoices.includes(target),
+ 'The identity-order original third-seat Traitor offer really contains Burseg');
+ const originalInventory = assassinateTargetInventory(game), originalTraitors = assassinateTargetTraitors(game);
+ const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 4);
+ assert.equal(ordinary.length, 4);
+ const dealOrder = ordinary.map((card, position) => {
+ const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: card.id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), ordinary.map(card => card.id));
+ assert.ok(assassinateTargetPlayer(game, owner).traitors.includes(target));
+ assert.equal(assassinateTargetPlayer(game, owner).forces['imperial_basin:11'], 6);
+ assert.equal(assassinateTargetPlayer(game, captor).forces['carthag:11'], 10);
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === opponent && assassinateTargetClean(state));
+ const shipment = transition(() => {
+ act(opponent, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 });
+ reach(assassinateTargetClean);
+ });
+ assert.equal(assassinateTargetPlayer(shipment.before, opponent).spice - assassinateTargetPlayer(game, opponent).spice, 2);
+ const movement = transition(() => {
+ act(opponent, { type: 'move', from: 'carthag:11', territory: 'imperial_basin', sector: 11, amount: 1 });
+ reach(assassinateTargetClean);
+ });
+ assert.equal(assassinateTargetPlayer(game, opponent).forces['carthag:11'], 1);
+ assert.equal(assassinateTargetPlayer(game, opponent).forces['imperial_basin:11'], 1);
+ reach(state => state.phase === 6 && state.active === captor && assassinateTargetClean(state));
+ const beforeFirstBattle = structuredClone(game);
+ const firstBattleStep: AssassinateTargetStep = { actor: captor,
+ action: { type: 'chooseBattle', territory: 'carthag', target: opponent } };
+ act(firstBattleStep.actor, firstBattleStep.action);
+ reach(state => !!state.battle && assassinateTargetClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
+ const firstPlans: AssassinateTargetStep[] = [
+ { actor: captor, action: { type: 'battlePlan', leader: 'harkonnen-0', dial: 0, support: 0, weapon: null, defense: null } },
+ { actor: opponent, action: { type: 'battlePlan', leader: 'emperor-4', dial: 0, support: 0, weapon: null, defense: null } },
+ ];
+ for (const step of firstPlans) act(step.actor, step.action);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'captureOffer');
+ const captureOffer = structuredClone(game), capture = declareCapture(game, target, actions);
+ game = capture.game;
+ const beforeCaptureChoice = structuredClone(game);
+ const captureChoiceStep: AssassinateTargetStep = { actor: captor,
+ action: { type: 'decision', mode: options.execute ? 'execute' : 'keep' } };
+ const captureChoice = transition(() => {
+ act(captureChoiceStep.actor, captureChoiceStep.action);
+ reach(state => !state.battle && assassinateTargetClean(state));
+ });
+ const afterCapture = structuredClone(game), victim = assassinateTargetPlayer(game, opponent).leaders.find(leader => leader.id === target)!;
+ assert.ok(victim.concealed);
+ assert.equal(victim.dead, !!options.execute);
+ assert.equal(victim.capturedBy, options.execute ? undefined : captor);
+ assert.equal(victim.deaths, options.execute ? 2 : 0);
+ const beforeSecondBattle = structuredClone(game), choice = game.players.flatMap(player =>
+ viewGame(game, player.id).battleChoices).find(choice => choice.territory === 'imperial_basin');
+ assert.ok(choice);
+ const secondBattleStep: AssassinateTargetStep = { actor: choice.chooser,
+ action: { type: 'chooseBattle', territory: 'imperial_basin',
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
+ act(secondBattleStep.actor, secondBattleStep.action);
+ reach(state => !!state.battle && assassinateTargetClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
+ const secondPlans: AssassinateTargetStep[] = [
+ { actor: opponent, action: { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0, weapon: null, defense: null } },
+ { actor: owner, action: { type: 'battlePlan', leader: 'moritani-4', dial: 0, support: 0, weapon: null, defense: null } },
+ ];
+ for (const step of secondPlans) act(step.actor, step.action);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniAssassinate');
+ const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
+ assert.equal(receipt.stage, 'choice'); assert.equal(receipt.faction, 'emperor');
+ assert.equal(receipt.opponent, opponent); assert.equal(receipt.opposingLeader, 'emperor-0');
+ assert.ok(assassinateTargetPlayer(game, owner).traitors.includes(target));
+ assert.deepEqual(assassinateTargetInventory(game), originalInventory);
+ assert.deepEqual(assassinateTargetTraitors(game), originalTraitors);
+ const reveal: Action = { type: 'decision', event: receipt.event, card: target };
+ const decline: Action = { type: 'decision', event: receipt.event, decline: true };
+ return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, shipment, movement,
+ beforeFirstBattle, firstBattleStep, firstPlans, captureOffer, captureDeclared: capture.declared,
+ capture: capture.transition, capturePool: capture.pool, captureRolls: capture.rolls,
+ beforeCaptureChoice, captureChoiceStep, captureChoice, afterCapture,
+ beforeSecondBattle, secondBattleStep, secondPlans,
+ owner, moritani: owner, opponent, emperor: opponent, captor, harkonnen: captor, target,
+ winningDisc: 'emperor-0', territory: 'imperial_basin', sector: 11, location: 'imperial_basin:11',
+ event: receipt.event, receipt: structuredClone(receipt), execute: !!options.execute,
+ potentialBounty: victim.strength, action: reveal,
+ reveal, revealStep: { actor: owner, action: reveal } satisfies AssassinateTargetStep,
+ decline, actions, sourceActionCount: actions.length, originalInventory, originalTraitors,
+ originalSpice, dealOrder, spiceOrder,
+ staging: [
+ 'Actual public native Advanced Harkonnen/Emperor/Moritani ready/start and private setup. Original scalar shuffle order naturally offers emperor-3 in Moritani’s third-seat Traitor packet; Moritani actually chooses it.',
+ 'Select four original unopened ordinary Treachery positions and two original unopened ordinary Spice positions. Native Moritani setup really places six counters in empty Imperial Basin11. No optional module is selected.',
+ 'Emperor actually pays two to ship two reserves to Carthag11, then really moves one to Imperial Basin11. Native first Storm, dual Spice, Charity, Bidding, Revival and movement turns execute normally.',
+ 'Harkonnen actually wins Carthag with Feyd6 against Bashar2, without weapons or leader deaths. Scope only the ensuing native capture permutation to choose unused Burseg3; no candidate body, captive flag, death or concealment is assigned. The original capturedLeader action really keeps or executes that disc.',
+ 'A different surviving native Emperor disc, Fenring6, really wins Imperial Basin against Moritani’s Vando1. The original printed-Emperor assassination opportunity opens while Moritani still physically holds Burseg’s original Traitor Card.',
+ 'Stop before the original reveal/decline. Both live-captive and concealed-executed variants use native actions and retain the same printed target identity. No phase, wallet, army, alliance, plan, custody state or assassination receipt is installed.',
+ ],
+ };
+}
+export type MoritaniAssassinateTargetFixture = ReturnType;
+
+/** Call after an explicit reveal or decline. Native casualty/card controls and
+ * original Mentat replacement continue once; no custody or replacement is set. */
+export function finishMoritaniAssassinateTarget(source: Game, fixture: MoritaniAssassinateTargetFixture,
+ actions: AssassinateTargetStep[] = []): Game {
+ assert.notEqual(source.decision?.kind, 'moritaniAssassinate', 'Choose the owned reveal or decline before continuing');
+ return advanceAssassinateTarget(source, game => game.phase === 8 && !game.battle &&
+ !game.response && !game.phaseOpening && !game.pendingTreacheryDiscard &&
+ game.moritaniAssassinate!.opportunities.some(receipt => receipt.event === fixture.event &&
+ (receipt.stage === 'replaced' || receipt.stage === 'declined')), actions);
+}
diff --git a/tests/fixture-moritani-foreign-ghola-winner.ts b/tests/fixture-moritani-foreign-ghola-winner.ts
index 71d2adb5..79f658d9 100644
--- a/tests/fixture-moritani-foreign-ghola-winner.ts
+++ b/tests/fixture-moritani-foreign-ghola-winner.ts
@@ -51,7 +51,7 @@ export type ForeignGholaWinnerWindow = {
event: string; turn: number; territory: string; owner: string; opponent: string;
faction: string; opposingLeader: string;
/** Owner-only opportunity; a rival view must stay empty. */
- cards: { card: string; name: string; bounty: number; dead: boolean }[];
+ cards: { card: string; name: string; potentialBounty: number }[];
blocked: string | null;
rivalCards: number;
};
@@ -261,7 +261,7 @@ export function createMoritaniForeignGholaWinnerFixture(): ForeignGholaWinnerFix
assert.equal(window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
'The borrowed disc’s printed faction never supplies the target pool.');
assert.equal(window.rivalCards, 0, 'Rivals see the decision without Moritani’s private options.');
- assert.equal(window.cards.find((choice) => choice.card === setup.target)!.bounty,
+ assert.equal(window.cards.find((choice) => choice.card === setup.target)!.potentialBounty,
leaders('tleilaxu')[0].strength, 'The printed bounty of the held card is quoted.');
return {
@@ -289,6 +289,7 @@ export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
assert.ok(choice, 'Choose one of the real private reveal options.');
const before = mixedE3MoritaniPlayer(g, f.moritani);
const victim = mixedE3MoritaniPlayer(g, f.tleilaxu).leaders.find((l) => l.id === card)!;
+ assert.equal(victim.dead, false, 'This original witness selects a living native target.');
const rivalSpice = mixedE3MoritaniPlayer(g, f.tleilaxu).spice;
const reserveBefore = [...(g.traitorReserve ?? [])];
g = stepMixedE3Moritani(g, { actor: f.moritani,
@@ -297,7 +298,7 @@ export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
const killed = mixedE3MoritaniPlayer(after, f.tleilaxu).leaders.find((l) => l.id === card)!;
assert.equal(killed.dead, true, 'The revealed native disc really died.');
assert.equal(killed.deaths, victim.deaths + 1);
- assert.equal(mixedE3MoritaniPlayer(after, f.moritani).spice, before.spice + choice.bounty,
+ assert.equal(mixedE3MoritaniPlayer(after, f.moritani).spice, before.spice + victim.strength,
'The printed bounty is paid once from the bank.');
assert.equal(mixedE3MoritaniPlayer(after, f.tleilaxu).spice, rivalSpice, 'The revealing seat pays nothing.');
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
@@ -321,7 +322,7 @@ export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
const heldBefore = mixedE3MoritaniPlayer(f.window.game, f.moritani).forces[f.location] ?? 0;
assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).forces[f.location] ?? 0, heldBefore,
'Moritani’s real counters at its starting stronghold are unchanged by the loss and its aftermath.');
- return { window: f.window.game, after, mentat: settled, replacement, retired: card, bounty: choice.bounty };
+ return { window: f.window.game, after, mentat: settled, replacement, retired: card, bounty: receipt.bounty };
}
diff --git a/tests/moritani-assassinate-controls.test.tsx b/tests/moritani-assassinate-controls.test.tsx
index 3b5ba08e..e282fbcd 100644
--- a/tests/moritani-assassinate-controls.test.tsx
+++ b/tests/moritani-assassinate-controls.test.tsx
@@ -1,16 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
-import { createElement } from 'react';
+import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
-import type { GameView } from '../game/engine';
+import type { Action, GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
-const { MoritaniAssassinate, MoritaniAssassinateHistory } = await import('../components/moritani-assassinate');
+const { MoritaniAssassinate, MoritaniAssassinateChoice, MoritaniAssassinateHistory } = await import('../components/moritani-assassinate');
function view(overrides: Record = {}) {
return {
@@ -34,8 +34,8 @@ function view(overrides: Record = {}) {
pending: {
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
cards: [
- { card: 'traitor-1', name: 'Duke Leto', bounty: 5, dead: false },
- { card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true },
+ { card: 'traitor-1', name: 'Duke Leto', potentialBounty: 5 },
+ { card: 'traitor-2', name: 'Thufir Hawat', potentialBounty: 4 },
], blocked: null,
},
history: [],
@@ -48,22 +48,96 @@ function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(MoritaniAssassinate, { game, act() {}, busy }));
}
-void test('owner sees public traitor choices, bounty, dead-target legality and rules', () => {
+function click(node: ReactNode, selector: string): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{
+ children?: ReactNode;
+ onClick?: () => void;
+ 'data-testid'?: string;
+ }>(child)) continue;
+ if (child.props['data-testid'] === selector && child.props.onClick) {
+ child.props.onClick();
+ return true;
+ }
+ if (click(child.props.children, selector)) return true;
+ }
+ return false;
+}
+
+function choose(game: GameView, selected = 'traitor-1', busy = false, decline = false): Action[] {
+ const actions: Action[] = [];
+ const tree = MoritaniAssassinateChoice({
+ game, selected, busy, onSelect() {}, act(action) { actions.push(action); },
+ });
+ click(tree, decline ? 'moritani-assassinate-decline' : 'moritani-assassinate-reveal');
+ return actions;
+}
+
+void test('owner sees canonical traitor names and printed potential bounties with conditional reward rules', () => {
const html = markup(view());
assert.match(html, /Traitor Card to reveal/);
assert.match(html, /Duke Leto/);
- assert.match(html, /5 spice bounty/);
- assert.match(html, /Thufir Hawat · dead target · 0 spice/);
+ assert.match(html, /5 spice potential bounty/);
+ assert.match(html, /Thufir Hawat · 4 spice potential bounty/);
assert.match(html, /replaced automatically during Mentat Pause/);
assert.match(html, /cannot be canceled with Karama/);
- const dead = markup(view({ moritaniAssassinate: {
+ assert.match(html, /potential bounty is the leader’s printed strength/);
+ assert.match(html, /awarded only if the target is alive/);
+ assert.match(html, /already-dead target remains a legal choice and awards no spice/);
+ assert.match(html, /physical Traitor Card makes the actual outcome public/);
+ const other = markup(view({ moritaniAssassinate: {
owner: 'm', blocked: null, pending: {
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
- cards: [{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true }], blocked: null,
+ cards: [{ card: 'traitor-2', name: 'Thufir Hawat', potentialBounty: 4 }], blocked: null,
}, history: [],
} }));
- assert.match(dead, /0 spice; target already dead/);
- assert.match(dead, /Printed leader strength: 4/);
+ assert.match(other, /Thufir Hawat · 4 spice potential bounty/);
+ assert.match(other, /Printed leader strength: 4/);
+ assert.doesNotMatch(other, /dead target · 0 spice|0 spice; target already dead/);
+});
+
+void test('real reveal and decline callbacks retain the selected physical card and original event', () => {
+ const game = view();
+ for (const card of game.moritaniAssassinate!.pending!.cards) {
+ assert.deepEqual(choose(game, card.card), [{ type: 'decision', event: 'assassinate-1', card: card.card }]);
+ }
+ assert.deepEqual(choose(game, 'traitor-2', false, true), [{ type: 'decision', event: 'assassinate-1', decline: true }]);
+ assert.deepEqual(choose(game, 'stale-selection'), [{ type: 'decision', event: 'assassinate-1', card: 'traitor-1' }]);
+});
+
+void test('pending choice labels remain uniform for living, dead, captured, concealed-executed and foreign-Ghola targets', () => {
+ const states = [
+ { dead: false },
+ { dead: true },
+ { dead: false, capturedBy: 'h' },
+ { dead: false, capturedBy: 'h', concealedExecuted: true },
+ { dead: false, gholaBy: 'o' },
+ ];
+ for (const state of states) {
+ const game = view();
+ const leader = game.allLeaders.find((candidate) => candidate.id === 'traitor-1')!;
+ Object.assign(leader, state);
+ // Only the uniform pending projection is a choice source. Even an available
+ // public leader record must not supply a private custody-dependent label.
+ for (const field of ['dead', 'capturedBy', 'concealedExecuted', 'gholaBy']) {
+ Object.defineProperty(leader, field, {
+ get() { throw new Error(`Pending choice read target ${field}`); },
+ });
+ }
+ for (const card of game.moritaniAssassinate!.pending!.cards) {
+ for (const field of ['dead', 'bounty', 'capturedBy', 'gholaBy']) {
+ Object.defineProperty(card, field, {
+ get() { throw new Error(`Pending choice read internal ${field}`); },
+ });
+ }
+ }
+ const html = markup(game);
+ assert.match(html, /Duke Leto · 5 spice potential bounty<\/option>/);
+ assert.match(html, /Duke Leto<\/strong> · 5 spice potential bounty/);
+ assert.match(html, /Printed leader strength: 5/);
+ assert.doesNotMatch(html, /dead target · 0 spice|0 spice; target already dead|captured|concealed-executed|foreign-Ghola/);
+ assert.deepEqual(choose(game), [{ type: 'decision', event: 'assassinate-1', card: 'traitor-1' }]);
+ }
});
void test('nonowner, stale, blocked, response and Truthtrance contexts stay guarded', () => {
@@ -78,6 +152,25 @@ void test('nonowner, stale, blocked, response and Truthtrance contexts stay guar
assert.match(blocked, />Continue);
assert.match(markup(view({ response: { kind: 'test' } })), /disabled=""/);
assert.match(markup(view({ truthtrance: { stage: 'answer' } })), /disabled=""/);
+ assert.deepEqual(choose(view(), 'traitor-1', true), []);
+ assert.deepEqual(choose(view(), 'traitor-1', true, true), []);
+ for (const game of [
+ view({ me: 'o' }),
+ view({ decision: { kind: 'moritaniAssassinate', player: 'm', event: 'old' } }),
+ view({ response: { kind: 'test' } }),
+ view({ truthtrance: { stage: 'answer' } }),
+ ]) {
+ assert.deepEqual(choose(game), []);
+ assert.deepEqual(choose(game, 'traitor-1', false, true), []);
+ }
+ const noCard = view();
+ noCard.moritaniAssassinate!.pending!.cards = [];
+ assert.deepEqual(choose(noCard), []);
+ assert.deepEqual(choose(noCard, '', false, true), [{ type: 'decision', event: 'assassinate-1', decline: true }]);
+ const unavailable = view();
+ unavailable.moritaniAssassinate!.pending!.blocked = 'This opportunity has ended.';
+ assert.deepEqual(choose(unavailable), []);
+ assert.deepEqual(choose(unavailable, '', false, true), [{ type: 'decision', event: 'assassinate-1', decline: true }]);
});
void test('public history shows face-up traitor markers and read-only inspection for every seat', () => {
diff --git a/tests/moritani-assassinate-engine.test.ts b/tests/moritani-assassinate-engine.test.ts
index 26bccfa4..3fbdbe8c 100644
--- a/tests/moritani-assassinate-engine.test.ts
+++ b/tests/moritani-assassinate-engine.test.ts
@@ -32,7 +32,9 @@ void test('a real lost battle reveals one different opposing traitor, kills its
void test('an already dead target remains a legal assassination reveal with no bounty or repeated death',()=>{
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-1',true));
const before=pending.players[0].spice,dead=structuredClone(pending.players[1].leaders[1]);
- assert.equal(viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0].bounty,0);
+ const choice=viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0];
+ assert.equal(choice.potentialBounty,dead.strength);
+ assert.equal('dead' in choice,false);assert.equal('bounty' in choice,false);
const done=assassinationChoice(pending,'guild-1');
assert.equal(done.players[0].spice,before);assert.deepEqual(done.players[1].leaders[1],dead);
const next=assassinationToMentat(done);assert.equal(next.moritaniAssassinate!.opportunities[0].stage,'replaced');
diff --git a/tests/moritani-assassinate-quote.test.ts b/tests/moritani-assassinate-quote.test.ts
index 4ea1cc0d..966d4d17 100644
--- a/tests/moritani-assassinate-quote.test.ts
+++ b/tests/moritani-assassinate-quote.test.ts
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { leaders } from '../game/cards';
+import { createAuditorLeader, leaders } from '../game/cards';
import {
createMoritaniAssassinateOpportunity,
moritaniAssassinateChoices,
+ projectedMoritaniAssassinateChoices,
moritaniAssassinateSignature,
moritaniAssassinateTrigger,
quoteMoritaniAssassinate,
@@ -201,7 +202,7 @@ void test('the sourced normal-reveal forfeiture and already normally revealed ca
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
});
-void test('missing, duplicated, altered or exceptional target custody cannot select another physical leader or invent bounty', () => {
+void test('missing, duplicated, altered or invalid target custody cannot select another physical leader or invent bounty', () => {
for (const damage of [
(c: MoritaniAssassinateContext) => {
c.leaders = c.leaders.filter((l) => l.id !== 'atreides-1');
@@ -248,6 +249,67 @@ void test('missing, duplicated, altered or exceptional target custody cannot sel
}
});
+void test('native capture and foreign-ghola targets retain one printed choice without disclosing life or custody', () => {
+ const base = fixture();
+ base.custodians = [{ id: 'h', faction: 'harkonnen' }, { id: 't', faction: 'tleilaxu' }];
+ const expected = projectedMoritaniAssassinateChoices(base);
+ const original = base.leaders[1];
+ for (const patch of [
+ {},
+ { dead: true, deaths: 1 },
+ { capturedBy: 'h', concealed: { captor: 'h', controller: 'a', dead: false, deaths: 0 } },
+ { dead: true, deaths: 2, concealed: { captor: 'h', controller: 'a', dead: false, deaths: 0 } },
+ { gholaBy: 't', deaths: 1 },
+ { gholaBy: 't', dead: true, deaths: 2 },
+ { gholaBy: 't', capturedBy: 'h', deaths: 1,
+ concealed: { captor: 'h', controller: 't', dead: false, deaths: 1 } },
+ ]) {
+ const context = structuredClone(base);
+ Object.assign(context.leaders[1], patch);
+ const before = structuredClone(context);
+ const projected = projectedMoritaniAssassinateChoices(context);
+ assert.deepEqual(projected, expected);
+ assert.deepEqual(Object.keys(projected.cards[0]).sort(), ['card', 'name', 'potentialBounty']);
+ const quote = quoteMoritaniAssassinate(context, original.id);
+ assert.equal(quote.kill, !context.leaders[1].dead);
+ assert.equal(quote.bounty, context.leaders[1].dead ? 0 : original.strength);
+ assert.deepEqual(context, before);
+ }
+});
+
+void test('native producer identities and the existing native controller are still required for exceptional targets', () => {
+ const base = fixture();
+ base.custodians = [{ id: 'h', faction: 'harkonnen' }, { id: 't', faction: 'tleilaxu' }];
+ for (const patch of [
+ { capturedBy: 't' }, { gholaBy: 'h' },
+ { concealed: { captor: 't', controller: 'a', dead: false, deaths: 0 } },
+ { capturedBy: 'h', controller: 'h' }, { gholaBy: 't', controller: 't' },
+ ]) {
+ const context = structuredClone(base);
+ Object.assign(context.leaders[1], patch);
+ assert.ok(!moritaniAssassinateChoices(context).cards.some(choice => choice.card === 'atreides-1'));
+ assert.throws(() => quoteMoritaniAssassinate(context, 'atreides-1'));
+ }
+});
+
+void test('Auditor remains a native printed two-spice target with its explicit capture and foreign-ghola exclusions', () => {
+ const context = fixture('choam');
+ const auditor = createAuditorLeader();
+ context.leaders = [...context.leaders, auditor];
+ context.held = [auditor.id];
+ context.custodians = [{ id: 'h', faction: 'harkonnen' }, { id: 't', faction: 'tleilaxu' }];
+ assert.equal(quoteMoritaniAssassinate(context, auditor.id).bounty, 2);
+ for (const patch of [
+ { capturedBy: 'h' }, { gholaBy: 't' },
+ { concealed: { captor: 'h', controller: 'a', dead: false, deaths: 0 } },
+ ]) {
+ const changed = structuredClone(context);
+ Object.assign(changed.leaders.at(-1)!, patch);
+ assert.deepEqual(projectedMoritaniAssassinateChoices(changed).cards, []);
+ assert.throws(() => quoteMoritaniAssassinate(changed, auditor.id));
+ }
+});
+
void test('a revealed card remains held until one Mentat replacement, and the used opposing faction cannot be assassinated again', () => {
const context = fixture(),
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
diff --git a/tests/moritani-assassinate-target.test.ts b/tests/moritani-assassinate-target.test.ts
new file mode 100644
index 00000000..a4bed7f5
--- /dev/null
+++ b/tests/moritani-assassinate-target.test.ts
@@ -0,0 +1,152 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { DUKE_VIDAL_ID } from '../game/duke-vidal';
+import {
+ createMoritaniAssassinateTargetFixture as fixture,
+ actAssassinateTarget as act,
+ assassinateTargetPlayer as player,
+ assassinateTargetInventory as inventory,
+ assassinateTargetTraitors as traitors,
+ finishMoritaniAssassinateTarget as finish,
+} from './fixture-moritani-assassinate-target';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+void test('a genuinely captured native target is publicly assassinated once for its printed bounty and replaced at Mentat', () => {
+ const f = fixture();
+ const target = player(f.game, f.opponent).leaders.find(l => l.id === f.target)!;
+ assert.equal(target.capturedBy, f.captor);
+ assert.equal(target.dead, false);
+ assert.ok(target.concealed);
+ assert.notEqual(f.game.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader, f.target);
+ const choice = viewGame(f.game, f.owner).moritaniAssassinate!.pending!;
+ assert.equal(choice.blocked, null);
+ assert.deepEqual(choice.cards.find(card => card.card === f.target), {
+ card: f.target, name: target.name, potentialBounty: target.strength,
+ });
+ const money = player(f.game, f.owner).spice;
+ const captorMoney = player(f.game, f.captor).spice;
+ const done = act(f.game, f.owner, f.action);
+ const dead = player(done, f.opponent).leaders.find(l => l.id === f.target)!;
+ assert.equal(dead.dead, true);
+ assert.equal(dead.deaths, target.deaths + 1);
+ assert.equal(dead.capturedBy, undefined);
+ assert.equal(dead.concealed, undefined);
+ assert.equal(dead.usedAt, undefined);
+ assert.equal(player(done, f.owner).spice, money + f.potentialBounty);
+ assert.equal(done.phase, 7);
+ assert.equal(player(done, f.captor).spice, captorMoney + 2);
+ assert.ok(done.log.some(row => row.text === 'Harkonnen received 2 spice from strongholds.'));
+ assert.equal(done.moritaniAssassinate!.opportunities.at(-1)!.bounty, f.potentialBounty);
+ assert.equal(player(done, f.opponent).leaders.find(l => l.id === f.winningDisc)!.dead, false);
+ rejected(done, f.owner, f.action);
+ const mentat = finish(done, f);
+ const receipt = mentat.moritaniAssassinate!.opportunities.find(r => r.event === f.event)!;
+ assert.equal(receipt.stage, 'replaced');
+ assert.ok(receipt.replacement);
+ assert.notEqual(receipt.replacement, f.target);
+ assert.equal(player(mentat, f.owner).traitors.includes(f.target), false);
+ assert.deepEqual(inventory(mentat), f.originalInventory);
+ assert.deepEqual(traitors(mentat), f.originalTraitors);
+});
+
+void test('a genuinely concealed execution has the same pending choices and resolves publicly with zero bounty and no repeated death', () => {
+ const live = fixture();
+ const f = fixture({ execute: true });
+ const target = player(f.game, f.opponent).leaders.find(l => l.id === f.target)!;
+ assert.equal(target.dead, true);
+ assert.equal(target.deaths, 2);
+ assert.ok(target.concealed);
+ const visible = viewGame(f.game, f.owner).moritaniAssassinate!.pending!;
+ const liveVisible = viewGame(live.game, live.owner).moritaniAssassinate!.pending!;
+ assert.deepEqual(visible.cards, liveVisible.cards);
+ assert.equal(visible.blocked, liveVisible.blocked);
+ for (const card of visible.cards)
+ assert.deepEqual(Object.keys(card).sort(), ['card', 'name', 'potentialBounty']);
+ const money = player(f.game, f.owner).spice;
+ const done = act(f.game, f.owner, f.action);
+ const dead = player(done, f.opponent).leaders.find(l => l.id === f.target)!;
+ assert.equal(dead.dead, true);
+ assert.equal(dead.deaths, target.deaths);
+ assert.equal(dead.concealed, undefined);
+ assert.equal(dead.capturedBy, undefined);
+ assert.equal(player(done, f.owner).spice, money);
+ assert.equal(done.moritaniAssassinate!.opportunities.at(-1)!.bounty, 0);
+ assert.ok(done.log.some(row => row.text.includes('already in the Tanks; no new death or spice')));
+ for (const actor of [f.owner, f.opponent, f.captor]) {
+ const actual = viewGame(done, actor).players.find(p => p.id === f.opponent)!.leaders.find(l => l.id === f.target)!;
+ assert.equal(actual.dead, true);
+ assert.equal(viewGame(done, actor).moritaniAssassinate!.history.at(-1)!.bounty, 0);
+ }
+ const mentat = finish(done, f);
+ assert.equal(mentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
+ assert.deepEqual(traitors(mentat), f.originalTraitors);
+});
+
+for (const execute of [false, true]) {
+ void test(`declining the ${execute ? 'concealed-executed' : 'living captive'} target preserves its original custody and physical Traitor`, () => {
+ const f = fixture({ execute });
+ const target = structuredClone(player(f.game, f.opponent).leaders.find(l => l.id === f.target)!);
+ const money = player(f.game, f.owner).spice;
+ const done = act(f.game, f.owner, f.decline);
+ assert.deepEqual(player(done, f.opponent).leaders.find(l => l.id === f.target), target);
+ assert.equal(player(done, f.owner).spice, money);
+ const mentat = finish(done, f);
+ assert.equal(mentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
+ assert.ok(player(mentat, f.owner).traitors.includes(f.target));
+ assert.deepEqual(player(mentat, f.opponent).leaders.find(l => l.id === f.target), target);
+ assert.deepEqual(traitors(mentat), f.originalTraitors);
+ });
+}
+
+void test('the target remains event-bound and owner-only with the original winning-disc restriction', () => {
+ const f = fixture();
+ rejected(f.game, f.captor, f.action);
+ rejected(f.game, f.owner, { ...f.action, event: 'other-battle' });
+ rejected(f.game, f.owner, { ...f.action, card: f.winningDisc });
+ rejected(f.game, f.owner, { ...f.action, card: DUKE_VIDAL_ID });
+ for (const actor of [f.opponent, f.captor])
+ assert.deepEqual(viewGame(f.game, actor).moritaniAssassinate!.pending!.cards, []);
+});
+
+for (const profile of DIFFICULTIES) {
+ void test(`${profile} legally uses the uniform potential-bounty choice for both genuine capture outcomes`, () => {
+ for (const execute of [false, true]) {
+ const f = fixture({ execute });
+ const own = viewGame(f.game, f.owner);
+ own.players.find(p => p.id === f.owner)!.bot = profile;
+ const actions = botActions(own);
+ assert.deepEqual(actions, [f.action]);
+ const done = act(f.game, f.owner, actions[0]);
+ assert.equal(done.moritaniAssassinate!.opportunities.at(-1)!.bounty, execute ? 0 : f.potentialBounty);
+ assert.equal(finish(done, f).moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
+ }
+ });
+}
+
+void test('an actual paid foreign-ghola target keeps native identity and Tleilaxu return custody after the controlled later battle', async () => {
+ const { createMoritaniAssassinateGholaTargetFixture } = await import('./fixture-moritani-assassinate-ghola-target');
+ const f = createMoritaniAssassinateGholaTargetFixture();
+ const target = player(f.game, f.opponent).leaders.find(l => l.id === f.target)!;
+ assert.equal(target.dead, false);
+ assert.ok(target.gholaBy);
+ const controller = target.gholaBy;
+ const money = player(f.game, f.owner).spice;
+ const controllerMoney = player(f.game, controller).spice;
+ const done = applyAction(f.game, f.owner, f.action);
+ const killed = player(done, f.opponent).leaders.find(l => l.id === f.target)!;
+ assert.equal(killed.faction, target.faction);
+ assert.equal(killed.dead, true);
+ assert.equal(killed.deaths, target.deaths + 1);
+ assert.equal(killed.gholaBy, controller);
+ assert.equal(killed.concealed, undefined);
+ assert.equal(player(done, f.owner).spice, money + f.potentialBounty);
+ assert.equal(player(done, controller).spice, controllerMoney);
+});
diff --git a/tests/moritani-auditor-winner.test.ts b/tests/moritani-auditor-winner.test.ts
index cc5a6669..d5827b71 100644
--- a/tests/moritani-auditor-winner.test.ts
+++ b/tests/moritani-auditor-winner.test.ts
@@ -35,7 +35,7 @@ void test('an original surviving Auditor2 win opens native CHOAM assassination,
assert.ok(ownChoice, 'The actual owner receives the real private opportunity.');
assert.equal(ownChoice.blocked, null);
assert.deepEqual(ownChoice.cards.map(c => c.card), [f.target]);
- assert.equal(ownChoice.cards[0].bounty, printedTarget.strength);
+ assert.equal(ownChoice.cards[0].potentialBounty, printedTarget.strength);
assert.deepEqual(viewGame(pending, f.native).moritaniAssassinate!.pending!.cards, []);
const targetBefore = mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === f.target)!;
@@ -79,7 +79,7 @@ void test('a different surviving CHOAM disc permits the actual Auditor Traitor t
const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true });
const pending = pendingMoritaniAuditorAssassination(f);
const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!;
- assert.deepEqual(own.cards.map(c => ({ card: c.card, bounty: c.bounty })), [
+ assert.deepEqual(own.cards.map(c => ({ card: c.card, bounty: c.potentialBounty })), [
{ card: CHOAM_AUDITOR_ID, bounty: 2 },
]);
const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice;
diff --git a/tests/moritani-ecaz-duke-winner.test.ts b/tests/moritani-ecaz-duke-winner.test.ts
index c1848cf1..340f2e91 100644
--- a/tests/moritani-ecaz-duke-winner.test.ts
+++ b/tests/moritani-ecaz-duke-winner.test.ts
@@ -69,7 +69,7 @@ void test('original Ecaz Ambassador Duke6 win opens the formerly missing native
const offer = viewGame(reload(pending), moritani).moritaniAssassinate!.pending!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.cards.map(c => c.card), [target]);
- assert.equal(offer.cards[0].bounty, native.strength);
+ assert.equal(offer.cards[0].potentialBounty, native.strength);
assert.equal(viewGame(pending, ecaz).moritaniAssassinate!.pending!.cards.length, 0);
const d = pending.decision;
assert.ok(d?.kind === 'moritaniAssassinate');
diff --git a/tests/moritani-foreign-ghola-winner.test.ts b/tests/moritani-foreign-ghola-winner.test.ts
index a0bffdcc..fb0da6c1 100644
--- a/tests/moritani-foreign-ghola-winner.test.ts
+++ b/tests/moritani-foreign-ghola-winner.test.ts
@@ -45,7 +45,7 @@ void test('a real Tleilaxu foreign ghola victory opens Moritani’s private loss
'Only the opposing player’s native Tleilaxu identities are targets.');
assert.equal(f.window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
'The borrowed foreign disc never becomes a target identity.');
- assert.equal(f.window.cards.find((choice) => choice.card === f.target)!.bounty, f.bounty);
+ assert.equal(f.window.cards.find((choice) => choice.card === f.target)!.potentialBounty, f.bounty);
// Original continuation: printed bounty, one physical death, Mentat replacement.
const outcome = revealMoritaniForeignGholaWitness(f);
diff --git a/tests/moritani-hero-replacement.test.ts b/tests/moritani-hero-replacement.test.ts
index 892c7546..366106ee 100644
--- a/tests/moritani-hero-replacement.test.ts
+++ b/tests/moritani-hero-replacement.test.ts
@@ -38,7 +38,7 @@ void test('original native Ixians loss pays the held native target bounty and Me
assert.equal(pendingReceipt.faction, 'ixians');
assert.equal(pendingReceipt.opposingLeader, f.battle.enemyLeader);
const ownerChoice = viewGame(f.pending, f.moritani).moritaniAssassinate!.pending!;
- assert.deepEqual(ownerChoice.cards.map(card => ({ card: card.card, bounty: card.bounty })),
+ assert.deepEqual(ownerChoice.cards.map(card => ({ card: card.card, bounty: card.potentialBounty })),
[{ card: f.target, bounty: target.strength }]);
assert.deepEqual(viewGame(f.pending, f.native).moritaniAssassinate!.pending!.cards, []);
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.moritani).spice,
diff --git a/tests/moritani-independent-skills-runtime.test.ts b/tests/moritani-independent-skills-runtime.test.ts
index ea072923..ca09072d 100644
--- a/tests/moritani-independent-skills-runtime.test.ts
+++ b/tests/moritani-independent-skills-runtime.test.ts
@@ -94,7 +94,7 @@ void test('Advanced standalone Moritani keeps the printed Assassinate power thro
assert.equal(choice.blocked, null);
assert.deepEqual(choice.cards, [{ card: f.traitorTarget,
name: owned(f, f.pending, f.traitorTarget).name,
- bounty: owned(f, f.pending, f.traitorTarget).strength, dead: false }]);
+ potentialBounty: owned(f, f.pending, f.traitorTarget).strength }]);
assert.equal(choice.cards.some((card) => card.card === f.winnerLeader), false);
assert.deepEqual(choiceOf(f.pending, f.opponent).cards, []);
assert.deepEqual(choiceOf(f.pending, f.third).cards, []);
@@ -171,7 +171,7 @@ void test('Advanced standalone Moritani keeps the printed power without Leader S
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const choice = choiceOf(f.pending, f.moritani);
assert.deepEqual(choice.cards.map((card) => card.card), [f.traitorTarget]);
- assert.equal(choice.cards[0].bounty, owned(f, f.pending, f.traitorTarget).strength);
+ assert.equal(choice.cards[0].potentialBounty, owned(f, f.pending, f.traitorTarget).strength);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
diff --git a/tests/moritani-nexus-skills-modules-runtime.test.ts b/tests/moritani-nexus-skills-modules-runtime.test.ts
index 221bd76d..5d49febd 100644
--- a/tests/moritani-nexus-skills-modules-runtime.test.ts
+++ b/tests/moritani-nexus-skills-modules-runtime.test.ts
@@ -178,7 +178,7 @@ void test('normal held Suk physical rescue precedes unused-disc assassination, c
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
const d = pending.decision;
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected loss choice');
- assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!.bounty, 3);
+ assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!.potentialBounty, 3);
const action = { type: 'decision', event: d.event, card: target.id };
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: 'stale' });
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
diff --git a/tests/moritani-skills-modules-runtime.test.ts b/tests/moritani-skills-modules-runtime.test.ts
index eb72ce40..bcd1e474 100644
--- a/tests/moritani-skills-modules-runtime.test.ts
+++ b/tests/moritani-skills-modules-runtime.test.ts
@@ -160,7 +160,7 @@ for (const hasKarama of [false, true]) void test(`the normal Guild Suk rescue an
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss choice');
const action: Action = { type: 'decision', event: d.event, card: f.target };
const privateMenu = viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards;
- assert.equal(privateMenu.find(c => c.card === f.target)!.bounty, 3);
+ assert.equal(privateMenu.find(c => c.card === f.target)!.potentialBounty, 3);
assert.equal(privateMenu.some(c => c.card === f.opponentLeader), false);
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: `${d.event}:stale` });
reject(pending, f.moritani, { ...action, card: f.opponentLeader });