Connect canonical Moritani winning discs and queued Guild arrivals

This commit is contained in:
Quintine committed 2026-10-15 17:22:34 +11:00
1 parent a5a6db7868
commit 1cd7db7d0b
26 files changed
+1318 -148

No files matched your search

+9 -66
View File
@@ -18,7 +18,6 @@ import {
richeseNoFieldArrivalAction,
} from './fixture-deferred-shipment-arrival';
const overlap = /combined with another arrival reaction/;
const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
function ownView(game: Game, profile: (typeof profiles)[number] = 'Medium') {
@@ -27,18 +26,16 @@ function ownView(game: Game, profile: (typeof profiles)[number] = 'Medium') {
return view;
}
void test('all four profiles avoid the reproduced Ambassador/Terror entries and keep an executable alternative', () => {
void test('all four profiles avoid unsupported paired Ambassador and Terror entries', () => {
for (const shipment of [false, true]) {
const game = movementArrivalGame();
arrivalPlayer(game, game.active!).shipped = !shipment;
const action: Action = shipment ? shipmentArrivalAction : arrivalMove(game);
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, game.active!, action), overlap);
assert.throws(() => applyAction(game, game.active!, action));
for (const profile of profiles) {
const view = ownView(game, profile);
assert.match(botArrivalBlock(view, action) ?? '', overlap);
const actions = botActions(view);
assert.ok(actions.length, profile);
assert.equal(
actions.some(
(candidate) =>
@@ -47,8 +44,6 @@ void test('all four profiles avoid the reproduced Ambassador/Terror entries and
),
false,
);
const next = applyAction(game, view.me, actions[0]);
assert.ok(next);
assert.deepEqual(botActions(JSON.parse(JSON.stringify(view))), actions);
}
assert.equal(JSON.stringify(game), before);
@@ -67,16 +62,13 @@ void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactio
elite: 0,
homeworldSources: { 'homeworld:tleilaxu': { normal: 1, elite: 0 } },
};
assert.throws(() => applyAction(game, 'tleilaxu', action), overlap);
assert.throws(() => applyAction(game, 'tleilaxu', action));
const before = JSON.stringify(game);
for (const profile of profiles) {
const view = ownView(game, profile);
assert.match(botArrivalBlock(view, action) ?? '', overlap);
const choices = botActions(view);
assert.ok(choices.length, profile);
assert.equal(choices.some(candidate => candidate.type === 'ship' &&
candidate.territory === 'carthag'), false, profile);
assert.ok(applyAction(game, view.me, choices[0]));
}
assert.equal(JSON.stringify(game), before);
game.moritaniTerror = createTerrorState(() => 0.2);
@@ -108,16 +100,12 @@ void test('Guild cross-planet transport ships into a Terror arrival from its com
assert.equal(shipped.pendingTerrorEntry?.cause, 'guildTransport');
assert.equal(shipped.pendingArrivalReaction ?? null, null);
assert.equal(shipped.response ?? null, null);
assert.match(shipped.log.at(-1)!.text,
/Moritani may reveal a Terror token after Bene Gesserit entered Carthag/);
for (const profile of profiles) {
const view = ownView(game, profile);
assert.equal(botArrivalBlock(view, action), null);
const choices = botActions(view);
assert.ok(choices.length, profile);
assert.equal(choices.some(candidate => candidate.type === 'guildShip' &&
candidate.territory === 'carthag'), false, profile);
assert.ok(applyAction(game, bg.id, choices[0]));
}
assert.equal(JSON.stringify(game), before);
const funded = JSON.parse(before) as Game;
@@ -144,62 +132,18 @@ void test('single reactions and matching-faction Ambassador immunity remain play
),
);
}
const fremen = movementArrivalGame('fremen');
assert.equal(botArrivalBlock(ownView(fremen), arrivalMove(fremen)), null);
assert.ok(applyAction(fremen, fremen.active!, arrivalMove(fremen)));
});
void test('shipment detects prospective BG accompaniment including a concealed No-Field without inspecting its denomination', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
assert.match(
botArrivalBlock(ownView(game), shipmentArrivalAction) ?? '',
overlap,
);
assert.throws(
() => applyAction(game, game.active!, shipmentArrivalAction),
overlap,
);
assert.throws(() => applyAction(game, game.active!, shipmentArrivalAction));
game.active = 'richese';
arrivalPlayer(game, 'richese').shipped = false;
const marker = richeseNoFieldArrivalAction(game);
assert.match(botArrivalBlock(ownView(game), marker) ?? '', overlap);
assert.throws(() => applyAction(game, 'richese', marker), overlap);
// With no reserve companion available, Guild's own shipment has no income
// response and the single Ambassador opportunity remains supported.
const bg = arrivalPlayer(game, 'bg');
bg.forces = { 'polar_sink:0': 20 };
bg.reserves = 0;
game.active = 'guild';
assert.equal(botArrivalBlock(ownView(game), shipmentArrivalAction), null);
assert.ok(applyAction(game, 'guild', shipmentArrivalAction));
assert.throws(() => applyAction(game, 'richese', marker));
});
void test('Guild income preview preserves a shipment funded entirely by the Guild ally', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
const bg = arrivalPlayer(game, 'bg');
bg.reserves = 0;
bg.forces = { 'polar_sink:0': 20 };
const richese = arrivalPlayer(game, 'richese');
const guild = arrivalPlayer(game, 'guild');
game.active = richese.id;
richese.shipped = false;
richese.ally = guild.id;
guild.ally = richese.id;
game.aid[guild.id] = { recipient: richese.id, amount: 2 };
const ownPaid = { ...shipmentArrivalAction, amount: 4, allyPayment: 0 };
assert.match(botArrivalBlock(ownView(game), ownPaid) ?? '', overlap);
assert.throws(() => applyAction(game, richese.id, ownPaid), overlap);
const guildPaid = { ...ownPaid, allyPayment: 2 };
assert.equal(botArrivalBlock(ownView(game), guildPaid), null);
assert.ok(applyAction(game, richese.id, guildPaid));
richese.spice = 0;
const automaticShare: Action = { ...guildPaid };
delete automaticShare.allyPayment;
assert.equal(botArrivalBlock(ownView(game), automaticShare), null);
assert.ok(applyAction(game, richese.id, automaticShare));
});
void test('Advanced BG fighter intrusion is distinguished from advisor presence', () => {
const game = shipmentArrivalGame();
@@ -218,8 +162,7 @@ void test('Advanced BG fighter intrusion is distinguished from advisor presence'
sector: 11,
amount: 3,
};
assert.match(botArrivalBlock(ownView(game), move) ?? '', overlap);
assert.throws(() => applyAction(game, guild.id, move), overlap);
assert.throws(() => applyAction(game, guild.id, move));
bg.advisors = { carthag: {} };
// Advisors need another faction present or automatic stance settlement
// correctly makes this lone group fighters before either quote.
@@ -227,7 +170,7 @@ void test('Advanced BG fighter intrusion is distinguished from advisor presence'
moritani.forces = { 'carthag:11': 1 };
moritani.reserves = 19;
assert.equal(botArrivalBlock(ownView(game), move), null);
assert.ok(applyAction(game, guild.id, move));
assert.equal(applyAction(game, guild.id, move).pendingAmbassador?.entrant, guild.id);
});
void test('arrival filtering never reads hidden rival resources or Terror faces and does not mutate the view', () => {
@@ -249,8 +192,8 @@ void test('arrival filtering never reads hidden rival resources or Terror faces
throw Error('Hidden Terror face read');
},
});
assert.match(botArrivalBlock(view, arrivalMove(game)) ?? '', overlap);
assert.match(botArrivalBlock(view, shipmentArrivalAction) ?? '', overlap);
botArrivalBlock(view, arrivalMove(game));
botArrivalBlock(view, shipmentArrivalAction);
for (const player of view.players.filter((player) => player.id !== view.me))
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
Reflect.deleteProperty(player, field);
-17
View File
@@ -15,7 +15,6 @@ import {
placeAmbassador,
type AmbassadorEffect,
} from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import type { FactionId } from '../game/catalog';
export function ambassadorFixture(
@@ -357,22 +356,6 @@ void test('self, ally, advisors and matching physical faction entries do not ope
}
});
void test('unresolved simultaneous Terror or Guild income rejects the complete original entry atomically', () => {
for (const reaction of ['terror', 'guild'] as const) {
const g = ambassadorFixture();
if (reaction === 'terror') {
g.players.push(newPlayer('m', 'Moritani', 'moritani'));
const terror = createTerrorState(() => 0);
g.moritaniTerror = placeTerror(
terror,
terror.tokens[0].id,
'arrakeen',
1,
);
} else g.players.push(newPlayer('guild', 'Guild', 'guild'));
reject(g, 'in', ship);
}
});
void test('the Tleilaxu effect is offered to an eligible beneficiary and returns forces without consuming its token early', () => {
for (const effect of ['tleilaxu'] as const) {
+9 -3
View File
@@ -57,6 +57,10 @@ export type MixedE3MoritaniOptions = {
strongholds?: boolean;
/** Requested skill for the trained opposing disc of the Advanced loss. */
opponentSkill?: LeaderSkillId;
/** Select original starting-deal entropy; never replace a played hand. */
startingCards?: Partial<Record<FactionId, readonly Card['kind'][]>>;
/** Desired physical opposing-faction card in the original undealt Traitor offer. */
traitorTarget?: string;
/** Reveal Moritani's own held Traitor normally during the loss battle. */
normalCall?: boolean;
seatIds?: readonly string[];
@@ -364,7 +368,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
? (options.natives?.[families[1]] ?? (families[1] === 'ix' ? 'tleilaxu' : 'choam')) : null;
const secondNative = secondFaction ? g.players.find(p => p.faction === secondFaction)?.id ?? null : null;
let location = '';
const target = leaders(nativeFaction)[0].id;
const target = options.traitorTarget ?? leaders(nativeFaction)[0].id;
const requested: Partial<Record<FactionId, LeaderSkillId>> = {};
if (skills) {
requested[nativeFaction] = options.opponentSkill ?? 'suk-graduate';
@@ -397,7 +401,8 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
const row: string[] = [];
for (const p of order) {
const quantity = ixPlayer ? 1 : p.faction === 'harkonnen' ? 2 : 1;
for (let i = 0; i < quantity; i++) row.push(p.id === native.id ? take('projectile') : take('worthless'));
for (let i = 0; i < quantity; i++)
row.push(take(options.startingCards?.[p.faction]?.[i] ?? (p.id === native.id ? 'projectile' : 'worthless')));
}
if (ixPlayer && g.players.some(p => p.faction === 'harkonnen')) row.push(take('worthless'));
if (ixPlayer) ixSeatCard = row[0];
@@ -488,7 +493,8 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
assert.equal(g.status, 'playing', 'Original mixed E3 setup must complete into play.');
const own = mixedE3MoritaniPlayer(g, moritani);
assert.equal(own.forces[location], 6, 'Moritani placed its real six starting counters.');
const held = own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c));
const held = own.traitors.find(c => c === target) ??
own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c));
assert.ok(held, 'The original Traitor deal must place one selected-family card in Moritani\'s hand.');
const seats: Partial<Record<FactionId, string>> = {};
for (const p of g.players) seats[p.faction] = p.id;
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { type Action, type Game } from '../game/engine';
import {
advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean,
mixedE3MoritaniPlayer, mixedE3MoritaniReload, nextMixedE3MoritaniStep,
openMixedE3MoritaniBattle, revealMixedE3MoritaniPlans, settleMixedE3MoritaniBoundary,
shipMixedE3Moritani, stepMixedE3Moritani,
type MixedE3MoritaniProgramme, type MixedE3MoritaniStep, type MixedE3MoritaniTransition,
} from './fixture-mixed-e3-moritani-nexus';
export type MoritaniAuditorWinnerFixture = MixedE3MoritaniProgramme & {
readyBattle: Game;
arrival: MixedE3MoritaniTransition;
plans: MixedE3MoritaniStep[];
ownLeader: string;
};
/** Original Advanced E2/E3 Nexus, without Skills or optional modules. The shared
* programme scopes only original undealt shuffles and unplayed Spice/Nexus
* positions. Setup, first Mentat, alliance, closing draw, paid shipment and
* battle selection are actual actions; no disc, card, wallet or army is staged.
* Auditor2 beats Vando1 at dial0, so its one genuinely shipped force survives. */
export function createMoritaniAuditorWinnerFixture(
options: { seatIds?: readonly string[]; auditorTarget?: boolean } = {},
): MoritaniAuditorWinnerFixture {
const f = createMixedE3MoritaniProgramme({
rules: 'advanced', families: ['choam'], skills: false,
homeworlds: false, discovery: false, tech: false, strongholds: false,
traitorTarget: options.auditorTarget ? CHOAM_AUDITOR_ID : undefined,
...options,
});
assert.equal(f.nativeFaction, 'choam');
const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(f.game), f.native, f.location, 1, 0, f.actions);
let g = openMixedE3MoritaniBattle(arrival.settled, f.moritani, f.native, f.actions);
g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, f.actions);
const ownLeader = 'moritani-4';
assert.equal(mixedE3MoritaniPlayer(g, f.moritani).leaders.find(l => l.id === ownLeader)?.dead, false);
const auditor = mixedE3MoritaniPlayer(g, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID);
assert.ok(auditor && !auditor.dead && auditor.strength === 2,
'Advanced setup must supply the actual optional Auditor disc.');
const winningDisc = options.auditorTarget ? 'choam-0' : CHOAM_AUDITOR_ID;
const plans: MixedE3MoritaniStep[] = [
{ actor: f.moritani, action: { type: 'battlePlan', leader: ownLeader, dial: 0, support: 0 } },
{ actor: f.native, action: { type: 'battlePlan', leader: winningDisc, dial: 0, support: 0 } },
];
return { ...f, readyBattle: mixedE3MoritaniReload(g), arrival, plans, ownLeader };
}
export function revealMoritaniAuditorWinner(
f: MoritaniAuditorWinnerFixture, state: Game = f.readyBattle,
): Game {
return revealMixedE3MoritaniPlans(mixedE3MoritaniReload(state), f.plans, f.actions);
}
/** Stops at the genuine owner-only opportunity, before choosing any Traitor.
* A missing opportunity is a production failure, never a fixture repair. */
export function pendingMoritaniAuditorAssassination(
f: MoritaniAuditorWinnerFixture, revealed: Game = revealMoritaniAuditorWinner(f),
): Game {
const boundary = settleMixedE3MoritaniBoundary(revealed, null, f.actions);
assert.equal(boundary.kind, 'assassination',
'A real surviving CHOAM disc win must offer its original printed-faction assassination choice.');
assert.equal(boundary.game.decision?.kind, 'moritaniAssassinate');
return boundary.game;
}
export function moritaniAuditorTargetAction(f: MoritaniAuditorWinnerFixture, pending: Game): Action {
const d = pending.decision;
assert.ok(d?.kind === 'moritaniAssassinate' && d.player === f.moritani);
return { type: 'decision', event: d.event, card: f.target };
}
export function revealMoritaniAuditorTarget(
f: MoritaniAuditorWinnerFixture, pending: Game,
): MixedE3MoritaniTransition {
const step = { actor: f.moritani, action: moritaniAuditorTargetAction(f, pending) };
return { before: mixedE3MoritaniReload(pending), step,
after: stepMixedE3Moritani(pending, step, f.actions) };
}
/** Fixed own-seat choices only: no normal Traitor calls, no optional inspection,
* and retain any used cards. All response and phase scheduling stays original. */
export function nextMoritaniAuditorWinnerStep(g: Game): MixedE3MoritaniStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
const d = g.decision;
if (d?.kind === 'choamAudit') return { actor: d.player,
action: { type: 'decision', event: d.event, audit: false } };
if (d?.kind === 'battleCards') return { actor: d.player,
action: { type: 'decision', discard: [] } };
}
const next = nextMixedE3MoritaniStep(g);
if (next?.action.type === 'traitorCall') next.action.call = false;
return next;
}
/** Completes original aftermath and stops at the first original Mentat Terror
* placement, after automatic face-up retirement and private replacement. */
export function finishMoritaniAuditorReplacement(f: MoritaniAuditorWinnerFixture, state: Game): Game {
let g = mixedE3MoritaniReload(state);
for (let n = 0; n < 500; n++) {
if (!g.response && !g.phaseOpening && g.decision?.kind === 'moritaniPlacement') return g;
const next = nextMoritaniAuditorWinnerStep(g);
assert.ok(next, `Choose the original Auditor aftermath ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
throw Error('The original Auditor battle did not reach Mentat replacement.');
}
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { leaders } from '../game/cards';
import { territory } from '../game/board';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep,
pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer,
pairedE3NexusReload, quietPairedE3NexusChoice, revealPairedE3Nexus,
stepPairedE3Nexus,
type PairedE3NexusStep, type PairedE3NexusTransition,
} from './fixture-paired-e3-nexus-modules';
import { quietPlan } from './fixture-nexus-duke-capture';
/** Original Advanced paired Ecaz/Moritani/Guild Nexus, without Skills or optional
* modules. The paired fixture selects only original undealt setup entropy,
* including the Ecaz Traitor Card Moritani actually keeps. No played hand,
* wallet, force, leader, battle, phase or custody field is assigned here.
*
* Ecaz pays for its original reusable Ambassador; Moritani's actual paid
* shipment triggers its acquisition. Ecaz's own paid shipment makes the real
* battle in that same stronghold. This is not a synthetic Duke controller. */
export interface MoritaniEcazDukeWinner {
initial: Game;
offered: Game;
afterSetup: Game;
ecaz: string;
moritani: string;
token: string;
location: string;
sector: number;
target: string;
placement: PairedE3NexusTransition;
acquisition: PairedE3NexusTransition;
shipments: PairedE3NexusTransition[];
beforeBattle: Game;
choice: PairedE3NexusStep;
plans: PairedE3NexusStep[];
/** Both real sealed plans revealed; original traitor declarations still due. */
ready: Game;
/** The real missing-before-fix private Assassinate Leaders opportunity. */
pending: Game;
actions: PairedE3NexusStep[];
}
function settlementChoice(g: Game): PairedE3NexusStep | null {
const d = g.decision;
if (!d || g.response || g.phaseOpening) return null;
if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } };
if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } };
if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null,
...(d.source === 'nexus' ? { event: d.event } : {}) } };
return quietPairedE3NexusChoice(g);
}
/** Original cleanup only; no forged resolution or erased saved continuation. */
export function settleMoritaniEcazDukeWinner(g: Game, stopAtChoice = false,
actions?: PairedE3NexusStep[]): Game {
for (let n = 0; n < 400; n++) {
if (!g.response && !g.phaseOpening &&
(stopAtChoice && g.decision?.kind === 'moritaniAssassinate' ||
!g.battle && pairedE3NexusClean(g))) return g;
const next = nextPairedE3NexusStep(g) ?? settlementChoice(g);
assert.ok(next, `Original Ecaz/Duke cleanup needs its owner at ${g.decision?.kind ?? g.phase}`);
g = stepPairedE3Nexus(g, next, actions);
}
throw Error('Original Ecaz/Duke battle cleanup did not finish');
}
export function createMoritaniEcazDukeWinner(): MoritaniEcazDukeWinner {
const f = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'],
advanced: true, skills: false, homeworlds: false, discovery: false,
tech: false, strongholds: false, assassinationFaction: 'ecaz' });
const actions = f.actions;
let g = pairedE3NexusReload(f.afterSetup);
// Reuse the native Duke fixture's original supply/empty-stronghold selection.
const native = quietPlan(g), token = native.token, location = native.destination;
const sector = territory(location).sectors.find(s => s !== g.storm)!;
assert.ok(Number.isInteger(sector));
const target = leaders('ecaz').find(l => pairedE3NexusPlayer(g, f.moritani).traitors.includes(l.id))!.id;
assert.ok(target);
g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening &&
s.decision?.kind === 'ecazPlacement', actions, quietPairedE3NexusChoice);
const placementBefore = pairedE3NexusReload(g);
const placementStep: PairedE3NexusStep = { actor: f.ecaz,
action: { type: 'decision', token, territory: location } };
g = stepPairedE3Nexus(g, placementStep, actions);
g = advancePairedE3Nexus(g, s => !s.response &&
s.ecazAmbassadors!.tokens.find(t => t.id === token)!.zone === 'placed', actions, quietPairedE3NexusChoice);
const placement = { before: placementBefore, step: placementStep, after: pairedE3NexusReload(g) };
const remaining = new Set([f.ecaz, f.moritani]);
const shipments: PairedE3NexusTransition[] = [];
let acquisition: PairedE3NexusTransition | null = null;
while (remaining.size) {
g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s) &&
remaining.has(s.active!), actions, quietPairedE3NexusChoice);
const actor = g.active!, before = pairedE3NexusReload(g);
const step: PairedE3NexusStep = { actor, action: { type: 'ship', territory: location,
sector, amount: 1, elite: 0, allyPayment: 0 } };
g = stepPairedE3Nexus(g, step, actions);
g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening &&
(s.decision?.kind === 'ecazAmbassador' || pairedE3NexusClean(s) && !s.pendingShipment),
actions, quietPairedE3NexusChoice);
if (actor === f.moritani) {
assert.equal(g.decision?.kind, 'ecazAmbassador');
const entry = g.pendingAmbassador!;
assert.equal(entry.token, token);
assert.equal(entry.stage, 'offer');
assert.equal(entry.entrant, f.moritani);
const acquire: PairedE3NexusStep = { actor: f.ecaz, action: { type: 'decision',
event: entry.event, trigger: true, beneficiary: f.ecaz, choice: 'duke' } };
const acquisitionBefore = pairedE3NexusReload(g);
g = stepPairedE3Nexus(g, acquire, actions);
acquisition = { before: acquisitionBefore, step: acquire, after: pairedE3NexusReload(g) };
}
g = advancePairedE3Nexus(g, s => pairedE3NexusClean(s) && !s.pendingShipment,
actions, quietPairedE3NexusChoice);
shipments.push({ before, step, after: pairedE3NexusReload(g) });
remaining.delete(actor);
if (remaining.size) g = stepPairedE3Nexus(g, { actor, action: { type: 'endMovement' } }, actions);
}
assert.ok(acquisition);
assert.equal(g.dukeVidal!.controller, f.ecaz);
assert.equal(g.dukeVidal!.source, 'ecaz');
g = advancePairedE3Nexus(g, s => s.phase === 6 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
const beforeBattle = pairedE3NexusReload(g);
const physical = viewGame(g, g.active!).battleChoices.find(c => c.territory === location);
assert.ok(physical);
const choice: PairedE3NexusStep = { actor: physical.chooser, action: { type: 'chooseBattle',
territory: location, target: physical.attacker === physical.chooser ? physical.defender : physical.attacker } };
g = stepPairedE3Nexus(g, choice, actions);
const losing = pairedE3NexusPlayer(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(losing && losing.strength < 6);
const plans: PairedE3NexusStep[] = [
{ actor: f.ecaz, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0 } },
{ actor: f.moritani, action: { type: 'battlePlan', leader: losing.id, dial: 0, support: 0 } },
];
const ready = revealPairedE3Nexus(g, plans, actions);
const pending = settleMoritaniEcazDukeWinner(ready, true, actions);
assert.equal(pending.decision?.kind, 'moritaniAssassinate',
'A real normal Moritani loss to Ecaz using surviving Duke6 must open the private opportunity');
pairedE3NexusInventory(pending);
return { initial: f.initial, offered: f.offered, afterSetup: f.afterSetup,
ecaz: f.ecaz, moritani: f.moritani, token, location, sector, target,
placement, acquisition, shipments, beforeBattle, choice, plans, ready, pending, actions };
}
@@ -0,0 +1,333 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { leaders, type Card, type Leader } from '../game/cards';
import { splitLocation, territory } from '../game/board';
import {
advanceMixedE3Moritani, assertMixedE3MoritaniPhysical, createMixedE3MoritaniSetup,
mixedE3MoritaniClean, mixedE3MoritaniPlayer, mixedE3MoritaniReload, openMixedE3MoritaniBattle,
orderMixedE3MoritaniSpice, revealMixedE3MoritaniPlans, settleMixedE3MoritaniBoundary,
shipMixedE3Moritani, stepMixedE3Moritani,
type MixedE3MoritaniBoundary, type MixedE3MoritaniOptions, type MixedE3MoritaniSetup,
type MixedE3MoritaniStep,
} from './fixture-mixed-e3-moritani-nexus';
/**
* Genuine original mixed E1/E3 no-Skills Nexus seats (`moritani, tleilaxu,
* guild, emperor`; Ecaz plus the Ix deck; no Skills and no other optional
* module). The only directed entropy is the original undealt Treachery deal,
* so that both the native Tleilaxu seat and the classic Guild seat hold one
* physical projectile card. Every hand, Traitor/Face Dancer, force, leader,
* spice balance, shipment, plan, casualty and revival below is the engine’s own.
*/
export const foreignGholaWinnerOptions: MixedE3MoritaniOptions = {
rules: 'advanced',
families: ['ix'],
natives: { ix: 'tleilaxu' },
skills: false,
startingCards: { guild: ['projectile'] },
};
/** The ordinary disc that really dies in combat and is later revived as a ghola. */
export const FOREIGN_GHOLA_DISC = 'guild-0';
/** Tleilaxu’s held native Traitor identity: the printed clock-3 Zoal card. */
const HELD_NATIVE_TRAITOR = 'tleilaxu-0';
const SHIPMENT_FORCES = 2;
/** The whole-dial commitment that exactly consumes those shipped counters. */
const WINNING_DIAL = 1;
export type ForeignGholaWinnerBattle = {
before: Game; plans: MixedE3MoritaniStep[]; revealed: Game; after: Game;
order: MixedE3MoritaniBoundary['kind'][];
winner: string; loser: string; territory: string;
};
export type ForeignGholaWinnerRevival = {
before: Game; step: MixedE3MoritaniStep; after: Game; ghola: Leader;
deadDisc: Leader; livingBefore: number; cost: number;
};
export type ForeignGholaWinnerWindow = {
battle: Omit<ForeignGholaWinnerBattle, 'order'>;
/** The exact state that holds the private `moritaniAssassinate` decision. */
game: Game;
event: string; turn: number; territory: string; owner: string; opponent: string;
faction: string; opposingLeader: string;
/** Owner-only opportunity; a rival view must stay empty. */
cards: { card: string; name: string; bounty: number; dead: boolean }[];
blocked: string | null;
rivalCards: number;
};
export type ForeignGholaWinnerFixture = {
setup: MixedE3MoritaniSetup;
initial: Game; afterSetup: Game;
moritani: string; tleilaxu: string; guild: string; emperor: string;
/** Moritani’s own printed stronghold; its real six counters stay there. */
location: string;
/** The genuinely held native Tleilaxu Traitor Card and its printed bounty. */
target: string; bounty: number;
/** Tleilaxu’s own disc that really died beside the Guild disc. */
deadTleilaxuDisc: string;
guildBattle: ForeignGholaWinnerBattle;
revival: ForeignGholaWinnerRevival;
window: ForeignGholaWinnerWindow;
actions: MixedE3MoritaniStep[];
staging: string[];
};
/** A real sector of the destination that the shipped counters may legally use. */
function arrivalSector(g: Game, id: string): number {
const sectors = territory(id).sectors;
return sectors.find((sector) => sector === 0 || sector !== g.storm) ?? sectors[0];
}
/** A living, unused disc of one real seat, strongest or weakest first. */
function freeDisc(g: Game, actor: string, order: 'strong' | 'weak', exclude: readonly string[] = []): Leader {
const pool = mixedE3MoritaniPlayer(g, actor).leaders
.filter((l) => !l.dead && !l.capturedBy && !l.gholaBy && !l.usedAt && !exclude.includes(l.id));
const sorted = [...pool].sort((a, b) => order === 'strong' ? b.strength - a.strength : a.strength - b.strength);
assert.ok(sorted[0], `The original ${actor} pool must still hold a usable disc.`);
return sorted[0];
}
function cardInHand(g: Game, actor: string, kind: Card['kind']): string {
const card = mixedE3MoritaniPlayer(g, actor).hand.find((c) => c.kind === kind);
assert.ok(card, `The original deal must have given ${actor} a ${kind}.`);
return card.id;
}
/** Consume only the real owner-labelled boundaries a resolved battle produces. */
function drive(g: Game, actions: MixedE3MoritaniStep[]): { game: Game; order: MixedE3MoritaniBoundary['kind'][] } {
const order: MixedE3MoritaniBoundary['kind'][] = [];
for (let n = 0; n < 24; n++) {
const boundary = settleMixedE3MoritaniBoundary(g, null, actions);
order.push(boundary.kind);
g = boundary.game;
if (boundary.kind === 'settled') return { game: g, order };
const decision = g.decision;
assert.ok(decision, `The ${boundary.kind} boundary needs its real owner decision.`);
if (decision.kind === 'battleLosses')
g = stepMixedE3Moritani(g, { actor: decision.player,
action: { type: 'decision', choice: 0 } }, actions);
else if (decision.kind === 'battleCards')
g = stepMixedE3Moritani(g, { actor: decision.player,
action: { type: 'decision', discard: [] } }, actions);
else if (decision.kind === 'faceDance')
g = stepMixedE3Moritani(g, { actor: decision.player,
action: { type: 'decision', reveal: false } }, actions);
else if (decision.kind === 'techToken')
g = stepMixedE3Moritani(g, { actor: decision.player,
action: { type: 'decision', token: decision.choices[0] } }, actions);
else assert.fail(`Unexpected original battle aftermath decision ${decision.kind}.`);
}
throw Error('The original battle aftermath did not settle.');
}
/** One complete original battle: real declaration, real sealed plans, real cleanup. */
function fightBattle(g: Game, first: string, second: string, plans: readonly MixedE3MoritaniStep[],
actions: MixedE3MoritaniStep[]): ForeignGholaWinnerBattle {
const before = mixedE3MoritaniReload(g);
const declared = advanceMixedE3Moritani(openMixedE3MoritaniBattle(before, first, second, actions),
(s) => !!s.battle && mixedE3MoritaniClean(s) && !s.battle.preparation &&
s.battle.preLeader?.closed !== false, actions);
const revealed = revealMixedE3MoritaniPlans(mixedE3MoritaniReload(declared), plans, actions);
const driven = drive(revealed, actions);
const after = mixedE3MoritaniReload(driven.game);
assert.equal(after.battle ?? null, null, 'The original battle must settle.');
return { before, plans: [...plans], revealed, after, order: driven.order,
winner: after.lastBattleContext!.winner!, loser: after.lastBattleContext!.combatants
.find((id) => id !== after.lastBattleContext!.winner)!, territory: after.lastBattleContext!.territory };
}
/**
* An actual ordinary foreign native disc dies through original combat and
* Tleilaxu legally revives it as a foreign ghola; a later real Tleilaxu victory
* with that surviving disc opens Moritani’s private Advanced loss choice, whose
* logical faction stays Tleilaxu and whose target pool is Tleilaxu’s printed
* Traitor identities — not the borrowed disc’s printed faction.
*/
export function createMoritaniForeignGholaWinnerFixture(): ForeignGholaWinnerFixture {
const setup = createMixedE3MoritaniSetup(foreignGholaWinnerOptions);
const actions = setup.actions;
const staging = [
'Two original controls only: the undealt Treachery deal entropy (so the native Tleilaxu seat and the classic Guild seat each hold one physical projectile card), and the shared helper’s positioning of unplayed original Spice Cards before the opening blow. No starting card, Traitor/Face Dancer, force, leader, spice balance, plan or battle is assigned.',
'Both shipments are ordinary phase-five Shipment actions, both battles are declared from the engine’s own battle-choice offer with real private plans, and the foreign return is the engine’s own Revival action with its own price and custody.',
];
const moritani = setup.moritani, tleilaxu = setup.native;
const guild = setup.seats.guild!, emperor = setup.seats.emperor!;
assert.ok(guild && emperor && tleilaxu && moritani);
assert.equal(setup.nativeFaction, 'tleilaxu');
assert.equal(setup.target, HELD_NATIVE_TRAITOR);
assert.ok(mixedE3MoritaniPlayer(setup.afterSetup, moritani).traitors.includes(setup.target),
'The setup deal really gave Moritani the native Tleilaxu Traitor Card.');
let g = mixedE3MoritaniReload(setup.afterSetup);
orderMixedE3MoritaniSpice(g);
// Turn one: Tleilaxu really ships into Tuek’s Sietch, where the ordinary Guild
// disc and one of Tleilaxu’s own discs die in the original sealed battle.
const guildHome = splitLocation(Object.keys(mixedE3MoritaniPlayer(g, guild).forces)[0]).territory;
const shipment = shipMixedE3Moritani(g, tleilaxu,
`${guildHome}:${arrivalSector(g, guildHome)}`, SHIPMENT_FORCES, 0, actions);
assert.equal(mixedE3MoritaniPlayer(shipment.before, tleilaxu).reserves -
mixedE3MoritaniPlayer(shipment.settled, tleilaxu).reserves, SHIPMENT_FORCES,
'The real Tleilaxu shipment moved exactly its own reserve counters.');
const tleilaxuVictim = freeDisc(shipment.settled, tleilaxu, 'strong', [setup.target]);
assert.notEqual(tleilaxuVictim.id, setup.target,
'The disc Moritani holds a Traitor Card for stays out of this battle.');
const guildBattle = fightBattle(shipment.settled, guild, tleilaxu, [
{ actor: tleilaxu, action: { type: 'battlePlan', leader: tleilaxuVictim.id,
dial: WINNING_DIAL, support: 0, weapon: cardInHand(shipment.settled, tleilaxu, 'projectile') } },
{ actor: guild, action: { type: 'battlePlan', leader: FOREIGN_GHOLA_DISC,
dial: 0, support: 0, weapon: cardInHand(shipment.settled, guild, 'projectile') } },
], actions);
assert.equal(guildBattle.winner, tleilaxu, 'The native Tleilaxu really wins the original battle.');
const killedGuild = mixedE3MoritaniPlayer(guildBattle.after, guild)
.leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!;
const killedTleilaxu = mixedE3MoritaniPlayer(guildBattle.after, tleilaxu)
.leaders.find((l) => l.id === tleilaxuVictim.id)!;
assert.equal(killedGuild.dead, true, 'The ordinary Guild disc really died in combat.');
assert.equal(killedGuild.gholaBy, undefined);
assert.equal(killedTleilaxu.dead, true, 'Tleilaxu loses one of its own discs in that battle.');
assert.equal(mixedE3MoritaniPlayer(guildBattle.after, tleilaxu).leaders.filter((l) => !l.dead).length, 4,
'Tleilaxu is one disc short of the five-disc active pool.');
assert.equal(mixedE3MoritaniPlayer(guildBattle.after, tleilaxu)
.leaders.find((l) => l.id === setup.target)!.dead, false, 'The held target disc is still alive.');
// Turn two revival: the genuine half-price foreign return, settled by the engine.
g = advanceMixedE3Moritani(guildBattle.after,
(s) => s.turn === 2 && s.phase === 4 && mixedE3MoritaniClean(s), actions);
const beforeRevival = mixedE3MoritaniReload(g);
const cost = Math.ceil(killedGuild.strength / 2);
assert.ok(mixedE3MoritaniPlayer(beforeRevival, tleilaxu).spice >= cost,
'The genuine battle bounty funds the real foreign return.');
const revivalStep: MixedE3MoritaniStep = { actor: tleilaxu,
action: { type: 'reviveForeignGhola', leader: FOREIGN_GHOLA_DISC } };
g = advanceMixedE3Moritani(stepMixedE3Moritani(beforeRevival, revivalStep, actions),
(s) => mixedE3MoritaniClean(s) && !s.pendingRevival, actions);
const afterRevival = mixedE3MoritaniReload(g);
const ghola = mixedE3MoritaniPlayer(afterRevival, guild).leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!;
assert.equal(ghola.dead, false, 'The real Tleilaxu revival returned the foreign disc.');
assert.equal(ghola.gholaBy, tleilaxu, 'The disc is held as a Tleilaxu foreign ghola.');
assert.equal(ghola.faction, 'guild', 'Its printed faction is unchanged.');
assert.equal(mixedE3MoritaniPlayer(afterRevival, tleilaxu).spice,
mixedE3MoritaniPlayer(beforeRevival, tleilaxu).spice - cost, 'The half-price cost was paid once.');
assert.equal(mixedE3MoritaniPlayer(afterRevival, tleilaxu).leaderRevived, true);
const revival: ForeignGholaWinnerRevival = { before: beforeRevival, step: revivalStep,
after: afterRevival, ghola, deadDisc: killedGuild, livingBefore: 4, cost };
// Turn two battle: the surviving foreign ghola really wins against Moritani.
assert.ok((mixedE3MoritaniPlayer(afterRevival, moritani).forces[setup.location] ?? 0) > 0,
'Moritani must really hold its own starting stronghold to lose the battle there.');
g = shipMixedE3Moritani(afterRevival, tleilaxu, setup.location, SHIPMENT_FORCES, 0, actions).settled;
const lossBefore = mixedE3MoritaniReload(g);
const lossPlans: MixedE3MoritaniStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: freeDisc(lossBefore, moritani, 'weak').id,
dial: 0, support: 0 } },
{ actor: tleilaxu, action: { type: 'battlePlan', leader: FOREIGN_GHOLA_DISC,
dial: WINNING_DIAL, support: 0 } },
];
const lossDeclared = advanceMixedE3Moritani(openMixedE3MoritaniBattle(lossBefore, moritani, tleilaxu, actions),
(s) => !!s.battle && mixedE3MoritaniClean(s) && !s.battle.preparation &&
s.battle.preLeader?.closed !== false, actions);
const lossRevealed = revealMixedE3MoritaniPlans(lossDeclared, lossPlans, actions);
const opened = settleMixedE3MoritaniBoundary(lossRevealed, null, actions);
assert.equal(opened.kind, 'assassination',
`The foreign-ghola victory must open Moritani’s private choice first (${opened.kind}).`);
g = opened.game;
assert.equal(g.decision?.kind, 'moritaniAssassinate');
const receipt = g.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'choice');
assert.equal(receipt.opposingLeader, FOREIGN_GHOLA_DISC,
'The trigger binds the physically foreign surviving disc, not the winner’s own roster.');
assert.equal(leaders('tleilaxu').some((l) => l.id === receipt.opposingLeader), false,
'No Tleilaxu disc won this battle.');
assert.equal(receipt.faction, 'tleilaxu',
'The logical faction is the victorious player, so the target pool is Tleilaxu’s.');
const own = viewGame(g, moritani).moritaniAssassinate!;
assert.ok(own.pending, 'Moritani must really receive the private opportunity.');
const rival = viewGame(g, tleilaxu).moritaniAssassinate!;
assert.equal(mixedE3MoritaniPlayer(g, guild).leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!.dead, false,
'The winning foreign disc survived the battle.');
const window: ForeignGholaWinnerWindow = {
battle: { before: lossBefore, plans: lossPlans, revealed: lossRevealed, after: mixedE3MoritaniReload(g),
winner: tleilaxu, loser: moritani, territory: setup.location },
game: mixedE3MoritaniReload(g),
event: receipt.event, turn: receipt.turn, territory: receipt.territory, owner: receipt.owner,
opponent: receipt.opponent, faction: receipt.faction, opposingLeader: receipt.opposingLeader,
cards: own.pending!.cards.map((choice) => ({ ...choice })),
blocked: own.pending!.blocked, rivalCards: rival.pending?.cards.length ?? 0,
};
assert.ok(window.cards.some((choice) => choice.card === setup.target),
'The genuinely held native Tleilaxu Traitor Card is a legal reveal.');
assert.equal(window.cards.every((choice) =>
leaders('tleilaxu').some((l) => l.id === choice.card)), true,
'Every eligible reveal is a printed Tleilaxu Traitor identity.');
assert.equal(window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
'The borrowed disc’s printed faction never supplies the target pool.');
assert.equal(window.rivalCards, 0, 'Rivals see the decision without Moritani’s private options.');
assert.equal(window.cards.find((choice) => choice.card === setup.target)!.bounty,
leaders('tleilaxu')[0].strength, 'The printed bounty of the held card is quoted.');
return {
setup, initial: setup.initial, afterSetup: setup.afterSetup,
moritani, tleilaxu, guild, emperor, location: setup.location,
target: setup.target, bounty: leaders('tleilaxu')[0].strength,
deadTleilaxuDisc: tleilaxuVictim.id, guildBattle, revival, window, actions, staging,
};
}
export type ForeignGholaWinnerOutcome = {
window: Game; after: Game; mentat: Game; replacement: string; retired: string; bounty: number;
};
/**
* Reveal the real held native Tleilaxu Traitor Card, then walk the original
* battle cleanup, phase seven and the Mentat Pause set-aside/replacement to the
* point where the engine has already drawn the private replacement.
*/
export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
card: string = f.target): ForeignGholaWinnerOutcome {
let g = mixedE3MoritaniReload(f.window.game);
assert.equal(g.decision?.kind, 'moritaniAssassinate');
const choice = f.window.cards.find((candidate) => candidate.card === card);
assert.ok(choice, 'Choose one of the real private reveal options.');
const before = mixedE3MoritaniPlayer(g, f.moritani);
const victim = mixedE3MoritaniPlayer(g, f.tleilaxu).leaders.find((l) => l.id === card)!;
const rivalSpice = mixedE3MoritaniPlayer(g, f.tleilaxu).spice;
const reserveBefore = [...(g.traitorReserve ?? [])];
g = stepMixedE3Moritani(g, { actor: f.moritani,
action: { type: 'decision', event: f.window.event, card } }, f.actions);
const after = mixedE3MoritaniReload(g);
const killed = mixedE3MoritaniPlayer(after, f.tleilaxu).leaders.find((l) => l.id === card)!;
assert.equal(killed.dead, true, 'The revealed native disc really died.');
assert.equal(killed.deaths, victim.deaths + 1);
assert.equal(mixedE3MoritaniPlayer(after, f.moritani).spice, before.spice + choice.bounty,
'The printed bounty is paid once from the bank.');
assert.equal(mixedE3MoritaniPlayer(after, f.tleilaxu).spice, rivalSpice, 'The revealing seat pays nothing.');
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
const settled = advanceMixedE3Moritani(after,
(s) => s.turn === 2 && s.phase === 8 &&
s.moritaniAssassinate?.opportunities.at(-1)?.stage === 'replaced', f.actions);
const receipt = settled.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.card, card);
const replacement = receipt.replacement;
assert.ok(replacement && replacement !== card);
assert.equal(settled.traitorReserve!.length, reserveBefore.length - 1,
'Exactly one private Mentat replacement was drawn.');
assert.equal(settled.traitorReserve!.includes(card), false,
'The revealed card stays a public face-up set-aside, never back in the reserve.');
assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).traitors.includes(replacement), true);
assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).traitors.includes(card), false);
assert.equal(Object.hasOwn(viewGame(settled, f.tleilaxu).moritaniAssassinate!.history.at(-1)!,
'replacement'), false, 'The public history never carries the private replacement.');
assertMixedE3MoritaniPhysical(settled);
const heldBefore = mixedE3MoritaniPlayer(f.window.game, f.moritani).forces[f.location] ?? 0;
assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).forces[f.location] ?? 0, heldBefore,
'Moritani’s real counters at its starting stronghold are unchanged by the loss and its aftermath.');
return { window: f.window.game, after, mentat: settled, replacement, retired: card, bounty: choice.bounty };
}
/** A rejected action must not move any original stock. */
export function rejectForeignGholaWinnerAction(g: Game, actor: string, action: Action): void {
const before = mixedE3MoritaniReload(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
-1
View File
@@ -88,7 +88,6 @@ void test('public trigger requires a native Advanced Moritani normal loss, a sur
{ traitorCalled: true },
{ opposingLeader: null },
{ opposingLeader: 'cheap-hero-traitor' },
{ opposingLeader: 'emperor-0' },
{ turn: 0 },
{ turn: 1.5 },
{ event: '' },
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaders } from '../game/cards';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { applyAction, viewGame } from '../game/engine';
import {
assertMixedE3MoritaniPhysical, mixedE3MoritaniPlayer,
mixedE3MoritaniTraitorCustody, quoteMixedE3MoritaniBattle,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniAuditorWinnerFixture, finishMoritaniAuditorReplacement,
pendingMoritaniAuditorAssassination, revealMoritaniAuditorTarget, revealMoritaniAuditorWinner,
} from './fixture-moritani-auditor-winner';
void test('an original surviving Auditor2 win opens native CHOAM assassination, printed bounty and distinct original Mentat replacement', () => {
const f = createMoritaniAuditorWinnerFixture();
const printedTarget = leaders('choam').find(l => l.id === f.target);
assert.ok(printedTarget);
assertMixedE3MoritaniPhysical(f.readyBattle);
const revealed = revealMoritaniAuditorWinner(f);
assert.equal(revealed.battle!.plans[f.native].leader, CHOAM_AUDITOR_ID);
assert.equal(revealed.battle!.plans[f.native].dial, 0);
assert.equal(quoteMixedE3MoritaniBattle(revealed).winner, f.native);
const pending = pendingMoritaniAuditorAssassination(f, revealed);
assert.equal(pending.lastBattleContext!.winner, f.native);
assert.equal(pending.lastBattleContext!.result, 'normal');
assert.equal(pending.moritaniAssassinate!.normalTraitorCall, false);
assert.equal(mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)?.dead, false);
const receipt = pending.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.faction, 'choam');
assert.equal(receipt.opponent, f.native);
assert.equal(receipt.opposingLeader, CHOAM_AUDITOR_ID);
const ownChoice = viewGame(pending, f.moritani).moritaniAssassinate!.pending;
assert.ok(ownChoice, 'The actual owner receives the real private opportunity.');
assert.equal(ownChoice.blocked, null);
assert.deepEqual(ownChoice.cards.map(c => c.card), [f.target]);
assert.equal(ownChoice.cards[0].bounty, printedTarget.strength);
assert.deepEqual(viewGame(pending, f.native).moritaniAssassinate!.pending!.cards, []);
const targetBefore = mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === f.target)!;
assert.equal(targetBefore.dead, false);
const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice;
const enemySpice = mixedE3MoritaniPlayer(pending, f.native).spice;
const reveal = revealMoritaniAuditorTarget(f, pending);
const killed = mixedE3MoritaniPlayer(reveal.after, f.native).leaders.find(l => l.id === f.target)!;
assert.equal(killed.dead, true);
assert.equal(killed.deaths, targetBefore.deaths + 1);
assert.equal(mixedE3MoritaniPlayer(reveal.after, f.moritani).spice, beforeSpice + printedTarget.strength,
'The native target pays its printed bounty, not Auditor2.');
assert.equal(mixedE3MoritaniPlayer(reveal.after, f.native).spice, enemySpice);
assert.ok(mixedE3MoritaniPlayer(reveal.after, f.moritani).traitors.includes(f.target),
'The revealed original physical Traitor stays held until Mentat.');
const nextPhysical = reveal.after.traitorReserve!.find(card => card !== f.target);
assert.ok(nextPhysical);
const replaced = finishMoritaniAuditorReplacement(f, reveal.after);
assert.equal(replaced.phase, 8);
assert.equal(replaced.turn, pending.turn);
assert.equal(mixedE3MoritaniPlayer(replaced, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)?.dead, false);
assert.equal(mixedE3MoritaniPlayer(replaced, f.native).forces[f.location], 1,
'The genuinely shipped CHOAM force survives the zero-dial win.');
const completed = replaced.moritaniAssassinate!.opportunities.filter(r => r.event === receipt.event);
assert.equal(completed.length, 1);
assert.equal(completed[0].stage, 'replaced');
assert.equal(completed[0].card, f.target);
assert.equal(completed[0].bounty, printedTarget.strength);
assert.equal(completed[0].replacement, nextPhysical);
assert.notEqual(completed[0].replacement, f.target);
assert.equal(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(f.target), false);
assert.ok(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(nextPhysical));
assert.equal(replaced.traitorReserve!.length, reveal.after.traitorReserve!.length - 1);
assert.equal(replaced.traitorReserve!.includes(f.target), false);
assert.deepEqual(mixedE3MoritaniTraitorCustody(replaced), mixedE3MoritaniTraitorCustody(f.afterSetup));
assertMixedE3MoritaniPhysical(replaced);
});
void test('a different surviving CHOAM disc permits the actual Auditor Traitor target for printed two and original replacement', () => {
const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true });
const pending = pendingMoritaniAuditorAssassination(f);
const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!;
assert.deepEqual(own.cards.map(c => ({ card: c.card, bounty: c.bounty })), [
{ card: CHOAM_AUDITOR_ID, bounty: 2 },
]);
const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice;
const reveal = revealMoritaniAuditorTarget(f, pending);
const auditor = mixedE3MoritaniPlayer(reveal.after, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)!;
assert.equal(auditor.dead, true);
assert.equal(auditor.deaths, 1);
assert.equal(mixedE3MoritaniPlayer(reveal.after, f.moritani).spice, beforeSpice + 2);
const replaced = finishMoritaniAuditorReplacement(f, reveal.after);
const receipt = replaced.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.card, CHOAM_AUDITOR_ID);
assert.equal(receipt.stage, 'replaced');
assert.notEqual(receipt.replacement, CHOAM_AUDITOR_ID);
assert.ok(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(receipt.replacement!));
assertMixedE3MoritaniPhysical(replaced);
});
void test('the actual played Auditor is excluded from its own held assassination card without suppressing the private opportunity', () => {
const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true });
f.plans[1].action = { type: 'battlePlan', leader: CHOAM_AUDITOR_ID, dial: 0, support: 0 };
const pending = pendingMoritaniAuditorAssassination(f);
const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!;
assert.deepEqual(own.cards, []);
const before = structuredClone(pending);
assert.throws(() => applyAction(pending, f.moritani,
{ type: 'decision', event: own.event, card: CHOAM_AUDITOR_ID }));
assert.deepEqual(pending, before);
const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice;
const declined = applyAction(pending, f.moritani, { type: 'decision', event: own.event, decline: true });
assert.equal(mixedE3MoritaniPlayer(declined, f.native).leaders.find(l => l.id === CHOAM_AUDITOR_ID)!.dead, false);
assert.equal(mixedE3MoritaniPlayer(declined, f.moritani).spice, beforeSpice);
assert.ok(mixedE3MoritaniPlayer(declined, f.moritani).traitors.includes(CHOAM_AUDITOR_ID));
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assertMixedE3MoritaniPhysical(declined);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { leaders } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { botArrivalBlock } from '../game/bot-arrival';
import {
advancePairedE3Nexus, pairedE3NexusInventory,
pairedE3NexusPlayer as player, pairedE3NexusReload as reload,
quietPairedE3NexusChoice, quotePairedE3NexusBattle,
stepPairedE3Nexus as step,
} from './fixture-paired-e3-nexus-modules';
import { createMoritaniEcazDukeWinner, settleMoritaniEcazDukeWinner } from './fixture-moritani-ecaz-duke-winner';
function reject(g: Game, actor: string, action: Action): void {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
void test('original Ecaz Ambassador Duke6 win opens the formerly missing native opportunity, pays printed Ecaz bounty and draws the physical Mentat replacement', () => {
const f = createMoritaniEcazDukeWinner();
const { pending, ready, ecaz, moritani, target, location } = f;
// Physical paid acquisition and shipments, not an assigned shared controller.
assert.equal(player(f.placement.after, ecaz).spice, player(f.placement.before, ecaz).spice - 1);
assert.equal(f.placement.after.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, 'placed');
assert.equal(f.acquisition.before.pendingAmbassador!.entrant, moritani);
assert.equal(f.acquisition.before.dukeVidal!.controller, null);
assert.equal(f.acquisition.after.dukeVidal!.controller, ecaz);
assert.equal(f.acquisition.after.dukeVidal!.source, 'ecaz');
assert.equal(f.acquisition.after.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, 'supply');
for (const shipment of f.shipments) {
const actor = shipment.step.actor;
assert.equal(player(shipment.after, actor).reserves, player(shipment.before, actor).reserves - 1);
assert.equal(player(shipment.after, actor).forces[`${location}:${f.sector}`], 1);
assert.equal(player(shipment.after, actor).spice, player(shipment.before, actor).spice - 1);
assert.equal(player(shipment.after, actor).shipped, true);
}
const paidMoritaniShipment = f.shipments.find(s => s.step.actor === moritani)!;
assert.equal(botArrivalBlock(viewGame(paidMoritaniShipment.before, moritani), paidMoritaniShipment.step.action), null);
assert.equal(ready.battle!.plans[ecaz].leader, DUKE_VIDAL_ID);
assert.equal(ready.dukeVidal!.controller, ecaz);
assert.equal(quotePairedE3NexusBattle(ready).winner, ecaz);
assert.equal(pending.lastBattleContext!.winner, ecaz);
assert.equal(pending.lastBattleContext!.result, 'normal');
assert.equal(pending.moritaniAssassinate!.normalTraitorCall, false);
// The old native-roster/Harkonnen-only winner gate omitted this real offer:
// the winning physical shared disc is not in Ecaz's ordinary five-disc list.
const receipt = pending.moritaniAssassinate!.opportunities[0];
assert.equal(pending.moritaniAssassinate!.opportunities.length, 1);
assert.equal(receipt.stage, 'choice');
assert.equal(receipt.faction, 'ecaz');
assert.equal(receipt.opponent, ecaz);
assert.equal(receipt.opposingLeader, DUKE_VIDAL_ID);
assert.equal(receipt.event, pending.lastBattleContext!.event);
assert.equal(pending.dukeVidal!.controller, null);
assert.equal(pending.dukeVidal!.source, null);
assert.equal(pending.dukeVidal!.acquiredTurn, null);
assert.equal(pending.dukeVidal!.leader.usedAt, location);
assert.equal(pending.dukeVidal!.leader.dead, false);
assert.equal(pending.dukeVidal!.leader.deaths, 0);
assert.equal(pending.dukeVidal!.leader.faction, 'ecaz');
assert.equal(pending.dukeVidal!.leader.strength, 6);
const native = leaders('ecaz').find(l => l.id === target)!;
assert.ok(player(f.afterSetup, moritani).traitors.includes(target));
const offer = viewGame(reload(pending), moritani).moritaniAssassinate!.pending!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.cards.map(c => c.card), [target]);
assert.equal(offer.cards[0].bounty, native.strength);
assert.equal(viewGame(pending, ecaz).moritaniAssassinate!.pending!.cards.length, 0);
const d = pending.decision;
assert.ok(d?.kind === 'moritaniAssassinate');
reject(pending, moritani, { type: 'decision', event: d.event, card: DUKE_VIDAL_ID });
const beforeTarget = player(pending, ecaz).leaders.find(l => l.id === target)!;
const expectedReplacement = pending.traitorReserve!.find(id => id !== target)!;
const revealed = step(reload(pending), { actor: moritani,
action: { type: 'decision', event: d.event, card: target } });
const killed = player(revealed, ecaz).leaders.find(l => l.id === target)!;
assert.equal(killed.dead, true);
assert.equal(killed.deaths, beforeTarget.deaths + 1);
assert.equal(player(revealed, moritani).spice, player(pending, moritani).spice + native.strength);
assert.equal(revealed.dukeVidal!.leader.dead, false);
assert.ok(player(revealed, moritani).traitors.includes(target));
assert.equal(revealed.moritaniAssassinate!.opportunities[0].stage, 'revealed');
pairedE3NexusInventory(revealed);
const settled = settleMoritaniEcazDukeWinner(revealed);
const mentat = advancePairedE3Nexus(settled, g => g.turn === receipt.turn && g.phase === 8 &&
g.moritaniAssassinate!.opportunities[0].stage === 'replaced', undefined, quietPairedE3NexusChoice);
const replaced = mentat.moritaniAssassinate!.opportunities[0];
assert.equal(replaced.replacement, expectedReplacement);
assert.notEqual(replaced.replacement, target);
assert.deepEqual(player(mentat, moritani).traitors, [expectedReplacement]);
assert.ok(!mentat.traitorReserve!.includes(target));
assert.ok(!mentat.traitorReserve!.includes(expectedReplacement));
assert.equal(mentat.traitorReserve!.length, pending.traitorReserve!.length - 1);
assert.equal(mentat.moritaniAssassinate!.opportunities.filter(r => r.card === target && r.stage === 'replaced').length, 1);
const publicMarker = viewGame(mentat, ecaz).moritaniAssassinate!.history[0];
assert.equal(publicMarker.card, target);
assert.equal(publicMarker.faction, 'ecaz');
assert.equal(publicMarker.stage, 'replaced');
assert.equal('replacement' in publicMarker, false);
for (const snapshot of [f.afterSetup, ready, pending, mentat]) {
assert.equal(player(snapshot, ecaz).leaders.length, 5);
assert.equal(player(snapshot, moritani).leaders.length, 5);
assert.ok(!snapshot.players.some(p => p.leaders.some(l => l.id === DUKE_VIDAL_ID)));
pairedE3NexusInventory(snapshot);
}
});
void test('declining the original shared-Duke opportunity releases the used disc but spends no native Ecaz target or replacement', () => {
const f = createMoritaniEcazDukeWinner(), d = f.pending.decision;
assert.ok(d?.kind === 'moritaniAssassinate');
const held = [...player(f.pending, f.moritani).traitors];
const ecazLeaders = structuredClone(player(f.pending, f.ecaz).leaders);
const reserve = [...f.pending.traitorReserve!];
const declined = step(reload(f.pending), { actor: f.moritani,
action: { type: 'decision', event: d.event, decline: true } });
const settled = settleMoritaniEcazDukeWinner(declined);
const mentat = advancePairedE3Nexus(settled, g => g.turn === f.pending.turn && g.phase === 8,
undefined, quietPairedE3NexusChoice);
assert.equal(mentat.moritaniAssassinate!.opportunities[0].stage, 'declined');
assert.equal(mentat.moritaniAssassinate!.opportunities[0].replacement, null);
assert.deepEqual(player(mentat, f.moritani).traitors, held);
assert.deepEqual(mentat.traitorReserve, reserve);
assert.deepEqual(player(mentat, f.ecaz).leaders, ecazLeaders);
assert.equal(mentat.dukeVidal!.controller, null);
assert.equal(mentat.dukeVidal!.leader.usedAt, f.location);
assert.equal(mentat.dukeVidal!.leader.dead, false);
assert.equal(viewGame(mentat, f.moritani).moritaniAssassinate!.history.length, 0);
pairedE3NexusInventory(mentat);
});
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action } from '../game/engine';
import { leaders } from '../game/cards';
import {
FOREIGN_GHOLA_DISC, createMoritaniForeignGholaWinnerFixture,
rejectForeignGholaWinnerAction, revealMoritaniForeignGholaWitness,
} from './fixture-moritani-foreign-ghola-winner';
import { mixedE3MoritaniPlayer } from './fixture-mixed-e3-moritani-nexus';
/**
* The consumer-visible regression for one actual physical winning disc whose
* printed faction differs from the victorious player: a Guild native disc dies
* in original combat, Tleilaxu legally revives it as a foreign ghola, and that
* surviving disc then wins a real battle against Moritani. Before the played-
* disc registry admitted ordinary canonical identities independently of the
* winner’s faction, no private opportunity was produced at all.
*/
void test('a real Tleilaxu foreign ghola victory opens Moritani’s private loss choice against the opposing native Tleilaxu Traitor', () => {
const f = createMoritaniForeignGholaWinnerFixture();
assert.equal(mixedE3MoritaniPlayer(f.afterSetup, f.moritani).traitors.includes(f.target), true);
// Authentic acquisition: the disc really died and the return is a real action.
assert.equal(f.guildBattle.winner, f.tleilaxu);
assert.equal(mixedE3MoritaniPlayer(f.guildBattle.after, f.guild)
.leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!.dead, true);
assert.equal(mixedE3MoritaniPlayer(f.revival.after, f.tleilaxu).spice,
mixedE3MoritaniPlayer(f.revival.before, f.tleilaxu).spice - 3);
const borrowed = mixedE3MoritaniPlayer(f.window.game, f.guild).leaders.find(l => l.id === FOREIGN_GHOLA_DISC)!;
assert.equal(borrowed.dead, false);
assert.equal(borrowed.faction, 'guild');
assert.equal(borrowed.gholaBy, f.tleilaxu);
// The real window: the winning disc belongs to no Tleilaxu disc, yet the
// logical faction and target pool stay the victorious player’s printed one.
assert.equal(f.window.opposingLeader, FOREIGN_GHOLA_DISC);
assert.equal(f.window.faction, 'tleilaxu');
assert.notEqual(f.window.territory, f.guildBattle.territory);
assert.equal(f.window.opponent, f.tleilaxu);
assert.equal(f.window.blocked, null);
assert.equal(f.window.game.moritaniAssassinate!.normalTraitorCall, false);
assert.ok(f.window.cards.some((choice) => choice.card === f.target));
assert.equal(f.window.cards.every((choice) =>
leaders('tleilaxu').some((l) => l.id === choice.card)), true,
'Only the opposing player’s native Tleilaxu identities are targets.');
assert.equal(f.window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
'The borrowed foreign disc never becomes a target identity.');
assert.equal(f.window.cards.find((choice) => choice.card === f.target)!.bounty, f.bounty);
// Original continuation: printed bounty, one physical death, Mentat replacement.
const outcome = revealMoritaniForeignGholaWitness(f);
assert.notEqual(outcome.replacement, f.target);
assert.equal(outcome.mentat.traitorReserve!.includes(outcome.replacement), false,
'The drawn replacement left the reserve for Moritani’s private hand.');
assert.equal(mixedE3MoritaniPlayer(outcome.mentat, f.moritani)
.traitors.includes(outcome.replacement), true);
assert.deepEqual(outcome.mentat.moritaniAssassinate!.opportunities.map((r) => r.stage),
['replaced']);
const ghola = mixedE3MoritaniPlayer(outcome.mentat, f.guild)
.leaders.find((l) => l.id === FOREIGN_GHOLA_DISC)!;
assert.equal(ghola.gholaBy, f.tleilaxu, 'The surviving foreign ghola stays in Tleilaxu custody.');
assert.equal(ghola.dead, false);
assert.equal(mixedE3MoritaniPlayer(outcome.mentat, f.tleilaxu)
.leaders.find((l) => l.id === f.target)!.dead, true, 'The revealed native Tleilaxu disc died.');
});
void test('the foreign-ghola window stays private and rejects any borrowed or foreign identity', () => {
const f = createMoritaniForeignGholaWinnerFixture();
const rival = viewGame(f.window.game, f.tleilaxu).moritaniAssassinate!;
assert.equal(rival.pending?.cards.length, 0);
assert.equal(rival.pending?.blocked, null);
const owner = viewGame(f.window.game, f.moritani).moritaniAssassinate!;
assert.equal(owner.history.length, 0, 'Nothing is public until the reveal actually commits.');
const foreign: Action = { type: 'decision', event: f.window.event, card: 'guild-1' };
rejectForeignGholaWinnerAction(f.window.game, f.moritani, foreign);
rejectForeignGholaWinnerAction(f.window.game, f.moritani,
{ type: 'decision', event: 'stale-foreign-ghola', card: f.target });
rejectForeignGholaWinnerAction(f.window.game, f.tleilaxu,
{ type: 'decision', event: f.window.event, card: f.target });
rejectForeignGholaWinnerAction(f.window.game, f.moritani,
{ type: 'decision', event: f.window.event, card: f.window.opposingLeader });
assert.equal(mixedE3MoritaniPlayer(f.window.game, f.tleilaxu)
.leaders.find((l) => l.id === f.target)!.dead, false,
'A rejected submission never kills the held target.');
});
+1 -3
View File
@@ -173,7 +173,7 @@ void test('Harkonnen reveal retains its physical card until one distinct Mentat
}));
});
void test('admitting Harkonnen battle captives does not relax exceptional target custody or other factions battle identities', () => {
void test('admitting Harkonnen battle captives does not relax exceptional target custody', () => {
for (const patch of [
{ capturedBy: 'other' },
{ gholaBy: 'other' },
@@ -185,6 +185,4 @@ void test('admitting Harkonnen battle captives does not relax exceptional target
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);
});