Connect native Moritani and Harkonnen captive Duke rules
This commit is contained in:
1 parent
559b42dd78
commit
a5a6db7868
40 files changed
+1408
-352
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user