105 lines
5.3 KiB
TypeScript
105 lines
5.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import type { Action, Game } from '../game/engine';
|
|
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
|
import {
|
|
advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean,
|
|
mixedE3MoritaniReload, nextMixedE3MoritaniStep, settleMixedE3MoritaniBoundary,
|
|
stageMixedE3MoritaniLoss, stepMixedE3Moritani,
|
|
} from './fixture-mixed-e3-moritani-nexus';
|
|
import type {
|
|
MixedE3MoritaniBattle, MixedE3MoritaniProgramme, MixedE3MoritaniStep, MixedE3MoritaniTransition,
|
|
} from './fixture-mixed-e3-moritani-nexus';
|
|
|
|
export type MoritaniHeroReplacementFixture = MixedE3MoritaniProgramme & {
|
|
battle: MixedE3MoritaniBattle;
|
|
pending: Game;
|
|
reveal: MixedE3MoritaniTransition;
|
|
/** Immediately after the actual native target reveal, before battle cleanup. */
|
|
revealed: Game;
|
|
/** Original Collection boundary, before the automatic Mentat replacement. */
|
|
preMentat: Game;
|
|
};
|
|
|
|
/** Fixed own-seat aftermath choices: retain used cards and choose a legal
|
|
* physical casualty option. Never calls a normal Traitor or stages custody. */
|
|
export function nextMoritaniHeroReplacementStep(g: Game): MixedE3MoritaniStep | null {
|
|
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
|
const d = g.decision;
|
|
if (d?.kind === 'battleLosses') {
|
|
const elite = Math.max(...d.options.map(option => option.elite));
|
|
return { actor: d.player, action: { type: 'decision',
|
|
choice: d.options.findIndex(option => option.elite === elite) } };
|
|
}
|
|
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;
|
|
}
|
|
|
|
export function moritaniHeroTargetAction(f: Pick<MoritaniHeroReplacementFixture, 'moritani' | 'target'>,
|
|
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 };
|
|
}
|
|
|
|
/** Original no-Skills Advanced E1/E3 play with native Ixians and classic seats.
|
|
* The shared producer selects only original undealt shuffle entropy, placing
|
|
* the single physical Ix Hero Traitor first in the original remaining reserve.
|
|
* There is no Tleilaxu seat or final Face Dancer reshuffle. Shared programme
|
|
* Spice/Nexus ordering conserves unplayed cards; no played Traitor reserve,
|
|
* hand, leader, wallet, army, battle or phase is edited here.
|
|
*
|
|
* Deliberately stops BEFORE the formerly failing Mentat transition, so the
|
|
* same builder supports failing-before API proof and actual GameTable play. */
|
|
export function createMoritaniHeroReplacementFixture(
|
|
options: { seatIds?: readonly string[] } = {},
|
|
): MoritaniHeroReplacementFixture {
|
|
const f = createMixedE3MoritaniProgramme({
|
|
rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false,
|
|
homeworlds: false, discovery: false, tech: false, strongholds: false,
|
|
initialReserveTraitors: [CHEAP_HERO_TRAITOR], ...options,
|
|
});
|
|
assert.equal(f.nativeFaction, 'ixians');
|
|
assert.equal(f.afterSetup.traitorReserve![0], CHEAP_HERO_TRAITOR);
|
|
f.staging.push('Original undealt Traitor shuffle entropy places the canonical Ix Cheap Hero Traitor first in the remaining reserve; no played reserve reorder.');
|
|
const battle = stageMixedE3MoritaniLoss(f, f.game);
|
|
const boundary = settleMixedE3MoritaniBoundary(battle.revealed, null, f.actions);
|
|
assert.equal(boundary.kind, 'assassination');
|
|
const pending = mixedE3MoritaniReload(boundary.game);
|
|
const step = { actor: f.moritani, action: moritaniHeroTargetAction(f, pending) };
|
|
const revealed = stepMixedE3Moritani(mixedE3MoritaniReload(pending), step, f.actions);
|
|
const reveal = { before: mixedE3MoritaniReload(pending), step,
|
|
after: mixedE3MoritaniReload(revealed) };
|
|
let g = mixedE3MoritaniReload(revealed);
|
|
for (let n = 0; n < 240; n++) {
|
|
if (!g.battle && mixedE3MoritaniClean(g)) break;
|
|
const next = nextMoritaniHeroReplacementStep(g);
|
|
assert.ok(next, `Choose the original Ixians aftermath ${g.decision?.kind ?? g.phase}.`);
|
|
g = stepMixedE3Moritani(g, next, f.actions);
|
|
}
|
|
assert.ok(!g.battle && mixedE3MoritaniClean(g));
|
|
const preMentat = advanceMixedE3Moritani(g,
|
|
state => state.phase === 7 && mixedE3MoritaniClean(state), f.actions);
|
|
assert.equal(preMentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
|
|
assert.equal(preMentat.traitorReserve![0], CHEAP_HERO_TRAITOR);
|
|
return { ...f, battle, pending, reveal, revealed, preMentat };
|
|
}
|
|
|
|
/** Runs the original phase transition and automatic replacement, stopping at
|
|
* the first genuine Mentat Terror-placement control. May also start directly
|
|
* after the target reveal for GameTable/API continuation. */
|
|
export function finishMoritaniHeroReplacement(f: MoritaniHeroReplacementFixture,
|
|
state: Game = f.preMentat): 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 = nextMoritaniHeroReplacementStep(g);
|
|
assert.ok(next, `Choose the original Hero-replacement continuation ${g.decision?.kind ?? g.phase}.`);
|
|
g = stepMixedE3Moritani(g, next, f.actions);
|
|
}
|
|
throw Error('The original Ixians battle did not reach Mentat replacement.');
|
|
}
|