Files
Dune/tests/homeworld-ecaz-duke-captive-engine.test.ts

168 lines
8.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DUKE_VIDAL_ID, dukeController, retireHomeworldDukeTenure } from '../game/duke-vidal';
import { quoteHomeworldEcazDuke, releaseCapturedHomeworldDuke } from '../game/homeworld-ecaz-duke';
import {
createHomeworldEcazDukeCaptiveFixture, expireEcazDukeCaptive,
useExpiredEcazDukeCaptive, controlledEcazDukeCaptiveDeparture,
actEcazDukeCaptive as act, ecazDukeCaptivePlayer as p,
ecazDukeCaptiveInventory as inventory,
type HomeworldEcazDukeCaptiveFixture,
} from './fixture-homeworld-ecaz-duke-captive';
let earned: HomeworldEcazDukeCaptiveFixture | undefined;
const fixture = () => earned ??= createHomeworldEcazDukeCaptiveFixture();
const reload = (g: Game): Game => structuredClone(g);
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game,actor,action));
assert.deepEqual(game,before);
}
function disc(game: Game) {
assert.equal(game.dukeVidal!.leader.id,DUKE_VIDAL_ID);
assert.equal(game.players.flatMap(p => p.leaders).filter(l => l.id === DUKE_VIDAL_ID).length,0);
assert.deepEqual(inventory(game),fixture().source.originalInventory);
}
void test('native occupied-Ecaz capture starts with original Harkonnen, actual World lease and unused-Duke lottery', () => {
const f = fixture(), source = f.source;
assert.match(f.disclosure,/Original public Advanced.*no installed occupation, lease or capture receipt/);
assert.equal(source.initial.players.length,5);
assert.equal(source.initial.players.find(p => p.id === f.captor)!.faction,'harkonnen');
assert.equal(p(source.afterSetup,f.captor).forces['carthag:11'],10);
assert.equal(p(source.afterSetup,f.captor).hand.length,2);
assert.equal(source.originalInventory.length,33);
assert.equal(p(source.duneShipment.after,f.owner).forces['sietch_tabr:14'],2);
assert.equal(source.secondEntrant!.steps[0].actor,f.captor);
assert.equal(source.qualification.player,f.owner);
assert.equal(source.qualification.cause,'sole');
assert.equal(f.beforeBattle.dukeVidal!.controller,f.owner);
assert.equal(f.beforeBattle.dukeVidal!.source,'homeworldEcaz');
assert.equal(f.beforeBattle.dukeVidal!.leader.usedAt,undefined);
assert.ok(Object.values(f.plans).every(plan => plan.leader !== f.dukeId));
assert.ok(f.candidates.includes(f.dukeId));
assert.equal(f.selected.decision?.kind === 'capturedLeader' && f.selected.decision.leader,f.dukeId);
assert.equal(f.game.dukeVidal!.leader.capturedBy,f.captor);
assert.equal(dukeController(f.game.dukeVidal!),f.captor);
assert.deepEqual(f.game.dukeVidal!.captureVisibility,{controller:f.owner,acquiredTurn:2,source:'homeworldEcaz'});
assert.equal(f.game.dukeVidal!.leader.usedAt,undefined);
assert.equal(f.sourceCount,f.sourceActions.length);
assert.equal(f.captureActions.filter(step => step.action.type === 'battlePlan').length,2);
assert.equal(f.captureActions.filter(step => step.action.type === 'decision' && step.action.accept === true).length,1);
disc(f.game);
});
void test('actual next-turn expiry retires occupied-Ecaz underlay while Harkonnen keeps the same unused living captive', () => {
const f = fixture(), expired = expireEcazDukeCaptive(f), duke = expired.game.dukeVidal!;
assert.equal(expired.game.turn,3);
assert.ok(expired.actions.length > 0);
assert.equal(duke.controller,null);
assert.equal(duke.acquiredTurn,null);
assert.equal(duke.source,null);
assert.deepEqual(duke.captureVisibility,{controller:null,acquiredTurn:null,source:null});
assert.equal(duke.leader.capturedBy,f.captor);
assert.equal(duke.leader.concealed!.controller,f.owner);
assert.equal(duke.leader.dead,false);
assert.equal(duke.leader.deaths,f.game.dukeVidal!.leader.deaths);
assert.equal(duke.leader.usedAt,undefined);
assert.equal(dukeController(duke),f.captor);
assert.deepEqual(expired.game.homeworldEcazDukeGrants,f.game.homeworldEcazDukeGrants);
const offer = quoteHomeworldEcazDuke(expired.game);
assert.equal(offer.owner,f.owner);
assert.match(offer.blocked!,/captur|conceal|custody/i);
assert.deepEqual(retireHomeworldDukeTenure(duke,expired.game.turn),duke);
disc(expired.game);
});
void test('real later captive-Duke battle use returns the expired shared disc set aside after a paid native entry', () => {
const f = fixture(), expired = expireEcazDukeCaptive(f);
const result = useExpiredEcazDukeCaptive(f,expired.game), game = result.game, duke = game.dukeVidal!;
assert.ok(result.actions.some(step => step.actor === f.owner && step.action.type === 'ship' &&
step.action.territory === f.territory && step.action.amount === 1));
assert.ok(result.actions.some(step => step.actor === f.captor && step.action.type === 'battlePlan' &&
step.action.leader === f.dukeId));
assert.equal(result.beforeBattle.dukeVidal!.leader.capturedBy,f.captor);
assert.equal(game.lastBattleContext!.winner,f.captor);
assert.equal(duke.leader.id,f.dukeId);
assert.equal(duke.leader.dead,false);
assert.equal(duke.leader.deaths,f.game.dukeVidal!.leader.deaths);
assert.equal(duke.leader.usedAt,f.territory);
assert.equal(duke.leader.capturedBy,undefined);
assert.equal(duke.leader.concealed,undefined);
assert.equal(duke.captureVisibility,undefined);
assert.equal(duke.controller,null);
assert.equal(duke.source,null);
assert.equal(duke.acquiredTurn,null);
disc(game);
});
void test('earned captive helper release restores the still-current qualified original occupied-Ecaz tenure', () => {
const f = fixture(), game = reload(f.game), before = reload(game);
const released = releaseCapturedHomeworldDuke(game,f.captor);
assert.equal(game.turn,2);
assert.equal(released.controller,f.owner);
assert.equal(released.source,'homeworldEcaz');
assert.equal(released.acquiredTurn,game.turn);
assert.equal(released.leader.id,f.dukeId);
assert.equal(released.leader.dead,false);
assert.equal(released.leader.deaths,game.dukeVidal!.leader.deaths);
assert.equal(released.leader.capturedBy,undefined);
assert.equal(released.leader.concealed,undefined);
assert.equal(released.captureVisibility,undefined);
assert.deepEqual(game,before,'This focused helper exercise is not a claimed native early-release action.');
});
void test('controlled conserved same-turn departure helper context cannot restore an ended occupied-Ecaz entitlement', () => {
const f = fixture(), game = controlledEcazDukeCaptiveDeparture(f.game);
// Explicit unit supplement: only present physical custody changed. No played
// departure, occupation observation, lease/capture receipt or clock is added.
assert.equal(game.turn,f.game.turn);
assert.deepEqual(game.homeworldOccupationHistory,f.game.homeworldOccupationHistory);
assert.deepEqual(game.homeworldEcazDukeGrants,f.game.homeworldEcazDukeGrants);
assert.equal(quoteHomeworldEcazDuke(game).owner,null);
const before = reload(game), released = releaseCapturedHomeworldDuke(game,f.captor);
assert.equal(released.controller,null);
assert.equal(released.source,null);
assert.equal(released.acquiredTurn,null);
assert.equal(released.leader.id,f.dukeId);
assert.equal(released.leader.dead,false);
assert.equal(released.leader.capturedBy,undefined);
assert.equal(released.captureVisibility,undefined);
assert.deepEqual(game,before);
disc(game);
});
void test('actual occupied-Ecaz acquisition remains blocked on the captive and foreign helper release rejects before mutation', () => {
const f = fixture(), expired = expireEcazDukeCaptive(f), game = expired.game;
const quote = quoteHomeworldEcazDuke(game); assert.ok(quote.event);
reject(game,f.owner,{type:'homeworldDuke',event:quote.event,recipient:f.owner});
const before = reload(game);
assert.throws(() => releaseCapturedHomeworldDuke(game,f.owner));
assert.deepEqual(game,before);
assert.equal(dukeController(game.dukeVidal!),f.captor);
disc(game);
});
void test('all four minimal profiles answer the actual occupied-Duke captured-leader choice legally', () => {
const f = fixture();
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const game = reload(f.selected), view = viewGame(game,f.captor);
view.players.find(p => p.id === f.captor)!.bot = profile;
const action = botActions(view)[0];
assert.ok(action,`${profile} needs the actual retained/executed captive decision.`);
const next = act(game,f.captor,action);
if (action.mode === 'execute') {
assert.equal(next.dukeVidal!.leader.dead,true);
assert.equal(next.dukeVidal!.leader.capturedBy,undefined);
// The native Keep baseline includes the same automatic Carthag income.
assert.equal(p(next,f.captor).spice,p(f.game,f.captor).spice+2);
} else {
assert.equal(action.mode,'keep');
assert.equal(next.dukeVidal!.leader.capturedBy,f.captor);
}
disc(next);
}
});