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

@@ -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' });
@@ -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);
@@ -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 };
}
@@ -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<T>(run: () => T, selected?: readonly number[]): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(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<typeof createMoritaniAssassinateTargetFixture>;
/** 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);
}
@@ -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 };
}
+105 -12
View File
@@ -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<string, unknown> = {}) {
return {
@@ -34,8 +34,8 @@ function view(overrides: Record<string, unknown> = {}) {
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, /<strong>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', () => {
+3 -1
View File
@@ -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');
+64 -2
View File
@@ -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;
+152
View File
@@ -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);
});
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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,
@@ -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);
@@ -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 });
@@ -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 });