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

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

446 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createAuditorLeader, leaders } from '../game/cards';
import {
createMoritaniAssassinateOpportunity,
moritaniAssassinateChoices,
projectedMoritaniAssassinateChoices,
moritaniAssassinateSignature,
moritaniAssassinateTrigger,
quoteMoritaniAssassinate,
validateMoritaniAssassinate,
type MoritaniAssassinateContext,
type MoritaniAssassinateReceipt,
type MoritaniAssassinateTrigger,
} from '../game/moritani-assassinate';
function fixture(
faction: MoritaniAssassinateTrigger['faction'] = 'atreides',
event = 'battle:2:arrakeen',
): MoritaniAssassinateContext {
const input: MoritaniAssassinateTrigger = {
advanced: true,
owner: 'm',
ownerFaction: 'moritani',
opponent: 'a',
faction,
event,
turn: 2,
territory: 'arrakeen',
opposingLeader: `${faction}-0`,
opposingLeaderSurvived: true,
normalBattle: true,
lostBattle: true,
traitorCalled: false,
};
const receipt = createMoritaniAssassinateOpportunity(input);
return {
state: {
version: 1,
owner: 'm',
normalTraitorCall: false,
opportunities: [receipt],
},
receipt,
held: [
`${faction}-0`,
`${faction}-1`,
`${faction}-2`,
'moritani-0',
'cheap-hero-traitor',
],
normallyRevealed: [],
leaders: leaders(faction),
};
}
function revised(
receipt: MoritaniAssassinateReceipt,
patch: Partial<MoritaniAssassinateReceipt>,
) {
const r = { ...receipt, ...patch };
r.signature = moritaniAssassinateSignature(r);
return r;
}
void test('public trigger requires a native Advanced Moritani normal loss, a surviving native opposing disc and no actual traitor call', () => {
const input: MoritaniAssassinateTrigger = {
advanced: true,
owner: 'm',
ownerFaction: 'moritani',
opponent: 'a',
faction: 'atreides',
event: 'battle',
turn: 2,
territory: 'arrakeen',
opposingLeader: 'atreides-0',
opposingLeaderSurvived: true,
normalBattle: true,
lostBattle: true,
traitorCalled: false,
};
assert.equal(moritaniAssassinateTrigger(input), true);
for (const patch of [
{ advanced: false },
{ ownerFaction: 'atreides' },
{ opponent: 'm' },
{ lostBattle: false },
{ normalBattle: false },
{ opposingLeaderSurvived: false },
{ traitorCalled: true },
{ opposingLeader: null },
{ opposingLeader: 'cheap-hero-traitor' },
{ turn: 0 },
{ turn: 1.5 },
{ event: '' },
]) {
const invalid = { ...input, ...patch } as MoritaniAssassinateTrigger,
before = structuredClone(invalid);
assert.equal(moritaniAssassinateTrigger(invalid), false);
assert.throws(
() => createMoritaniAssassinateOpportunity(invalid),
/Assassinate/,
);
assert.deepEqual(invalid, before);
}
for (const faction of [
'atreides',
'beneGesserit',
'guild',
'emperor',
'fremen',
] as const)
assert.equal(
moritaniAssassinateTrigger({
...input,
faction,
opposingLeader: `${faction}-0`,
}),
true,
);
});
void test('only a different held native Traitor Card is selectable, and live targets pay exactly their printed strength without mutating inputs', () => {
for (const faction of [
'atreides',
'beneGesserit',
'guild',
'emperor',
'fremen',
] as const) {
const context = fixture(faction),
before = structuredClone(context),
native = context.leaders[1];
const choices = moritaniAssassinateChoices(context);
assert.equal(choices.blocked, null);
assert.deepEqual(choices.unavailable, []);
assert.deepEqual(
choices.cards.map((c) => c.card),
[`${faction}-1`, `${faction}-2`],
);
const quote = quoteMoritaniAssassinate(context, native.id);
assert.equal(quote.leader, native.id);
assert.equal(quote.bounty, native.strength);
assert.equal(quote.kill, true);
assert.equal(quote.receipt.stage, 'revealed');
assert.equal(quote.receipt.card, native.id);
assert.equal(quote.receipt.replacement, null);
validateMoritaniAssassinate(
{ ...context.state, opportunities: [quote.receipt] },
{ turn: 2, held: context.held, removed: [] },
);
assert.deepEqual(context, before);
for (const card of [
`${faction}-0`,
'moritani-0',
'cheap-hero-traitor',
`${faction}-4`,
]) {
assert.throws(() => quoteMoritaniAssassinate(context, card), /eligible/);
assert.deepEqual(context, before);
}
}
});
void test('already-dead targets remain revealable for no death or spice, while prior use locations do not remove a live target', () => {
const context = fixture();
context.leaders[1].dead = true;
context.leaders[1].deaths = 2;
context.leaders[2].usedAt = 'carthag';
const before = structuredClone(context);
assert.deepEqual(moritaniAssassinateChoices(context).cards[0], {
card: 'atreides-1',
name: context.leaders[1].name,
bounty: 0,
dead: true,
});
const dead = quoteMoritaniAssassinate(context, 'atreides-1');
assert.equal(dead.kill, false);
assert.equal(dead.bounty, 0);
assert.equal(dead.receipt.stage, 'revealed');
assert.equal(quoteMoritaniAssassinate(context, 'atreides-2').kill, true);
validateMoritaniAssassinate(
{ ...context.state, opportunities: [dead.receipt] },
{ turn: 2, held: context.held, removed: [] },
);
assert.deepEqual(context, before);
});
void test('the sourced normal-reveal forfeiture and already normally revealed cards stay explicit private guards', () => {
const context = fixture();
context.state.normalTraitorCall = true;
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
assert.throws(() => quoteMoritaniAssassinate(context, 'atreides-1'));
context.state.normalTraitorCall = false;
context.normallyRevealed = ['atreides-1'];
const choices = moritaniAssassinateChoices(context);
assert.deepEqual(
choices.cards.map((c) => c.card),
['atreides-2'],
);
assert.throws(() => quoteMoritaniAssassinate(context, 'atreides-1'));
context.normallyRevealed = ['atreides-1', 'atreides-2'];
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
});
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');
},
(c: MoritaniAssassinateContext) => {
c.leaders = [...c.leaders, c.leaders[1]];
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].strength = 99;
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].name = 'Wrong disc';
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].faction = 'guild';
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].capturedBy = 'other';
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].gholaBy = 'other';
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].controller = null;
},
(c: MoritaniAssassinateContext) => {
c.leaders[1].concealed = { captor: 'other', dead: false, deaths: 0 };
},
]) {
const context = fixture();
damage(context);
const before = structuredClone(context);
const choices = moritaniAssassinateChoices(context);
assert.ok(!choices.cards.some((c) => c.card === 'atreides-1'));
assert.match(
choices.unavailable.find((c) => c.card === 'atreides-1')!.reason,
/custody/,
);
assert.throws(
() => quoteMoritaniAssassinate(context, 'atreides-1'),
/eligible/,
);
assert.deepEqual(context, before);
}
});
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;
const state = { ...context.state, opportunities: [revealed] };
validateMoritaniAssassinate(state, {
turn: 2,
held: context.held,
removed: [],
});
assert.throws(
() =>
validateMoritaniAssassinate(state, {
turn: 2,
held: context.held.filter((c) => c !== 'atreides-1'),
removed: ['atreides-1'],
}),
/stay held/,
);
assert.throws(
() =>
validateMoritaniAssassinate(state, {
turn: 3,
held: context.held,
removed: [],
}),
/stay held/,
);
const replaced = revised(revealed, {
stage: 'replaced',
replacement: 'moritani-4',
});
state.opportunities = [replaced];
validateMoritaniAssassinate(state, {
turn: 3,
held: [...context.held.filter((c) => c !== 'atreides-1'), 'moritani-4'],
removed: ['atreides-1'],
});
const next = fixture('atreides', 'battle:3:carthag');
next.receipt.turn = 3;
next.receipt.signature = moritaniAssassinateSignature(next.receipt);
next.state.opportunities = [replaced, next.receipt];
assert.match(moritaniAssassinateChoices(next).blocked!, /already been used/);
assert.throws(
() => quoteMoritaniAssassinate(next, 'atreides-2'),
/already been used/,
);
});
void test('declining keeps all cards and allows a later loss; distinct opposing factions may each use one card', () => {
const context = fixture(),
declined = revised(context.receipt, { stage: 'declined' });
const retry = fixture('atreides', 'retry');
retry.state.opportunities = [declined, retry.receipt];
assert.equal(moritaniAssassinateChoices(retry).blocked, null);
const first = quoteMoritaniAssassinate(retry, 'atreides-1').receipt;
const other = fixture('guild', 'guild-battle');
other.state.opportunities = [declined, first, other.receipt];
const second = quoteMoritaniAssassinate(other, 'guild-1').receipt;
validateMoritaniAssassinate(
{ ...other.state, opportunities: [declined, first, second] },
{ turn: 2, held: ['atreides-1', 'guild-1'], removed: [] },
);
assert.deepEqual(context.state.opportunities, [context.receipt]);
});
void test('signed JSON state rejects changed events, invalid stage fields, duplicate faction use, invented bounty and reopened physical custody', () => {
const context = fixture(),
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
const state = { ...context.state, opportunities: [revealed] },
before = structuredClone(state);
validateMoritaniAssassinate(JSON.parse(JSON.stringify(state)), {
turn: 2,
held: context.held,
removed: [],
});
for (const patch of [
{ bounty: 999 },
{ card: 'atreides-0' },
{ card: 'guild-1' },
{ replacement: 'guild-0' },
{ stage: 'choice' },
{ stage: 'replaced', replacement: 'unknown' },
{ stage: 'replaced', replacement: 'atreides-1' },
{ owner: 'x' },
{ faction: 'harkonnen' },
{ opposingLeader: 'cheap-hero-traitor' },
] as Partial<MoritaniAssassinateReceipt>[]) {
const damaged = { ...state, opportunities: [revised(revealed, patch)] };
const unchanged = structuredClone(damaged);
assert.throws(() => validateMoritaniAssassinate(damaged), /Assassinate/);
assert.deepEqual(damaged, unchanged);
}
assert.throws(
() =>
validateMoritaniAssassinate({
...state,
opportunities: [revealed, structuredClone(revealed)],
}),
/duplicated/,
);
assert.throws(
() =>
validateMoritaniAssassinate({
...state,
opportunities: [
revealed,
revised(revealed, { event: 'other', card: 'atreides-2', bounty: 4 }),
],
}),
/once per opposing faction/,
);
assert.throws(
() =>
validateMoritaniAssassinate({
...state,
opportunities: [{ ...revealed, event: 'changed' }],
}),
/changed/,
);
assert.throws(
() =>
validateMoritaniAssassinate(state, {
turn: 2,
held: context.held,
removed: ['atreides-1'],
}),
/overlapping/,
);
assert.deepEqual(state, before);
const stale = fixture();
stale.receipt = { ...stale.receipt, event: 'stale' };
assert.throws(() => moritaniAssassinateChoices(stale), /original saved/);
});