Files
Dune/tests/caladan-arrival-engine.test.ts

216 lines
10 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 { fighterCount } from '../game/advisors';
import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
import { TERROR_KINDS } from '../game/moritani-terror';
import {
actCaladanArrival as act, advanceCaladanArrival as advance,
allowCaladanArrivalResponses as allow, caladanArrivalInventory as inventory,
caladanArrivalPlayer as p, caladanArrivalReload as reload,
createCaladanArrivalFixture as fixture,
} from './fixture-caladan-arrival';
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function completed(g: Game, event: string) {
assert.equal(g.homeworldVictoryReinforcement!.event, event);
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
assert.equal(p(g,'a').reserves, 5);
inventory(g);
}
void test('controlled Caladan battle sources record actual plans, calls and the original free addition', () => {
const f = fixture({bg:true});
assert.match(f.disclosure, /Controlled.*not public acquisition/);
assert.equal(f.sourceCount, f.actions.length);
assert.equal(f.actions.filter(s => s.action.type === 'chooseBattle').length,1);
assert.equal(f.actions.filter(s => s.action.type === 'battlePlan').length,2);
assert.equal(f.actions.filter(s => s.action.type === 'traitorCall').length,2);
assert.equal(f.source.homeworldVictoryReinforcement,undefined);
const before = structuredClone(p(f.game,'a'));
const g = act(reload(f.game),'a',f.action);
assert.equal(p(g,'a').reserves,before.reserves-1);
assert.equal(p(g,'a').forces[f.destination],before.forces[f.destination]+1);
for (const field of ['spice','shipped','moved','battleLosses'] as const)
assert.deepEqual(p(g,'a')[field],before[field]);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
assert.equal(g.homeworldRevivalReturn,undefined);
inventory(g);
});
for (const flip of [false,true])
void test(`Caladan Advanced Intrusion ${flip ? 'flip' : 'keep'} waits for the original arrival`, () => {
const f = fixture({bg:true});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.decision?.kind,'intrusion');
assert.equal(g.decision?.player,'bg');
assert.equal(g.decision?.kind === 'intrusion' && g.decision.victoryEvent,f.event);
assert.equal(fighterCount(p(g,'bg'),f.territory),1);
reject(g,'a',f.action);
g = act(g,'bg',{type:'decision',accept:flip});
if (flip) {
assert.equal(g.response?.kind,'advisorFlip');
assert.equal(g.response?.advisorVictoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = allow(g);
}
g = advance(g);
assert.equal(fighterCount(p(g,'bg'),f.territory),flip ? 0 : 1);
assert.equal(p(g,'bg').forces[f.destination],1);
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
reject(g,'a',f.action);
});
void test('Caladan Terror decline leaves the original hidden token and completes once', () => {
const f = fixture({terror:'robbery'});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.pendingTerrorEntry?.cause,'caladanReinforcement');
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
assert.equal(g.pendingTerrorEntry?.entrant,'a');
assert.equal(g.pendingTerrorEntry?.amount,1);
assert.equal(g.pendingTerrorEntry?.elite,0);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
reject(g,'a',{type:'decision',decline:true});
g = act(g,'m',{type:'decision',decline:true});
g = advance(g);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'placed');
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
});
for (const kind of TERROR_KINDS)
void test(`Caladan original Terror ${kind} resolves before its reinforcement completes`, () => {
const f = fixture({terror:kind});
let g = act(reload(f.game),'a',f.action);
const before = reload(g), entry = g.pendingTerrorEntry!;
assert.equal(entry.stage,'offer');
assert.equal(entry.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',reveal:true});
if (kind === 'robbery') {
assert.equal(g.pendingTerrorEntry?.stage,'robbery');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',choice:'spice'});
const stolen = Math.ceil(p(before,'a').spice/2);
assert.equal(p(g,'a').spice,p(before,'a').spice-stolen);
assert.equal(p(g,'m').spice,p(before,'m').spice+stolen);
} else if (kind === 'sneakAttack') {
assert.equal(g.pendingTerrorEntry?.stage,'sneakAttack');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',amount:1});
assert.equal(p(g,'m').reserves,p(before,'m').reserves-1);
assert.equal(p(g,'m').forces[f.destination],1);
assert.equal(p(g,'m').spice,p(before,'m').spice);
} else if (kind === 'sabotage') {
g = advance(g);
assert.equal(g.pendingTerrorEntry?.stage,'gift');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
assert.ok(g.discard.some(c => c.id === f.heldCard));
assert.equal(p(g,'a').hand.length,0);
g = act(g,'m',{type:'decision',decline:true});
} else if (kind === 'assassination') {
const deadBefore = p(before,'a').leaders.filter(l => l.dead).length;
assert.equal(p(g,'a').leaders.filter(l => l.dead).length,deadBefore+1);
const victim = p(g,'a').leaders.find(l => l.dead && !p(before,'a').leaders.find(old => old.id === l.id)!.dead)!;
assert.equal(p(g,'m').spice,p(before,'m').spice+victim.strength);
} else if (kind === 'extortion') {
assert.ok(g.moritaniExtortion);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'extortion');
assert.equal(p(g,'m').spice,p(before,'m').spice);
} else {
assert.equal(p(g,'a').forces[f.destination] ?? 0,0);
assert.equal(p(g,'a').tanks,p(before,'a').tanks+3);
}
g = advance(g);
assert.equal(g.pendingTerrorEntry,null);
if (kind !== 'extortion')
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed');
completed(g,f.event);
reject(g,'a',f.action);
});
void test('Caladan adds directly to a surviving army aboard the actual Ix HMS without shipping', () => {
const f = fixture({hms:true});
assert.ok(f.source.mobileStronghold?.location);
assert.equal(f.territory,'hidden_mobile_stronghold');
const choice = caladanReinforcementChoice(viewGame(f.game,'a'),f.destination);
assert.equal(choice.blocked,null);
assert.deepEqual(choice.action,f.action);
const before = structuredClone(p(f.game,'a'));
const g = advance(act(reload(f.game),'a',choice.action!));
assert.equal(p(g,'a').forces[f.destination],3);
for (const field of ['spice','shipped','moved'] as const)
assert.deepEqual(p(g,'a')[field],before[field]);
assert.deepEqual(g.mobileStronghold,f.game.mobileStronghold);
completed(g,f.event);
});
void test('Caladan original event and owner reject stale, foreign and oversized entry before cost', () => {
const f = fixture({bg:true});
reject(f.game,'g',f.action);
reject(f.game,'a',{...f.action,event:`${f.event}:stale`});
reject(f.game,'a',{...f.action,amount:2});
reject(f.game,'a',{...f.action,destination:'arrakeen:10'});
assert.equal(p(f.game,'a').reserves,6);
assert.equal(p(f.game,'a').forces[f.destination],2);
inventory(f.game);
});
void test('all four minimal profiles use the actual Caladan and Intrusion controls legally', () => {
const f = fixture({bg:true});
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
let g = reload(f.game);
const own = viewGame(g,'a');
own.players.find(seat => seat.id === 'a')!.bot = profile;
const action = botActions(own).find(a => a.type === 'decision' && a.event === f.event);
assert.ok(action,`${profile} needs a current original reinforcement action.`);
g = act(g,'a',action);
if (g.decision?.kind === 'intrusion') {
const view = viewGame(g,'bg');
view.players.find(seat => seat.id === 'bg')!.bot = profile;
const reaction = botActions(view)[0];
assert.ok(reaction,`${profile} needs a native Intrusion choice.`);
g = allow(act(g,'bg',reaction));
}
g = advance(g);
assert.equal(g.homeworldVictoryReinforcement!.stage,'complete');
assert.equal(p(g,'a').reserves,action.decline ? 6 : 5);
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed,true);
inventory(g);
}
});
for (const first of ['first','second'] as const)
void test(`Caladan Ambassador ${first} in storm order retains its original victory through Terror`, () => {
const f = fixture({terror:'robbery',ambassador:first});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.pendingArrivalOverlap?.first,first === 'first' ? 'ambassador' : 'terror');
assert.equal(g.pendingArrivalOverlap?.victoryEvent,f.event);
const declineAmbassador = () => {
const entry = g.pendingAmbassador; assert.ok(entry);
assert.equal(entry.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'ec',{type:'decision',event:entry.event,decline:true});
};
const declineTerror = () => {
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',decline:true});
};
if (first === 'first') { declineAmbassador(); declineTerror(); }
else { declineTerror(); declineAmbassador(); }
g = advance(g);
assert.equal(g.pendingArrivalOverlap,null);
assert.equal(g.homeworldVictoryReinforcement!.ambassadors!.length,1);
assert.equal(g.homeworldVictoryReinforcement!.ambassadors![0].completed,true);
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
});