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

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

353 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { ownedTech } from '../game/tech-tokens';
import {
initializeDiscoveryStandaloneNexusE3Skills as initialize,
completeDiscoveryStandaloneNexusE3SkillsSetup as complete,
createDiscoveryStandaloneNexusE3SkillsFixture as fixture,
closeDiscoveryStandaloneNexusE3Skills as close,
enterDiscoveryStandaloneNexusE3Skills as enter,
cunningDiscoveryStandaloneNexusE3Skills as cunning,
openDiscoveryStandaloneNexusE3SkillsBattle as open,
revealDiscoveryStandaloneNexusE3SkillsBattle as reveal,
settleDiscoveryStandaloneNexusE3SkillsBattle as boundary,
finishDiscoveryStandaloneNexusE3SkillsBattle as finish,
advanceDiscoveryStandaloneNexusE3Skills as advance,
stepDiscoveryStandaloneNexusE3Skills as step,
discoveryStandaloneNexusE3SkillsPlayer as player,
discoveryStandaloneNexusE3SkillsPolicy as policy,
assertDiscoveryStandaloneNexusE3SkillsCustody as custody,
type DiscoveryStandaloneNexusE3SkillsOptions,
} from './fixture-discovery-standalone-nexus-e3-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
/** Consume actual physical loss/rescue choices; do not hide the later owner choice. */
function aftermath(state: Game, callers: readonly string[] = []): Game {
let next = boundary(state, callers);
for (let count = 0; count < 20 && (next.kind === 'losses' || next.kind === 'rescue'); count++) {
const d = next.game.decision!;
if (d.kind === 'battleLosses') next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', choice: 0 } }), callers);
else if (d.kind === 'sukRescue') {
const max = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === max);
next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', event: d.event, choice } }), callers);
}
}
return next.game;
}
void test('original one-native Basic/Advanced 2–6 setup trains eligible own discs and preserves supplied original hands and offers', () => {
for (const native of ['ecaz', 'moritani'] as const) for (const rules of ['basic', 'advanced'] as const)
for (const count of [2, 3, 4, 5, 6]) {
const factions: FactionId[] = [native, 'guild', 'emperor', 'fremen', 'beneGesserit', rules === 'basic' ? 'harkonnen' : 'atreides'];
const options: DiscoveryStandaloneNexusE3SkillsOptions = { native, rules, factions: factions.slice(0, count),
seatIds: factions.slice(0, count).map((f, n) => `${native}-${rules}-${count}-${n}-${f}`),
tech: count >= 3, strongholds: rules === 'advanced' };
if (native === 'ecaz' && rules === 'basic' && count % 2) { assert.throws(() => initialize(options)); continue; }
const offered = advance(initialize(options), g => g.setupStage === 'leaderSkills');
const owner = offered.players.find(p => p.faction === native)!.id;
const original = structuredClone(offered), continued = initialize({ initial: offered });
assert.deepEqual(continued, original);
const trained = complete(continued, { initial: offered });
assert.deepEqual(trained.players.map(p => p.id), original.players.map(p => p.id));
for (const p of original.players) assert.deepEqual(player(trained, p.id).hand, p.hand);
assert.ok(trained.leaderSkills!.assignments.every(a => original.leaderSkills!.offers[a.owner].cards.includes(a.skill) &&
player(original, a.owner).leaders.some(l => l.id === a.leader)));
const assignment = trained.leaderSkills!.assignments.find(a => a.owner === owner)!;
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
if (rules === 'basic') assert.equal(trained.moritaniAssassinate, undefined);
if (count === 2) {
const f = fixture({ initial: offered });
assert.equal(viewGame(f.game, owner).discoveryEntry!.owner, owner);
if (rules === 'advanced') {
assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null);
assert.equal(f.game.strongholdCards!.owners[f.claim], owner);
}
assert.throws(() => close(f), /two other real alliance seats/);
}
custody(trained);
}
});
void test('source guards reject Advanced Harkonnen, paired/mixed E3, variants, shared Duke and foreign/captured training without consuming offers', () => {
for (const native of ['ecaz', 'moritani'] as const) {
for (const other of ['harkonnen', native === 'ecaz' ? 'moritani' : 'ecaz', 'ixians', 'choam', 'richese', 'tleilaxu'] as FactionId[])
assert.throws(() => initialize({ native, factions: [native, 'guild', other] }));
assert.throws(() => initialize({ native, rules: 'basic', strongholds: true }));
const offered = initialize({ native }), owner = offered.players.find(p => p.faction === native)!.id;
const own = viewGame(offered, owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s])!;
const foreign = offered.players.find(p => p.id !== owner)!.leaders[0].id;
for (const leader of [DUKE_VIDAL_ID, foreign]) reject(offered, owner,
{ type: 'leaderSkill', event: own.offer!.event, skill, leader });
const captured = structuredClone(offered), disc = player(captured, owner).leaders.find(l => l.id === own.eligibleLeaders[0].id)!;
// Rejected counterfactual only: no captured state is accepted as played history.
disc.capturedBy = offered.players.find(p => p.id !== owner)!.id;
reject(captured, owner, { type: 'leaderSkill', event: own.offer!.event, skill, leader: disc.id });
assert.throws(() => initialize({ initial: { ...offered, ecazTreachery: true } }));
assert.throws(() => initialize({ initial: { ...offered, homeworlds: {} as NonNullable<Game['homeworlds']> } }));
custody(offered);
}
});
void test('human lobby/setup actors and first hands survive the actual Discovery reveal and pre-Storm free entry, not a redeal or a paid shipment', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const lobby = createGame(`HUMAN-${native}`, newPlayer(`human-${native}`, native, native), true, ['ecaz']);
joinGame(lobby, newPlayer('human-guild', 'Guild', 'guild')); joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen'));
const original = structuredClone(lobby), offered = initialize({ initial: lobby }), f = fixture({ initial: offered });
assert.deepEqual(lobby, original); assert.deepEqual(f.setup, offered);
assert.equal(f.owner, `human-${native}`); assert.equal(f.opponent, 'human-guild');
for (const p of offered.players) assert.deepEqual(player(f.game, p.id).hand, p.hand);
const own = viewGame(f.game, f.owner), source = player(f.game, f.owner).forces[f.source];
assert.equal(own.discoveryEntry!.owner, f.owner);
assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.before.phase, 8);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
reject(f.game, f.opponent, { type: 'decision', event: own.discoveryEntry!.event, accept: false });
reject(f.firstMentat.before, f.owner, { type: 'nexusCardChoice', turn: 1, card: null, choice: 'draw', ownRedraws: 0 });
const entered = enter(f), funds = player(f.game, f.owner).spice;
assert.equal(player(entered, f.owner).forces['cistern:0'], 3);
assert.equal(player(entered, f.owner).forces[f.source] ?? 0, source - 3);
assert.equal(player(entered, f.owner).spice, funds);
assert.equal(player(entered, f.owner).shipped, player(f.game, f.owner).shipped);
assert.deepEqual(entered.leaderSkills, f.game.leaderSkills); custody(entered);
}
});
void test('Maker losses, all votes, typed reserve ride, settled alliances and both Advanced piles precede the only native closing draw', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const f = fixture({ native, tech: true, strongholds: true }), closing = close(f);
assert.equal(player(closing.vote, f.owner).tanks, player(closing.entered, f.owner).tanks + 6);
assert.equal(player(closing.vote, f.owner).forces[f.wormSource] ?? 0, 0);
assert.equal(player(closing.vote, f.owner).forces['cistern:0'], 3);
assert.equal(closing.vote.nexusCards!.cards!.hands[f.owner], null);
reject(closing.vote, f.owner, closing.draw.step.action);
const ride = closing.ride; assert.ok(ride && f.fremen);
const rider = player(ride.before, f.fremen);
assert.equal(player(ride.after, f.fremen).reserves, rider.reserves - 2);
assert.equal(player(ride.after, f.fremen).elites!.reserves, rider.elites!.reserves - 1);
assert.equal(player(ride.after, f.fremen).forces['polar_sink:0'], 2);
assert.equal(player(ride.after, f.fremen).elites!.forces['polar_sink:0'], 1);
assert.equal(player(ride.after, f.fremen).spice, rider.spice);
assert.equal(closing.draw.before.phase, 1); assert.equal(closing.draw.before.spiceWindow, null);
assert.equal(closing.draw.before.spiceResolution, null); assert.equal(closing.draw.before.nexus, false);
assert.equal(player(closing.alliance, f.owner).ally, null);
assert.equal(player(closing.alliance, f.opponent).ally, f.fremen);
assert.equal(closing.draw.after.nexusCards!.cards!.hands[f.owner], native);
assert.equal(closing.draw.after.nexusCards!.cards!.deck.includes(native), false);
reject(closing.draw.after, f.owner, closing.draw.step.action);
assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null);
assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.owner).length, 1);
custody(closing.game);
}
});
void test('quiet native Ecaz Cunning acquires the living unclaimed or set-aside dead Duke separately from training, and rejects exceptional custody or an already-open battle', () => {
const f = fixture({ native: 'ecaz' }), closing = close(f), acquired = cunning(f, closing.game);
assert.equal(acquired.before.status, 'playing');
assert.equal(acquired.before.turn, closing.game.turn);
assert.equal(acquired.before.phase, 6);
assert.equal(acquired.before.battle, null);
assert.ok(viewGame(acquired.before, f.owner).battleChoices.length > 0,
'Physical opponents keep the original Battle chooser open before quiet Cunning.');
assert.equal(acquired.before.dukeVidal!.controller, null);
assert.equal(acquired.after.dukeVidal!.controller, f.owner);
assert.equal(acquired.after.dukeVidal!.leader.dead, false);
assert.equal(acquired.after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(acquired.after.leaderSkills, acquired.before.leaderSkills);
assert.ok(acquired.after.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
const tanks = structuredClone(acquired.before);
tanks.dukeVidal!.leader = { ...tanks.dukeVidal!.leader, dead: true, deaths: 2, usedAt: 'cistern' };
const beforeRevival = structuredClone(tanks), revived = step(tanks, acquired.step);
assert.deepEqual(tanks, beforeRevival);
assert.deepEqual(revived.dukeVidal, {
...beforeRevival.dukeVidal!,
controller: f.owner, acquiredTurn: revived.turn, source: 'ecazNexus',
leader: { ...acquired.before.dukeVidal!.leader, dead: false, deaths: 2 },
});
assert.equal(revived.dukeVidal!.leader.usedAt, undefined);
assert.equal(revived.nexusCards!.cards!.hands[f.owner], null);
assert.equal(revived.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length,
beforeRevival.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length + 1);
assert.equal(revived.nexusEcazDukeHistory!.length, (beforeRevival.nexusEcazDukeHistory?.length ?? 0) + 1);
assert.deepEqual(revived.leaderSkills, beforeRevival.leaderSkills);
custody(revived);
for (const property of ['controller', 'acquiredTurn', 'source'] as const) {
const input = structuredClone(tanks);
if (property === 'controller') input.dukeVidal!.controller = f.opponent;
else if (property === 'acquiredTurn') input.dukeVidal!.acquiredTurn = input.turn;
else input.dukeVidal!.source = 'ecazNexus';
reject(input, f.owner, acquired.step.action);
}
for (const property of ['capturedBy', 'gholaBy', 'usedAt'] as const) {
const input = structuredClone(acquired.before);
if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern';
else input.dukeVidal!.leader[property] = f.opponent;
reject(input, f.owner, acquired.step.action);
}
const battle = open(f, closing.game);
reject(battle.game, f.owner, acquired.step.action);
const revealed = reveal(battle), cleanup = aftermath(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, f.owner);
assert.equal(player(cleanup, f.owner).forces[battle.location], 5);
assert.equal(player(cleanup, f.opponent).forces[battle.location] ?? 0, 0);
assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
assert.equal(cleanup.dukeVidal!.controller, f.owner, 'The actual native trained disc was used; the separate Duke was not set aside.');
const done = finish(step(cleanup, { actor: f.owner, action: { type: 'decision', discard: [] } }));
assert.equal(player(done, f.owner).hand.some(c => c.id === battle.card), true);
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.leader)!.usedAt, 'cistern');
custody(done);
});
void test('mandatory selected-lead Occupy stays a real battle with owner-labelled commitments, native skill custody, payments, cleanup and Tech', () => {
for (const lead of ['native', 'ally'] as const) {
const f = fixture({ native: 'ecaz', factions: ['ecaz', 'guild', 'emperor'], tech: true, strongholds: true }), closing = close(f);
const acquired = cunning(f, closing.game), battle = open(f, acquired.after, { occupy: true, lead });
const choice = battle.leadChoice; assert.ok(choice);
assert.equal(choice.before.status, 'playing');
assert.equal(choice.before.turn, acquired.after.turn + 1);
assert.equal(choice.before.decision!.kind, 'ecazBattleLead'); assert.equal(choice.before.decision!.player, f.owner);
reject(choice.before, f.opponent, choice.step.action);
reject(choice.before, f.owner, { ...choice.step.action, lead: f.opponent });
assert.equal(battle.game.battle!.ecazOccupy!.lead, battle.actor);
assert.equal(battle.game.battle!.plans[battle.actor], undefined);
assert.notEqual(battle.leader, DUKE_VIDAL_ID);
if (lead === 'ally') reject(battle.game, battle.actor, { ...battle.plans[0].action, leader: f.leader });
const assignments = structuredClone(battle.game.leaderSkills!.assignments), revealed = reveal(battle);
const cleanup = aftermath(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, battle.actor);
assert.equal(player(cleanup, f.owner).forces[battle.location], 1);
assert.equal(player(cleanup, f.owner).tanks, player(revealed, f.owner).tanks + 2);
const ally = player(revealed, f.owner).ally!;
assert.equal(player(cleanup, ally).forces[battle.location], 5);
assert.equal(player(cleanup, ally).tanks, player(revealed, ally).tanks + 1);
assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
assert.equal(player(cleanup, battle.actor).spice, player(revealed, battle.actor).spice - 1);
const companion = battle.actor === f.owner ? ally : f.owner;
assert.equal(player(cleanup, companion).spice, player(revealed, companion).spice);
assert.equal(player(cleanup, f.opponent).spice, player(revealed, f.opponent).spice - 1);
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Actual winner card custody precedes optional winner Tech.');
const done = finish(step(cleanup, { actor: battle.actor, action: { type: 'decision', discard: [battle.card] } }));
assert.equal(done.discard.filter(c => c.id === battle.card).length, 1);
assert.equal(player(done, battle.actor).hand.some(c => c.id === battle.card), false);
assert.deepEqual(done.leaderSkills!.assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })),
assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })));
for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(done.techTokens![token].owner, battle.actor);
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
custody(done);
}
});
void test('Advanced Moritani after-loss assassination kills the actual unused trained disc, pays printed bounty, returns its skill and replaces only the private Traitor at actual Mentat', () => {
const f = fixture({ native: 'moritani', tech: true, strongholds: true }), closing = close(f), battle = open(f, closing.game);
assert.ok(f.target);
assert.ok(player(f.trained, f.owner).traitors.includes(f.target));
assert.ok(f.actions.some(s => s.actor === f.owner && s.action.type === 'traitor' && s.action.leader === f.target),
'The trained enemy disc is selected through the original pending Traitor offer.');
const revealed = reveal(battle), pending = aftermath(revealed);
assert.equal(pending.decision?.kind, 'moritaniAssassinate'); assert.equal(pending.decision!.player, f.owner);
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 6);
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
const originalSkill = pending.leaderSkills!.assignments.find(a => a.leader === target.id)!.skill;
assert.equal(target.dead, false); assert.equal(target.usedAt, undefined);
const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
const action: Action = { type: 'decision', event: decision.event, card: target.id };
reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader });
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
assert.equal(quote.potentialBounty, 3);
let killed = step(pending, { actor: f.owner, action });
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3);
assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false);
assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1);
// The originally dealt hand may hold a Karama, so the after-loss window legally
// offers the native special denial before the winner cleanup; decline it through
// the real current decision exactly like the printed opportunity.
if (killed.decision?.kind === 'moritaniSpecialKarama')
killed = step(killed, { actor: killed.decision.player,
action: { type: 'decision', event: killed.decision.event, decline: true } });
assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent);
assert.deepEqual(killed.techTokens, pending.techTokens);
let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } }));
for (const card of [battle.card, battle.enemyCard!]) assert.equal(done.discard.filter(c => c.id === card).length, 1);
for (const token of ownedTech(revealed.techTokens, f.owner)) assert.equal(done.techTokens![token].owner, f.opponent);
done = advance(done, g => g.decision?.kind === 'moritaniPlacement');
assert.deepEqual(done.traitorReserve, reserve.slice(1));
assert.deepEqual(player(done, f.owner).traitors, [...held.filter(c => c !== target.id), reserve[0]]);
assert.equal('replacement' in viewGame(done, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assert.equal(done.leaderSkills!.offers[f.opponent], undefined);
reject(done, f.owner, action);
const placed = cunning(f, done);
assert.equal(placed.after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm');
assert.deepEqual(placed.after.leaderSkills, done.leaderSkills);
custody(placed.after);
});
void test('normal Traitor call forfeits Advanced assassination; Basic losses never grant its power, while original Cunning remains available', () => {
const advanced = fixture({ native: 'moritani' }), closing = close(advanced);
const called = open(advanced, closing.game, { normalCall: true }), revealed = reveal(called);
assert.ok(player(advanced.trained, advanced.owner).traitors.includes(called.enemyLeader));
assert.equal(called.enemyLeader, advanced.target);
let done = aftermath(revealed, [advanced.owner]);
assert.notEqual(done.decision?.kind, 'moritaniAssassinate');
done = finish(done);
assert.equal(player(done, advanced.opponent).leaders.find(l => l.id === called.enemyLeader)!.dead, true);
const held = [...player(done, advanced.owner).traitors], reserve = [...done.traitorReserve!];
done = advance(done, g => g.decision?.kind === 'moritaniPlacement');
assert.deepEqual(player(done, advanced.owner).traitors, held); assert.deepEqual(done.traitorReserve, reserve);
custody(done);
const basic = fixture({ native: 'moritani', rules: 'basic', tech: true }), basicClose = close(basic);
const battle = open(basic, basicClose.game), basicReveal = reveal(battle), loss = aftermath(basicReveal);
assert.equal(loss.moritaniAssassinate, undefined);
assert.equal(player(loss, basic.owner).spice, player(basicReveal, basic.owner).spice);
reject(loss, basic.owner, { type: 'decision', event: 'not-earned', card: basic.target });
assert.equal(player(loss, basic.opponent).leaders.find(l => l.id === basic.target)!.dead, false);
const finished = finish(loss), placed = cunning(basic, finished);
assert.equal(placed.after.nexusCards!.cards!.hands[basic.owner], null);
assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm');
assert.equal(placed.after.moritaniAssassinate, undefined); custody(placed.after);
});
void test('all four minimal policies act for the eligible native Cunning/assassination and cleanup owner with physical consumer outcomes', () => {
const ecaz = fixture({ native: 'ecaz' }), ecazClosing = close(ecaz), ecazCunning = cunning(ecaz, ecazClosing.game);
const moritani = fixture({ native: 'moritani', tech: true }), moritaniClosing = close(moritani);
const battle = open(moritani, moritaniClosing.game), pending = aftermath(reveal(battle));
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
for (const difficulty of DIFFICULTIES) {
const ecazAction = policy(ecazCunning.before, ecaz.owner, difficulty)[0]; assert.ok(ecazAction);
const acquired = step(structuredClone(ecazCunning.before), { actor: ecaz.owner, action: ecazAction });
assert.equal(acquired.dukeVidal!.controller, ecaz.owner); assert.equal(acquired.nexusCards!.cards!.hands[ecaz.owner], null);
assert.deepEqual(acquired.leaderSkills, ecazCunning.before.leaderSkills);
const action = policy(pending, pending.decision!.player, difficulty)[0]; assert.ok(action);
let game = step(structuredClone(pending), { actor: pending.decision!.player, action });
assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true);
assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3);
// A dealt Karama may open the native denial before cleanup. Follow the
// actual owned policy choice without pinning an incidental decline shape.
if (game.decision?.kind === 'moritaniSpecialKarama') {
const karamaActor = game.decision.player;
const karama = policy(game, karamaActor, difficulty)[0]; assert.ok(karama);
game = step(game, { actor: karamaActor, action: karama });
}
if (game.decision?.kind === 'battleCards') {
const cleanupActor = game.decision.player, cleanup = policy(game, cleanupActor, difficulty)[0];
assert.ok(cleanup);
game = step(game, { actor: cleanupActor, action: cleanup });
}
game = finish(game);
assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false);
game = advance(game, g => g.decision?.kind === 'moritaniPlacement');
assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === moritani.target), false);
custody(game);
}
});