Connect native Moritani and Harkonnen captive Duke rules

This commit is contained in:
Quintine committed 2026-10-15 16:20:58 +11:00
1 parent 559b42dd78
commit a5a6db7868
40 files changed
+1408 -352

No files matched your search

+1 -1
View File
@@ -139,7 +139,7 @@ void test('Cunning liberates actual captivity, never saves a captor to restore a
assert.equal(liberated.leader.deaths, 4);
assert.equal(dukeController(expireDuke(liberated, 3)), null);
assert.equal(dukeController(captive), 'h');
for (const source of ['ecaz', 'moritani', 'ally'] as const)
for (const source of ['ecaz', 'ally'] as const)
assert.throws(() => acquireDuke(captive, 'other', 3, source));
const foreign = structuredClone(captive);
foreign.leader.gholaBy = 'tleilaxu';
+2 -8
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import { ownedTech } from '../game/tech-tokens';
@@ -14,7 +14,7 @@ import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceMoritaniNexus as advance, assertMoritaniNexusPhysical as physical,
closeMoritaniNexus as close, createMoritaniNexusClosing as closing,
createMoritaniNexusDiscovery as discovery, createMoritaniNexusLobby as lobby,
createMoritaniNexusDiscovery as discovery,
moritaniNexusAct as act, moritaniNexusClean as clean, moritaniNexusPlayer as player,
moritaniNexusPolicy as policy, moritaniNexusReload as reload,
openMoritaniNexusBattle as openBattle, openMoritaniNexusLoss as loss,
@@ -331,9 +331,3 @@ void test('original Discovery supply, reveal and free nested entry survive the l
physical(done);
});
void test('Advanced Harkonnen original lobbies remain inadmissible without mutating declared source pieces', () => {
const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
let ready = reload(native);
for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } });
const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before);
});
+12 -7
View File
@@ -105,8 +105,7 @@ export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Ga
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']);
assert.equal(g.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1);
assert.ok(g.players.some(p => p.faction === 'moritani'));
assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', 'harkonnen'].includes(p.faction)));
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
assert.ok(g.players.length >= (options.tech ? 3 : 2) && g.players.length <= 6);
assert.ok(!options.strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
@@ -120,19 +119,25 @@ export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Ga
assert.ok(g.nexusCards.cards === null && g.nexusCards.phase === null);
return g;
}
export function createMoritaniNexusSetup(options: MoritaniNexusOptions = {}): MoritaniNexusSetup {
export function createMoritaniNexusSetup(options: MoritaniNexusOptions & {
opponentFaction?: 'guild' | 'harkonnen'; harkonnenKarama?: boolean;
} = {}): MoritaniNexusSetup {
let g = createMoritaniNexusLobby(options);
const initial = moritaniNexusReload(g), actions: MoritaniNexusStep[] = [];
const owner = g.players.find(p => p.faction === 'moritani')!.id;
const opponent = g.players.find(p => p.faction === 'guild')!.id; assert.ok(opponent);
const target = leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id;
const opponentFaction = options.opponentFaction ?? 'guild';
const opponent = g.players.find(p => p.faction === opponentFaction)!.id; assert.ok(opponent);
const target = opponentFaction === 'guild'
? leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id
: leaders('harkonnen')[1].id;
for (const p of g.players) if (!p.ready) g = moritaniNexusAct(g, { actor: p.id, action: { type: 'ready' } }, actions);
if (options.initial) g = initializeNexusGameForAudit(g);
else {
const nx = options.nexus ?? 'moritani', cards = treacheryDeck(['ecaz']), remaining = [...cards];
const front = g.players.flatMap(p => {
const kinds = [p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; });
const kinds = [options.harkonnenKarama && p.faction === 'harkonnen' ? 'karama'
: p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind || c.effect === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; });
});
const traitors = traitorDeck(g.players), desired = [target, ...traitors.filter(c => c !== target)];
const index = nx === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4;
+6 -22
View File
@@ -608,27 +608,14 @@ export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3Morit
* trying the engine's own plan validator. Never assumes a fixed dial or budget. */
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min',
extra: { weapon?: string; defense?: string } = {}): Action {
const steps: MixedE3MoritaniStep[] = [];
let base = g;
for (let n = 0; n < 6; n++) {
const decision = base.decision;
if (decision?.kind === 'moritaniPlacement') {
const decline: MixedE3MoritaniStep = { actor: decision.player, action: { type: 'decision', decline: true } };
steps.push(decline); base = stepMixedE3Moritani(base, decline); continue;
}
if (decision?.kind !== 'leaderSkillVisibility') break;
const posture: MixedE3MoritaniStep = { actor: decision.player,
action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === actor } };
steps.push(posture); base = stepMixedE3Moritani(base, posture);
}
const spice = mixedE3MoritaniPlayer(base, actor).spice;
const spice = mixedE3MoritaniPlayer(g, actor).spice;
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0];
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0];
const reasons = new Set<string>();
for (const support of supports) for (const dial of dials) {
for (const card of [extra, {}]) {
const action: Action = { type: 'battlePlan', leader, dial, support, ...card };
try { applyAction(mixedE3MoritaniReload(base), actor, action); return action; }
try { applyAction(mixedE3MoritaniReload(g), actor, action); return action; }
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
}
}
@@ -643,13 +630,10 @@ export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game,
const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(state), f.native, f.location, 1, 0, f.actions);
const ready = advanceMixedE3Moritani(arrival.settled, s => !s.decision, f.actions);
let g = openMixedE3MoritaniBattle(ready, f.moritani, f.native, f.actions);
// A pending posture declaration is a real private choice; conceal it first so
// both plans are submitted against the same genuine state.
for (let n = 0; n < 4 && g.decision?.kind === 'leaderSkillVisibility'; n++) {
const posture = g.decision;
g = stepMixedE3Moritani(g, { actor: posture.player,
action: { type: 'leaderSkillVisibility', event: posture.event, hide: true } }, f.actions);
}
// Finish original Voice, Mentat/posture and preparation acknowledgements
// before either validator probe; never validate against an undispatched clone.
g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, f.actions);
const arrangements = mixedE3MoritaniReload(g);
const trained = new Set((g.leaderSkills?.assignments ?? []).map(a => a.leader));
const ownLeader = mixedE3MoritaniPlayer(g, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id))
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { splitLocation } from '../game/board';
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
import { moritaniTerrorAssassinationPool } from '../game/moritani-terror-assassination';
import { createMoritaniNexusSetup } from './fixture-e3-nexus-moritani';
import {
advanceNexusDuke, assertNexusDukeCustody, emptyNexusDukePlan, nextNexusDukeStep,
nexusDukeClean, nexusDukePlayer, openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
type NexusDukePlan, type NexusDukeStep,
} from './fixture-nexus-duke-capture';
type Frame = { before: Game; after: Game };
export type MoritaniDukeCaptureFixture = {
game: Game; owner: string; captor: string; guild: string; target: string; home: string;
plan: NexusDukePlan; actions: NexusDukeStep[]; acquisition: Frame; capture: Frame;
terrorToken?: string;
};
function nativeLeader(game: Game, actor: string, territory: string, strong: boolean): string {
const pool = nexusDukePlayer(game, actor).leaders
.filter(l => !l.dead && !l.capturedBy && !l.gholaBy && (!l.usedAt || l.usedAt === territory))
.sort((a, b) => strong ? b.strength - a.strength : a.strength - b.strength);
assert.ok(pool[0]);
return pool[0].id;
}
function winGuildStronghold(game: Game, owner: string, guild: string, plan: NexusDukePlan,
actions: NexusDukeStep[]): Game {
const battle = openNexusDukeBattle(game, plan, actions,
{ territory: 'tueks_sietch', first: owner, second: guild });
const sealed = sealNexusDukeBattle(battle.game, plan, actions, [
{ actor: owner, leader: nativeLeader(battle.game, owner, 'tueks_sietch', true), dial: 0, support: 0 },
{ actor: guild, leader: nativeLeader(battle.game, guild, 'tueks_sietch', false), dial: 0, support: 0 },
]);
return advanceNexusDuke(sealed, g => !g.battle && nexusDukeClean(g), plan, actions);
}
/** Real native setup, two prospective stronghold battles, original Moritani
* acquisition, an actual Harkonnen victory/lottery and original Keep/Execute.
* Returns before final movement completion, or at an actual optional Terror entry. */
export function createMoritaniDukeCaptureFixture(options: {
karama?: boolean; execute?: boolean; terror?: boolean; terrorEntrant?: 'harkonnen' | 'guild';
} = {}): MoritaniDukeCaptureFixture {
const setup = createMoritaniNexusSetup({ roster: ['moritani', 'harkonnen', 'guild'],
opponentFaction: 'harkonnen', harkonnenKarama: options.karama });
const owner = setup.owner, captor = setup.opponent, guild = 'guild';
const home = splitLocation(Object.keys(nexusDukePlayer(setup.afterSetup, owner).forces)[0]).territory;
const plan: NexusDukePlan = { ...emptyNexusDukePlan(), owner, captor, entrant: guild,
others: [captor, guild], capture: 'accept', mode: options.execute ? 'execute' : 'keep' };
const actions = setup.actions;
plan.ship = { [owner]: { territory: 'tueks_sietch', amount: 1 },
[captor]: { territory: home, amount: 1 } };
const acquisitionBefore = advanceNexusDuke(setup.afterSetup,
g => g.phase === 5 && nexusDukeClean(g), plan, actions);
let game = advanceNexusDuke(acquisitionBefore,
g => !!g.dukeVidal && dukeController(g.dukeVidal) === owner, plan, actions);
const acquisition = { before: acquisitionBefore, after: structuredClone(game) };
const battle = openNexusDukeBattle(game, plan, actions,
{ territory: home, first: owner, second: captor });
const sealed = sealNexusDukeBattle(battle.game, plan, actions, [
{ actor: owner, leader: nativeLeader(battle.game, owner, home, false), dial: 0, support: 0 },
{ actor: captor, leader: nativeLeader(battle.game, captor, home, true), dial: 0, support: 0 },
]);
game = advanceNexusDuke(sealed, g => g.decision?.kind === 'capturedLeader', plan, actions);
assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader, DUKE_VIDAL_ID);
const before = structuredClone(game);
game = stepNexusDuke(game, plan, { actor: captor,
action: { type: 'decision', mode: options.execute ? 'execute' : 'keep' } }, actions);
const capture = { before, after: structuredClone(game) };
plan.capture = 'decline';
game = winGuildStronghold(game, owner, guild, plan, actions);
let terrorToken: string | undefined;
const terrorEntrant = options.terrorEntrant === 'guild' ? guild : captor;
const terrorLocation = terrorEntrant === guild ? 'arrakeen' : 'tueks_sietch';
if (options.terror) {
game = advanceNexusDuke(game, g => g.decision?.kind === 'moritaniPlacement', plan, actions);
const token = game.moritaniTerror!.tokens.find(t => t.kind === 'assassination' && t.status === 'available');
assert.ok(token);
terrorToken = token.id;
game = stepNexusDuke(game, plan, { actor: owner,
action: { type: 'decision', token: token.id, territory: terrorLocation } }, actions);
}
plan.ship = { [owner]: { territory: 'carthag', amount: 1 },
[captor]: { territory: 'tueks_sietch', amount: 1 } };
if (options.terrorEntrant === 'guild') plan.ship[guild] = { territory: terrorLocation, amount: 1 };
game = advanceNexusDuke(game, g => options.terror
? g.decision?.kind === 'moritaniTerror' && g.pendingTerrorEntry?.token === terrorToken
: g.turn === 2 && g.phase === 5 && nexusDukeClean(g) &&
!!g.active && g.movementRemaining?.length === 1 &&
plan.shipped[owner] === String(g.turn) && plan.shipped[captor] === String(g.turn), plan, actions);
assertNexusDukeCustody(game);
return { game, owner, captor, guild, target: setup.target, home, plan, actions, acquisition, capture, terrorToken };
}
export function reclaimMoritaniDuke(f: MoritaniDukeCaptureFixture): Frame {
const before = structuredClone(f.game);
const last = nextNexusDukeStep(f.game, f.plan);
assert.ok(last && last.action.type === 'endMovement');
let game = stepNexusDuke(f.game, f.plan, last, f.actions);
game = advanceNexusDuke(game, g => g.phase === 6 && nexusDukeClean(g), f.plan, f.actions);
assertNexusDukeCustody(game);
return { before, after: game };
}
export function cancelMoritaniDukeReclaim(f: MoritaniDukeCaptureFixture): Frame {
const before = structuredClone(f.game), last = nextNexusDukeStep(f.game, f.plan);
assert.ok(last && last.action.type === 'endMovement');
let game = stepNexusDuke(f.game, f.plan, last, f.actions);
game = advanceNexusDuke(game, g => g.response?.kind === 'moritaniDuke', f.plan, f.actions);
const karama = nexusDukePlayer(game, f.captor).hand.find(c => c.effect === 'karama');
assert.ok(karama);
game = stepNexusDuke(game, f.plan, { actor: f.captor,
action: { type: 'card', card: karama.id, mode: 'cancel' } }, f.actions);
game = advanceNexusDuke(game, g => g.phase === 6 && nexusDukeClean(g), f.plan, f.actions);
assertNexusDukeCustody(game);
return { before, after: game };
}
/** The native captor really uses Duke; its returned disc cannot erase the
* surviving battle identity before Moritani's distinct printed-faction choice. */
export function openCapturedDukeAssassinateWindow(game: Game, f: MoritaniDukeCaptureFixture): Game {
const carthag = openNexusDukeBattle(game, f.plan, f.actions,
{ territory: 'carthag', first: f.owner, second: f.captor });
const sealed = sealNexusDukeBattle(carthag.game, f.plan, f.actions, [
{ actor: f.owner, leader: nativeLeader(carthag.game, f.owner, 'carthag', false), dial: 0, support: 0 },
{ actor: f.captor, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
]);
game = advanceNexusDuke(sealed, g => g.decision?.kind === 'moritaniAssassinate', f.plan, f.actions);
return game;
}
export function assassinateAfterCapturedDukeUse(game: Game, f: MoritaniDukeCaptureFixture): {
window: Game; after: Game; replaced: Game;
} {
game = openCapturedDukeAssassinateWindow(game, f);
const offer = viewGame(game, f.owner).moritaniAssassinate?.pending;
assert.ok(offer);
assert.ok(offer.cards.some(c => c.card === f.target), 'The actual held native Harkonnen Traitor remains a legal different-disc target.');
const window = structuredClone(game);
game = stepNexusDuke(game, f.plan, { actor: f.owner,
action: { type: 'decision', event: offer.event, card: f.target } }, f.actions);
const after = structuredClone(game);
game = advanceNexusDuke(game, g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
const tueks = openNexusDukeBattle(game, f.plan, f.actions,
{ territory: 'tueks_sietch', first: f.owner, second: f.captor });
const rest = sealNexusDukeBattle(tueks.game, f.plan, f.actions, [
{ actor: f.owner, leader: nativeLeader(tueks.game, f.owner, 'tueks_sietch', true), dial: 0, support: 0 },
{ actor: f.captor, leader: nativeLeader(tueks.game, f.captor, 'tueks_sietch', false), dial: 0, support: 0 },
]);
game = advanceNexusDuke(rest, g => g.phase === 8 && nexusDukeClean(g), f.plan, f.actions);
assertNexusDukeCustody(game);
return { window, after, replaced: game };
}
export function useMoritaniReclaimedDuke(game: Game, f: MoritaniDukeCaptureFixture, use = true): Game {
const carthag = openNexusDukeBattle(game, f.plan, f.actions,
{ territory: 'carthag', first: f.owner, second: f.captor });
let sealed = sealNexusDukeBattle(carthag.game, f.plan, f.actions, [
{ actor: f.owner, leader: use ? DUKE_VIDAL_ID : nativeLeader(carthag.game, f.owner, 'carthag', true), dial: 0, support: 0 },
{ actor: f.captor, leader: nativeLeader(carthag.game, f.captor, 'carthag', false), dial: 0, support: 0 },
]);
game = advanceNexusDuke(sealed, g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
const tueks = openNexusDukeBattle(game, f.plan, f.actions,
{ territory: 'tueks_sietch', first: f.owner, second: f.captor });
sealed = sealNexusDukeBattle(tueks.game, f.plan, f.actions, [
{ actor: f.owner, leader: nativeLeader(tueks.game, f.owner, 'tueks_sietch', true), dial: 0, support: 0 },
{ actor: f.captor, leader: nativeLeader(tueks.game, f.captor, 'tueks_sietch', false), dial: 0, support: 0 },
]);
game = advanceNexusDuke(sealed, g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
if (!use) game = advanceNexusDuke(game, g => g.turn > 2, f.plan, f.actions);
assertNexusDukeCustody(game);
return game;
}
/** Controls only the original random permutation, never the effect, candidate
* body or assigned leader state. UUID and non-scalar entropy remain native. */
export function revealMoritaniTerrorVictim(f: MoritaniDukeCaptureFixture, victim?: string): Frame {
const before = structuredClone(f.game);
const entry = f.game.pendingTerrorEntry!;
assert.ok(f.game.decision?.kind === 'moritaniTerror' && entry.token === f.terrorToken);
const pool = moritaniTerrorAssassinationPool(f.game, entry.entrant).map(l => l.id);
const desired = victim ? [victim, ...pool.filter(id => id !== victim)] : pool;
assert.deepEqual(pool.slice().sort(), desired.slice().sort());
const working = pool.slice(), rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let index = 0;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
const action = { type: 'decision' as const, reveal: true };
f.actions.push({ actor: f.owner, action });
let after: Game;
try { after = applyAction(structuredClone(f.game), f.owner, action); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
assertNexusDukeCustody(after);
return { before, after };
}
+2 -1
View File
@@ -718,7 +718,8 @@ export function assertNexusDukeCustody(game: Game): void {
}
const traitors = [...(game.traitorReserve ?? []),
...game.players.flatMap((player) => [...player.traitors, ...player.traitorChoices]),
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
...(game.moritaniAssassinate?.opportunities.filter(record => record.stage === 'replaced').map(record => record.card!) ?? [])];
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
assert.ok(!traitors.includes(DUKE_VIDAL_ID), 'The shared disc has no Traitor identity.');
const physical = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand),
@@ -104,7 +104,7 @@ void test('original mixed E3 native Moritani admission conserves decks, marks, t
}
});
void test('two-through-six original seats, the combined five/six-native roster and the Harkonnen guards stay original', () => {
void test('two-through-six original seats and the combined five/six-native roster keep original physical setup', () => {
const two = setup({ families: ['ix'], roster: ['moritani', 'tleilaxu'] });
assert.equal(two.afterSetup.players.length, 2); assert.equal(two.partner, null);
assert.equal(two.afterSetup.nexusCards!.cards!.deck.length, 12);
@@ -123,11 +123,10 @@ void test('two-through-six original seats, the combined five/six-native roster a
assert.equal(six.entry, 'combined'); assert.equal(six.afterSetup.richeseCache!.length, 10);
assert.ok(six.afterSetup.expansions.includes('ix'));
physical(six.afterSetup);
// Basic Harkonnen remains a legal classic seat; Advanced retains its guard.
// Basic Harkonnen retains its original native setup.
const basic = setup({ rules: 'basic', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] });
assert.equal(player(basic.afterSetup, basic.seats.harkonnen!).traitors.length, 4);
assert.equal(basic.afterSetup.moritaniAssassinate, undefined);
assert.throws(() => setup({ rules: 'advanced', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] }));
assert.throws(() => setup({ rules: 'basic', families: ['ix'], strongholds: true }));
});
-1
View File
@@ -89,7 +89,6 @@ void test('public trigger requires a native Advanced Moritani normal loss, a sur
{ opposingLeader: null },
{ opposingLeader: 'cheap-hero-traitor' },
{ opposingLeader: 'emperor-0' },
{ faction: 'harkonnen', opposingLeader: 'harkonnen-0' },
{ turn: 0 },
{ turn: 1.5 },
{ event: '' },
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { dukeController } from '../game/duke-vidal';
import { viewGame } from '../game/engine';
import {
assassinateAfterCapturedDukeUse, cancelMoritaniDukeReclaim,
createMoritaniDukeCaptureFixture, reclaimMoritaniDuke, useMoritaniReclaimedDuke,
} from './fixture-moritani-duke-capture';
import { nextNexusDukeStep, nexusDukePlayer, stepNexusDuke } from './fixture-nexus-duke-capture';
void test('original Moritani qualification takes its real Harkonnen captive and releases after one native battle', () => {
const f = createMoritaniDukeCaptureFixture();
assert.equal(dukeController(f.game.dukeVidal!), f.captor);
const reclaimed = reclaimMoritaniDuke(f);
const duke = reclaimed.after.dukeVidal!;
assert.equal(dukeController(duke), f.owner);
assert.equal(duke.leader.capturedBy, undefined);
assert.equal(duke.leader.concealed, undefined);
assert.equal(duke.captureVisibility, undefined);
assert.equal(duke.leader.dead, false);
assert.equal(duke.leader.deaths, f.game.dukeVidal!.leader.deaths);
assert.equal(duke.source, 'moritani');
assert.equal(nexusDukePlayer(reclaimed.after, f.owner).spice, nexusDukePlayer(reclaimed.before, f.owner).spice);
const used = useMoritaniReclaimedDuke(reclaimed.after, f);
assert.equal(dukeController(used.dukeVidal!), null);
assert.equal(used.dukeVidal!.leader.dead, false);
assert.equal(used.dukeVidal!.leader.usedAt, 'carthag');
assert.equal(used.dukeVidal!.leader.capturedBy, undefined);
});
void test('unused original Moritani reclamation expires without restoring Harkonnen custody', () => {
const f = createMoritaniDukeCaptureFixture();
const reclaimed = reclaimMoritaniDuke(f);
const expired = useMoritaniReclaimedDuke(reclaimed.after, f, false);
assert.equal(dukeController(expired.dukeVidal!), null);
assert.equal(expired.dukeVidal!.leader.dead, false);
assert.equal(expired.dukeVidal!.leader.capturedBy, undefined);
assert.equal(expired.dukeVidal!.leader.deaths, f.game.dukeVidal!.leader.deaths);
});
void test('a qualified public acquisition cannot revive an actually executed captive Duke', () => {
const f = createMoritaniDukeCaptureFixture({ execute: true });
assert.equal(f.game.dukeVidal!.leader.dead, true);
const resolved = reclaimMoritaniDuke(f);
assert.equal(dukeController(resolved.after.dukeVidal!), null);
assert.equal(resolved.after.dukeVidal!.leader.dead, true);
assert.equal(resolved.after.dukeVidal!.leader.deaths, 2);
assert.equal(resolved.after.dukeVidal!.leader.concealed, undefined);
assert.equal(nexusDukePlayer(resolved.after, f.owner).spice, nexusDukePlayer(resolved.before, f.owner).spice);
});
void test('original Karama preserves the captive and its actual winning use still opens native Harkonnen Assassinate', () => {
const f = createMoritaniDukeCaptureFixture({ karama: true });
const karama = nexusDukePlayer(f.game, f.captor).hand.find(c => c.effect === 'karama')!;
const canceled = cancelMoritaniDukeReclaim(f);
assert.equal(dukeController(canceled.after.dukeVidal!), f.captor);
assert.deepEqual(canceled.after.dukeVidal, canceled.before.dukeVidal);
assert.ok(canceled.after.discard.some(c => c.id === karama.id));
const used = assassinateAfterCapturedDukeUse(canceled.after, f);
assert.equal(dukeController(used.window.dukeVidal!), null);
assert.equal(used.window.dukeVidal!.leader.dead, false);
const targetBefore = nexusDukePlayer(used.window, f.captor).leaders.find(l => l.id === f.target)!;
const targetAfter = nexusDukePlayer(used.after, f.captor).leaders.find(l => l.id === f.target)!;
assert.equal(targetAfter.dead, true);
assert.equal(targetAfter.deaths, targetBefore.deaths + 1);
assert.equal(nexusDukePlayer(used.after, f.owner).spice,
nexusDukePlayer(used.window, f.owner).spice + targetBefore.strength);
assert.equal(nexusDukePlayer(used.after, f.owner).traitors.includes(f.target), true);
assert.equal(nexusDukePlayer(used.replaced, f.owner).traitors.includes(f.target), false);
assert.equal(used.replaced.moritaniAssassinate!.opportunities.filter(r =>
r.card === f.target && r.stage === 'replaced').length, 1);
});
void test('a private execution does not remove or identify the public qualifying acquisition window', () => {
const living = createMoritaniDukeCaptureFixture({ karama: true });
const executed = createMoritaniDukeCaptureFixture({ karama: true, execute: true });
const open = (f: typeof living) => {
const next = nextNexusDukeStep(f.game, f.plan);
assert.ok(next);
return stepNexusDuke(f.game, f.plan, next, f.actions);
};
const livePending = open(living), deadPending = open(executed);
assert.equal(livePending.response?.kind, 'moritaniDuke');
assert.equal(deadPending.response?.kind, 'moritaniDuke');
const livePublic = viewGame(livePending, living.guild);
const deadPublic = viewGame(deadPending, executed.guild);
assert.deepEqual(deadPublic.response, livePublic.response);
assert.deepEqual(deadPublic.dukeVidal, livePublic.dukeVidal);
assert.equal(deadPending.dukeVidal!.leader.dead, true);
});
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { acquireDuke, captureDuke, consumeDuke, createDukeVidal, dukeController, expireDuke } from '../game/duke-vidal';
void test('Moritani takes the actual living captive without restoring the captor after its tenure', () => {
const initial = acquireDuke(createDukeVidal(), 'm', 1, 'moritani');
initial.leader.deaths = 2;
const captive = captureDuke(initial, 'h', 'm', 1, 'arrakeen');
const before = structuredClone(captive);
const taken = acquireDuke(captive, 'm', 2, 'moritani');
assert.deepEqual(captive, before);
assert.equal(dukeController(taken), 'm');
assert.equal(taken.source, 'moritani');
assert.equal(taken.leader.capturedBy, undefined);
assert.equal(taken.leader.concealed, undefined);
assert.equal(taken.captureVisibility, undefined);
assert.equal(taken.leader.dead, false);
assert.equal(taken.leader.deaths, 2);
assert.equal(taken.leader.faction, 'ecaz');
assert.equal(taken.leader.strength, 6);
const unused = expireDuke(taken, 2);
assert.equal(dukeController(unused), null);
assert.equal(unused.leader.capturedBy, undefined);
const used = consumeDuke(taken);
assert.equal(dukeController(used), null);
assert.equal(used.leader.deaths, 2);
});
+2 -122
View File
@@ -10,8 +10,6 @@ import {
} from '../game/engine';
import { type FactionId } from '../game/catalog';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createTechTokens } from '../game/tech-tokens';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
@@ -119,7 +117,7 @@ void test('each native leader is eligible when alone alive; death count and batt
}
});
void test('Assassination pays three for Zoal but leaves unaudited empty live-leader pools unchanged', () => {
void test('Assassination pays the printed three for Zoal', () => {
const initial = fixture('assassination', 'tleilaxu');
const zoal = player(initial, 'e').leaders.find(
(leader) => leader.name === 'Zoal',
@@ -133,33 +131,15 @@ void test('Assassination pays three for Zoal but leaves unaudited empty live-lea
player(done, 'e').leaders.find((leader) => leader.id === zoal.id)!.dead,
true,
);
const empty = fixture();
player(empty, 'e').leaders.forEach((leader) => {
leader.dead = true;
leader.deaths = 2;
});
const entered = enter(empty);
assert.equal(viewGame(entered, 'm').terrorEntry!.canReveal, false);
rejected(entered, { type: 'decision', reveal: true });
assert.equal(
entered.moritaniTerror!.tokens.find(
(token) => token.kind === 'assassination',
)!.status,
'placed',
);
const declined = decide(entered, { decline: true });
assert.deepEqual(declined.players, entered.players);
assert.equal(declined.pendingTerrorEntry, null);
});
void test('unresolved native and foreign custody disables Assassination only in the owner projection and rejects atomically', () => {
void test('malformed capture and Ghola assignments reject atomically without disclosing rival token faces', () => {
for (const field of ['capturedBy', 'gholaBy'] as const) {
for (const foreign of [false, true]) {
const initial = fixture();
if (foreign) player(initial, 'a').leaders[0][field] = 'e';
else player(initial, 'e').leaders[0][field] = 'a';
const g = enter(initial);
assert.equal(viewGame(g, 'm').terrorEntry!.canReveal, false);
assert.equal('canReveal' in viewGame(g, 'e').terrorEntry!, false);
assert.equal('kind' in viewGame(g, 'a').terrorEntry!, false);
rejected(g, { type: 'decision', reveal: true });
@@ -260,30 +240,11 @@ void test('Sneak Attack reinforces a stronghold already occupied by Moritani wit
assert.equal(totalForces(done, 'm'), 20);
});
void test('all four AI profiles offer legal Assassination and Sneak Attack decisions', () => {
for (const difficulty of DIFFICULTIES) {
for (const state of [
offered(),
offered('sneakAttack'),
decide(offered('sneakAttack'), { reveal: true }),
]) {
player(state, 'm').bot = difficulty;
const actions = botActions(viewGame(state, 'm'));
assert.ok(
actions.length > 0,
difficulty + ':' + state.pendingTerrorEntry?.stage,
);
for (const action of actions)
assert.doesNotThrow(() => applyAction(state, 'm', action));
}
}
});
void test('unknown native leader identities cannot bypass Assassination; newly verified rosters are eligible', () => {
const unknown = fixture();
player(unknown, 'e').leaders[0].id = 'not-a-catalog-leader';
const offeredUnknown = enter(unknown);
assert.equal(viewGame(offeredUnknown, 'm').terrorEntry!.canReveal, false);
rejected(offeredUnknown, { type: 'decision', reveal: true });
for (const faction of ['choam', 'richese'] as const) {
const known = enter(fixture('assassination', faction));
@@ -291,29 +252,10 @@ void test('unknown native leader identities cannot bypass Assassination; newly v
const malformed = fixture('assassination', faction);
player(malformed, 'e').leaders[0].id = 'unverified-expansion-leader';
const missing = enter(malformed);
assert.equal(viewGame(missing, 'm').terrorEntry!.canReveal, false);
rejected(missing, { type: 'decision', reveal: true });
}
});
void test('advanced Harkonnen tables uniformly disable Assassination without exposing hidden capture changes', () => {
const initial = fixture();
initial.advanced = true;
const harkonnen = newPlayer('h', 'Harkonnen', 'harkonnen');
initial.players.push(harkonnen);
initial.order.push('h');
const empty = enter(initial);
assert.equal(viewGame(empty, 'm').terrorEntry!.canReveal, false);
rejected(empty, { type: 'decision', reveal: true });
const captured = structuredClone(initial);
player(captured, 'e').leaders[0].capturedBy = 'h';
const held = enter(captured);
assert.deepEqual(
viewGame(held, 'm').terrorEntry,
viewGame(empty, 'm').terrorEntry,
);
rejected(held, { type: 'decision', reveal: true });
});
void test('Sneak Attack rejects an altered client sector and accrues Heighliners once for a positive entry while allowing zero', () => {
const revealed = decide(offered('sneakAttack'), { reveal: true });
@@ -328,12 +270,6 @@ void test('Sneak Attack rejects an altered client sector and accrues Heighliners
assert.equal(player(accrued, 'm').forces['arrakeen:10'], 1);
assert.equal(accrued.techTokens!.heighliners.triggeredTurn, accrued.turn);
assert.equal(accrued.techTokens!.heighliners.spice, 1);
assert.equal(
accrued.log.some((line) =>
line.text.includes('Heighliners accrued 1 spice for Atreides'),
),
true,
);
const passed = decide(technology, { amount: 0 });
assert.deepEqual(passed.techTokens, technology.techTokens);
assert.deepEqual(passed.players, technology.players);
@@ -384,59 +320,3 @@ void test('declining the Bene Gesserit intrusion still offers the free advisor s
);
});
void test('all four AI profiles complete blocked effects by declining instead of retrying illegal choices', () => {
const noLeaders = fixture();
player(noLeaders, 'e').leaders.forEach((leader) => {
leader.dead = true;
});
const blockedAssassination = enter(noLeaders);
const blockedSneak = decide(offered('sneakAttack'), { reveal: true });
blockedSneak.storm = 10;
const blockedOffer = offered('sneakAttack');
blockedOffer.storm = 10;
const emptyOffer = offered('sneakAttack');
player(emptyOffer, 'm').reserves = 0;
assert.ok(viewGame(blockedOffer, 'm').terrorEntry!.sneakAttack?.blocked);
assert.equal(viewGame(blockedOffer, 'm').terrorEntry!.stage, 'offer');
assert.equal(viewGame(emptyOffer, 'm').terrorEntry!.sneakAttack?.maximum, 0);
for (const state of [blockedOffer, emptyOffer]) {
for (const id of ['e', 'a']) {
assert.equal('sneakAttack' in viewGame(state, id).terrorEntry!, false);
assert.equal('kind' in viewGame(state, id).terrorEntry!, false);
}
}
for (const difficulty of DIFFICULTIES) {
for (const state of [
blockedAssassination,
blockedSneak,
blockedOffer,
emptyOffer,
]) {
const g = reload(state);
player(g, 'm').bot = difficulty;
const actions = botActions(viewGame(g, 'm'));
assert.ok(actions.length > 0);
for (const action of actions) {
const done = applyAction(g, 'm', action);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision, null);
assert.deepEqual(done.players, g.players);
if (state === blockedOffer || state === emptyOffer) {
assert.equal(action.decline, true);
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
assert.equal(
done.moritaniTerror!.tokens.find(
(token) => token.kind === 'sneakAttack',
)!.status,
'placed',
);
}
}
}
}
assert.ok(viewGame(blockedSneak, 'm').terrorEntry!.sneakAttack?.blocked);
assert.equal(
'sneakAttack' in viewGame(blockedSneak, 'e').terrorEntry!,
false,
);
});
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaders } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
createMoritaniAssassinateOpportunity,
moritaniAssassinateChoices,
moritaniAssassinateSignature,
moritaniAssassinateTrigger,
quoteMoritaniAssassinate,
validateMoritaniAssassinate,
type MoritaniAssassinateContext,
type MoritaniAssassinateReceipt,
type MoritaniAssassinateTrigger,
} from '../game/moritani-assassinate';
function trigger(opposingLeader = 'harkonnen-0'): MoritaniAssassinateTrigger {
return {
advanced: true,
owner: 'm',
ownerFaction: 'moritani',
opponent: 'h',
faction: 'harkonnen',
event: 'battle:2:arrakeen',
turn: 2,
territory: 'arrakeen',
opposingLeader,
opposingLeaderSurvived: true,
normalBattle: true,
lostBattle: true,
traitorCalled: false,
};
}
function fixture(opposingLeader = 'harkonnen-0'): MoritaniAssassinateContext {
const receipt = createMoritaniAssassinateOpportunity(trigger(opposingLeader));
return {
state: { version: 1, owner: 'm', normalTraitorCall: false, opportunities: [receipt] },
receipt,
held: ['harkonnen-0', 'harkonnen-1', 'moritani-0', 'atreides-0', DUKE_VIDAL_ID, 'cheap-hero-traitor'],
normallyRevealed: [],
leaders: leaders('harkonnen'),
};
}
function revised(
receipt: MoritaniAssassinateReceipt,
patch: Partial<MoritaniAssassinateReceipt>,
): MoritaniAssassinateReceipt {
const result = { ...receipt, ...patch };
result.signature = moritaniAssassinateSignature(result);
return result;
}
void test('a surviving native Harkonnen battle disc excludes only its own card and permits a different printed Harkonnen victim', () => {
const context = fixture();
assert.equal(moritaniAssassinateTrigger(trigger()), true);
assert.deepEqual(moritaniAssassinateChoices(context).cards.map(card => card.card), ['harkonnen-1']);
assert.throws(() => quoteMoritaniAssassinate(context, 'harkonnen-0'));
const quote = quoteMoritaniAssassinate(context, 'harkonnen-1');
assert.equal(quote.kill, true);
assert.equal(quote.bounty, 4);
validateMoritaniAssassinate({ ...context.state, opportunities: [quote.receipt] }, {
turn: 2, held: context.held, removed: [],
});
assert.throws(() => validateMoritaniAssassinate({
...context.state,
opportunities: [revised(quote.receipt, { card: 'harkonnen-0', bounty: 6 })],
}));
assert.equal(moritaniAssassinateChoices({
...context, receipt: quote.receipt,
state: { ...context.state, opportunities: [quote.receipt] },
}).cards.length, 0);
});
void test('a Harkonnen win with a captured foreign disc, including Moritani, still exposes only printed Harkonnen targets', () => {
for (const battleDisc of ['atreides-0', 'moritani-0', DUKE_VIDAL_ID]) {
const context = fixture(battleDisc);
// The authoritative engine supplies the actual controlled surviving disc.
assert.equal(moritaniAssassinateTrigger(trigger(battleDisc)), true);
assert.deepEqual(moritaniAssassinateChoices(context).cards.map(c => c.card), [
'harkonnen-0', 'harkonnen-1',
]);
const foreignCard = battleDisc === 'moritani-0' ? 'atreides-0' : 'moritani-0';
assert.throws(() => quoteMoritaniAssassinate(context, foreignCard));
const quote = quoteMoritaniAssassinate(context, 'harkonnen-0');
assert.equal(quote.bounty, 6);
assert.equal(quote.kill, true);
const state = { ...context.state, opportunities: [quote.receipt] };
validateMoritaniAssassinate(state, { turn: 2, held: context.held, removed: [] });
// A signed receipt cannot reclassify the captured disc or Duke as the victim.
assert.throws(() => validateMoritaniAssassinate({
...state,
opportunities: [revised(quote.receipt, { card: battleDisc, bounty: 0 })],
}));
}
});
void test('Cheap Hero, missing disc, death, traitor calls and nonnormal outcomes cannot create a Harkonnen opportunity', () => {
for (const patch of [
{ opposingLeader: 'cheap-hero-traitor' },
{ opposingLeader: 'cheap-hero' },
{ opposingLeader: 'choam-auditor' },
{ opposingLeader: 'harkonnen-5' },
{ opposingLeader: null },
{ opposingLeaderSurvived: false },
{ advanced: false },
{ normalBattle: false },
{ lostBattle: false },
{ traitorCalled: true },
]) {
const input = { ...trigger(), ...patch };
assert.equal(moritaniAssassinateTrigger(input), false);
assert.throws(() => createMoritaniAssassinateOpportunity(input));
}
const context = fixture();
assert.throws(() => validateMoritaniAssassinate({
...context.state,
opportunities: [revised(context.receipt, { opposingLeader: 'cheap-hero-traitor' })],
}));
});
void test('dead native Harkonnen targets consume the faction use without a second death or bounty', () => {
const context = fixture('moritani-0');
context.leaders[0].dead = true;
context.leaders[0].deaths = 2;
const before = structuredClone(context);
const quote = quoteMoritaniAssassinate(context, 'harkonnen-0');
assert.equal(quote.kill, false);
assert.equal(quote.bounty, 0);
const next = fixture(DUKE_VIDAL_ID);
next.receipt = revised(next.receipt, { event: 'battle:2:carthag' });
next.state.opportunities = [quote.receipt, next.receipt];
assert.deepEqual(moritaniAssassinateChoices(next).cards, []);
assert.throws(() => quoteMoritaniAssassinate(next, 'harkonnen-1'));
assert.deepEqual(context, before);
});
void test('the normal own-card reveal forfeits Harkonnen assassination across turns while a decline does not consume it', () => {
const context = fixture('atreides-0');
const declined = revised(context.receipt, { stage: 'declined' });
const next = fixture('moritani-0');
next.receipt = revised(next.receipt, { event: 'battle:3:carthag', turn: 3 });
next.state.opportunities = [declined, next.receipt];
assert.equal(quoteMoritaniAssassinate(next, 'harkonnen-1').bounty, 4);
next.state.normalTraitorCall = true;
assert.deepEqual(moritaniAssassinateChoices(next).cards, []);
assert.throws(() => quoteMoritaniAssassinate(next, 'harkonnen-1'));
});
void test('Harkonnen reveal retains its physical card until one distinct Mentat replacement and never resets faction use', () => {
const context = fixture(DUKE_VIDAL_ID);
const revealed = quoteMoritaniAssassinate(context, 'harkonnen-1').receipt;
const state = { ...context.state, opportunities: [revealed] };
assert.throws(() => validateMoritaniAssassinate(state, {
turn: 2, held: ['moritani-0'], removed: ['harkonnen-1'],
}));
for (const replacement of ['harkonnen-1', DUKE_VIDAL_ID, 'cheap-hero-traitor'])
assert.throws(() => validateMoritaniAssassinate({
...state, opportunities: [revised(revealed, { stage: 'replaced', replacement })],
}));
const replaced = revised(revealed, { stage: 'replaced', replacement: 'guild-2' });
validateMoritaniAssassinate({ ...state, opportunities: [replaced] }, {
turn: 3, held: ['guild-2'], removed: ['harkonnen-1'],
});
const next = fixture('harkonnen-0');
next.receipt = revised(next.receipt, { event: 'battle:3:carthag', turn: 3 });
next.state.opportunities = [replaced, next.receipt];
assert.throws(() => quoteMoritaniAssassinate(next, 'harkonnen-1'));
assert.throws(() => validateMoritaniAssassinate({
...state,
opportunities: [replaced, revised(revealed, {
event: 'battle:3:carthag', turn: 3, card: 'harkonnen-0', opposingLeader: 'moritani-0', bounty: 6,
})],
}));
});
void test('admitting Harkonnen battle captives does not relax exceptional target custody or other factions battle identities', () => {
for (const patch of [
{ capturedBy: 'other' },
{ gholaBy: 'other' },
{ controller: 'other' },
{ concealed: { captor: 'other', dead: false, deaths: 0 } },
]) {
const context = fixture('moritani-0');
Object.assign(context.leaders[1], patch);
assert.deepEqual(moritaniAssassinateChoices(context).cards.map(c => c.card), ['harkonnen-0']);
assert.throws(() => quoteMoritaniAssassinate(context, 'harkonnen-1'));
}
for (const battleDisc of ['harkonnen-0', 'moritani-0', DUKE_VIDAL_ID])
assert.equal(moritaniAssassinateTrigger({ ...trigger(battleDisc), faction: 'atreides' }), false);
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
import { createMoritaniDukeCaptureFixture, revealMoritaniTerrorVictim } from './fixture-moritani-duke-capture';
import { advanceNexusDuke, nexusDukeClean, nexusDukePlayer } from './fixture-nexus-duke-capture';
void test('the actual native Terror lottery kills the entrant’s captured Duke once and resumes its paid shipment', () => {
const f = createMoritaniDukeCaptureFixture({ terror: true });
assert.equal(f.game.pendingTerrorEntry!.entrant, f.captor);
assert.equal(f.game.dukeVidal!.leader.capturedBy, f.captor);
const revealed = revealMoritaniTerrorVictim(f, DUKE_VIDAL_ID);
assert.equal(revealed.after.dukeVidal!.leader.dead, true);
assert.equal(revealed.after.dukeVidal!.leader.deaths, revealed.before.dukeVidal!.leader.deaths + 1);
assert.equal(dukeController(revealed.after.dukeVidal!), null);
assert.equal(revealed.after.dukeVidal!.leader.capturedBy, undefined);
assert.equal(revealed.after.dukeVidal!.leader.concealed, undefined);
assert.equal(revealed.after.dukeVidal!.captureVisibility, undefined);
assert.equal(nexusDukePlayer(revealed.after, f.owner).spice,
nexusDukePlayer(revealed.before, f.owner).spice + 6);
assert.ok(nexusDukePlayer(revealed.after, f.captor).leaders.every(l => !l.dead));
assert.equal(nexusDukePlayer(revealed.after, f.captor).forces['tueks_sietch:5'], 1);
assert.equal(revealed.after.moritaniTerror!.tokens.find(t => t.id === f.terrorToken)!.status, 'removed');
assert.ok(!revealed.after.pendingTerrorEntry);
const settled = advanceNexusDuke(revealed.after,
g => g.phase === 6 && nexusDukeClean(g), f.plan, f.actions);
assert.equal(settled.dukeVidal!.leader.dead, true);
assert.equal(dukeController(settled.dukeVidal!), null);
});
void test('an explicit empty-pool checkpoint spends the original revealed token with no death or bounty', () => {
const f = createMoritaniDukeCaptureFixture({ terror: true, terrorEntrant: 'guild' });
assert.equal(f.game.pendingTerrorEntry!.entrant, f.guild);
// Controlled boundary: the original arrival/token are genuine; only the
// entrant's preceding native-death history is staged for this empty edge.
for (const leader of nexusDukePlayer(f.game, f.guild).leaders) {
leader.dead = true;
leader.deaths = 1;
}
const before = structuredClone(nexusDukePlayer(f.game, f.guild).leaders);
const revealed = revealMoritaniTerrorVictim(f);
assert.deepEqual(nexusDukePlayer(revealed.after, f.guild).leaders, before);
assert.equal(nexusDukePlayer(revealed.after, f.owner).spice,
nexusDukePlayer(revealed.before, f.owner).spice);
assert.equal(revealed.after.moritaniTerror!.tokens.find(t => t.id === f.terrorToken)!.status, 'removed');
assert.equal(nexusDukePlayer(revealed.after, f.guild).forces['arrakeen:10'], 1);
assert.ok(!revealed.after.pendingTerrorEntry);
assert.equal(revealed.after.active, f.guild);
});
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
battleLeaderStrength,
createAuditorLeader,
leaders,
type Leader,
} from '../game/cards';
import type { FactionId } from '../game/catalog';
import { acquireDuke, captureDuke, createDukeVidal, type DukeState } from '../game/duke-vidal';
import {
moritaniTerrorAssassinationPool,
moritaniTerrorAssassinationValue,
} from '../game/moritani-terror-assassination';
type TerrorFixture = {
advanced: boolean;
players: { id: string; faction: FactionId; leaders: Leader[] }[];
dukeVidal: DukeState;
};
function fixture(): TerrorFixture {
return {
advanced: true,
players: (['atreides', 'harkonnen', 'tleilaxu', 'ecaz', 'moritani', 'choam'] as const)
.map((faction) => ({ id: faction, faction, leaders: leaders(faction) })),
dukeVidal: createDukeVidal(),
};
}
const seat = (g: TerrorFixture, id: string) =>
g.players.find((player) => player.id === id)!;
const ids = (pool: readonly Leader[]) => pool.map((leader) => leader.id);
void test('ordinary eligibility retains all five living native discs without mutating custody', () => {
const g = fixture();
g.advanced = false;
const owner = seat(g, 'atreides');
const before = structuredClone(g);
const first = moritaniTerrorAssassinationPool(g, owner.id);
assert.deepEqual(ids(first), ['atreides-0', 'atreides-1', 'atreides-2', 'atreides-3', 'atreides-4']);
assert.deepEqual(g, before);
});
void test('native discs controlled elsewhere are excluded and the captor receives the actual foreign disc', () => {
const g = fixture();
const native = seat(g, 'atreides').leaders;
native[0].capturedBy = 'harkonnen';
native[1].gholaBy = 'tleilaxu';
const before = structuredClone(g);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'atreides')), ['atreides-2', 'atreides-3', 'atreides-4']);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [
'atreides-0', 'harkonnen-0', 'harkonnen-1', 'harkonnen-2', 'harkonnen-3', 'harkonnen-4',
]);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'tleilaxu')), [
'atreides-1', 'tleilaxu-0', 'tleilaxu-1', 'tleilaxu-2', 'tleilaxu-3', 'tleilaxu-4',
]);
assert.deepEqual(g, before);
});
void test('capture overrides foreign-Ghola control without moving or copying the native physical disc', () => {
const g = fixture();
const ghola = seat(g, 'atreides').leaders[2];
ghola.gholaBy = 'tleilaxu';
ghola.capturedBy = 'harkonnen';
ghola.concealed = { captor: 'harkonnen', controller: 'tleilaxu', dead: false, deaths: 1 };
ghola.deaths = 1;
ghola.usedAt = 'carthag';
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'tleilaxu')), ids(seat(g, 'tleilaxu').leaders));
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [ghola.id, ...ids(seat(g, 'harkonnen').leaders)]);
});
void test('used living discs remain eligible while dead native and foreign discs do not', () => {
const g = fixture();
const native = seat(g, 'harkonnen').leaders;
native.forEach((leader) => { leader.dead = true; leader.deaths = 2; });
native[3].dead = false;
native[3].usedAt = 'arrakeen';
const foreign = seat(g, 'atreides').leaders[0];
foreign.capturedBy = 'harkonnen';
foreign.dead = true;
foreign.deaths = 1;
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [native[3].id]);
foreign.dead = false;
foreign.usedAt = 'carthag';
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [foreign.id, native[3].id]);
});
void test('the shared Duke is included once for actual server control, never merely for Ecaz identity', () => {
const g = fixture();
g.dukeVidal = acquireDuke(g.dukeVidal, 'moritani', 2, 'moritani');
g.dukeVidal.leader.usedAt = 'arrakeen';
const before = structuredClone(g);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'moritani')),
[...ids(seat(g, 'moritani').leaders), g.dukeVidal.leader.id]);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
assert.deepEqual(g, before);
});
void test('a genuinely captured shared Duke follows the native captor rather than his original tenure', () => {
const g = fixture();
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal, 'moritani', 2, 'moritani'),
'harkonnen', 'moritani', 2, 'arrakeen');
const before = structuredClone(g);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'moritani')), ids(seat(g, 'moritani').leaders));
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')),
[...ids(seat(g, 'harkonnen').leaders), g.dukeVidal.leader.id]);
assert.deepEqual(g, before, 'quoting does not reveal the captive or change tenure');
});
void test('empty actual control returns an empty array without changing hidden custody or requiring a victim', () => {
const g = fixture();
for (const leader of seat(g, 'atreides').leaders) {
leader.dead = true;
leader.deaths = 1;
}
const concealed = seat(g, 'atreides').leaders[0];
concealed.dead = false;
concealed.capturedBy = 'harkonnen';
concealed.concealed = { captor: 'harkonnen', controller: 'atreides', dead: false, deaths: 1 };
const before = structuredClone(g);
assert.deepEqual(moritaniTerrorAssassinationPool(g, 'atreides'), []);
assert.deepEqual(g, before);
});
void test('set-aside and dead Duke do not receive faction fallback control', () => {
const g = fixture();
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
g.dukeVidal = acquireDuke(g.dukeVidal, 'ecaz', 1, 'ecaz');
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 1;
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
});
void test('the actual optional Auditor participates without replacing any of the native five', () => {
const g = fixture();
const choam = seat(g, 'choam');
const ordinary = [...choam.leaders];
const auditor = createAuditorLeader();
auditor.usedAt = 'sietch_tabr';
choam.leaders.push(auditor);
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'choam')), [...ids(ordinary), auditor.id]);
assert.equal(moritaniTerrorAssassinationValue(auditor), 2);
auditor.dead = true;
auditor.deaths = 1;
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'choam')), ids(ordinary));
g.advanced = false;
assert.throws(() => moritaniTerrorAssassinationPool(g, 'choam'));
});
void test('printed bounty preserves Zoal three and shared Duke six, not copied battle values', () => {
const zoal = leaders('tleilaxu')[0];
const opponent = leaders('fremen')[0];
assert.equal(battleLeaderStrength(zoal, opponent), 7);
assert.equal(moritaniTerrorAssassinationValue(zoal), 3);
assert.equal(moritaniTerrorAssassinationValue(createDukeVidal().leader), 6);
assert.throws(() => moritaniTerrorAssassinationValue({ ...zoal, strength: 7 }));
});
void test('missing, duplicated, extra and altered physical native discs reject rather than distort probability', () => {
const changes: ((g: TerrorFixture) => void)[] = [
(g) => { seat(g, 'atreides').leaders.pop(); },
(g) => { seat(g, 'atreides').leaders[1] = { ...seat(g, 'atreides').leaders[0] }; },
(g) => { seat(g, 'atreides').leaders.push({ ...seat(g, 'atreides').leaders[0] }); },
(g) => { seat(g, 'atreides').leaders[0].strength = 99; },
(g) => { seat(g, 'atreides').leaders[0].name = 'Invented disc'; },
(g) => { seat(g, 'atreides').leaders[0].faction = 'ecaz'; },
(g) => { seat(g, 'atreides').leaders[0].id = 'atreides-invented'; },
(g) => { seat(g, 'atreides').leaders[0].controller = 'ecaz'; },
(g) => { seat(g, 'atreides').leaders[0].capturedBy = 'missing-seat'; },
(g) => { seat(g, 'atreides').leaders[0].gholaBy = 'ecaz'; },
(g) => { g.players[1].id = g.players[0].id; },
];
for (const change of changes) {
const g = fixture();
change(g);
const before = structuredClone(g);
assert.throws(() => moritaniTerrorAssassinationPool(g, 'atreides'));
assert.deepEqual(g, before);
}
});
void test('shared-Duke roster copies, altered identity and forbidden foreign-Ghola sources reject', () => {
const changes: ((g: TerrorFixture) => void)[] = [
(g) => { seat(g, 'ecaz').leaders.push(g.dukeVidal.leader); },
(g) => { seat(g, 'ecaz').leaders[0] = { ...g.dukeVidal.leader }; },
(g) => { g.dukeVidal.leader.id = 'fake-duke'; },
(g) => { g.dukeVidal.leader.strength = 7; },
(g) => { g.dukeVidal.leader.gholaBy = 'tleilaxu'; },
(g) => { g.dukeVidal.controller = 'missing-seat'; },
];
for (const change of changes) {
const g = fixture();
change(g);
const before = structuredClone(g);
assert.throws(() => moritaniTerrorAssassinationPool(g, 'ecaz'));
assert.deepEqual(g, before);
}
});
+1 -2
View File
@@ -150,7 +150,7 @@ void test('a privately executed return remains a Tanks resurrection without leak
validateNexusEcazDuke(g, receipt, 0);
});
void test('Advanced Harkonnen admission is only the existing standalone no-Skills profile, not a broader Duke exception', () => {
void test('Advanced Harkonnen Duke support stays inside original no-Skills E3 profiles rather than lifting Skills guards', () => {
const g = position();
assert.equal(nexusEcazDukeModeSupported(g), true);
assert.equal(quoteEcazDukeAcquisition(g, 'e').source, 'ecaz');
@@ -160,7 +160,6 @@ void test('Advanced Harkonnen admission is only the existing standalone no-Skill
assert.throws(() => resolveEcazDukeRevival(g, 'h'));
for (const change of [
(saved: Game) => { saved.leaderSkills = {} as Game['leaderSkills']; },
(saved: Game) => { saved.players[2].faction = 'moritani'; },
]) {
const excluded = structuredClone(g);
change(excluded);
+1 -19
View File
@@ -2,9 +2,8 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { acquireDuke } from '../game/duke-vidal';
import { drawNexusCard } from '../game/nexus-cards';
import { nexusEcazDukePosition as position } from './fixture-nexus-ecaz-duke';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
import { nexusTraitorInventory } from './fixture-nexus-traitors';
import { nexusReady } from './fixture-nexus-cards';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
@@ -65,23 +64,6 @@ void test('a stale offer, busy table, invalid retained Tanks custody, captured o
reject(busy, 'p', action);
});
void test('Advanced Harkonnen battle lock rejects native Cunning before spending its card', () => {
const game = nexusTraitorFixture({ ownerFaction: 'ecaz', opponentFaction: 'moritani',
observerFaction: 'harkonnen', advanced: true, phase: 6 });
const cards = game.nexusCards!.cards!;
const old = cards.hands.p!;
cards.hands.p = null;
cards.deck.push(old);
const index = cards.deck.indexOf('ecaz');
assert.ok(index >= 0);
cards.deck.unshift(...cards.deck.splice(index, 1));
game.nexusCards!.cards = drawNexusCard(cards, 'p', game.players, () => 0);
game.dukeVidal = acquireDuke(game.dukeVidal!, 'q', game.turn, 'moritani');
const offer = viewGame(game, 'p').nexusEcazDuke!;
assert.match(offer.blocked!, /Advanced Harkonnen/);
reject(game, 'p', { type: 'nexusEcazDuke', event: offer.event });
assert.equal(game.nexusCards!.cards!.hands.p, 'ecaz');
});
void test('saved Ecaz Duke receipts detect changed historical ownership, event and missing physical spend', () => {
const { game, action } = position();
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
@@ -147,14 +147,6 @@ void test('original paired E3 Basic/Advanced two-to-six-seat setup conserves eca
}
const basic = setup({ advanced: false, skills: true, roster: ['ecaz', 'moritani', 'harkonnen'] });
assert.equal(player(basic.afterSetup, 'harkonnen').traitors.length, 4); inventory(basic.afterSetup);
let blocked = createGame('PAIRHARKGUARD', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(blocked, newPlayer('m', 'Moritani', 'moritani'));
joinGame(blocked, newPlayer('h', 'Harkonnen', 'harkonnen'));
for (const p of blocked.players) blocked = applyAction(blocked, p.id, { type: 'ready' });
blocked.nexusCards = { cards: null, phase: null };
const before = structuredClone(blocked);
assert.throws(() => initializePairedNexusGameForAudit(blocked));
assert.deepEqual(blocked, before, 'Rejected capture composition cannot redeal original pieces.');
});
void test('original paired E3 skill offers reject shared Duke and foreign training immutably', () => {