Files
Dune/tests/fixture-caladan-sneak.ts

156 lines
8.3 KiB
TypeScript

import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import { location, territory } from '../game/board';
import { caladanVictoryFixture, holdVictoryCard } from './fixture-caladan-victory';
import {
actCaladanArrival, caladanArrivalInventory, caladanArrivalPlayer,
caladanArrivalReload, type CaladanArrivalStep,
} from './fixture-caladan-arrival';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export const actCaladanSneak = actCaladanArrival;
export const caladanSneakPlayer = caladanArrivalPlayer;
export const caladanSneakReload = caladanArrivalReload;
export const caladanSneakInventory = caladanArrivalInventory;
export type CaladanSneakStep = CaladanArrivalStep;
/** Neutral native controls only. All entry decisions and the current original
* reinforcement remain owned by the caller; no later battle is started. */
export function settleCaladanSneak(game: Game, actions: CaladanSneakStep[] = []): Game {
let g = game;
for (let n = 0; n < 120; n++) {
if (g.homeworldVictoryReinforcement?.stage === 'complete') return g;
if (g.decision && ['caladanReinforcement','moritaniTerror','ecazAmbassador',
'intrusion','advisor','arrivalOverlap'].includes(g.decision.kind)) return g;
if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g;
const next = nextStrongholdFactionsNativeStep(g);
if (!next) return g;
g = actCaladanSneak(g,next.actor,next.action,actions);
}
assert.fail('The bounded Caladan Sneak continuation did not stop.');
}
/** Finish exactly the present preventable layer; a later matching/automatic
* advisor conversion remains observable instead of being folded into it. */
export function passCaladanSneakResponse(game: Game, actions: CaladanSneakStep[] = []): Game {
let g = game;
const signature = (state: Game) => state.response ? JSON.stringify([
state.response.kind, state.response.spiritualAdvisorSource,
state.response.automaticAdvisorFlip,
]) : null;
const original = signature(g);
assert.ok(original);
for (let n = 0; signature(g) === original && n < 40; n++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(actor);
g = actCaladanSneak(g,actor.id,{type:'passResponse'},actions);
}
assert.notEqual(signature(g),original);
return g;
}
export function allowCaladanSneakResponses(game: Game, actions: CaladanSneakStep[] = []): Game {
let g = game;
for (let n = 0; g.response && n < 40; n++) g = passCaladanSneakResponse(g,actions);
assert.equal(g.response,null);
return settleCaladanSneak(g,actions);
}
export function createCaladanSneakFixture(options: {
advanced?: boolean; bg?: 'none'|'away'|'advisors';
ambassador?: 'first'|'second'; polarAlone?: boolean;
} = {}) {
const advanced = options.advanced ?? true, bg = options.bg ?? 'away';
let game = caladanVictoryFixture({advanced,extraSeats:[
{id:'m',faction:'moritani'},
...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : []),
...(bg !== 'none' ? [{id:'bg',faction:'beneGesserit'} as const] : []),
]});
// Explicit controlled source seam: the original Homeworld preview setup is
// genuine, while these later positions/purses/card custody and token placement
// are conserved scenario inputs, not an acquisition or movement history.
game.expansions.push('ecaz');
for (const id of ['a','g']) {
const p = caladanSneakPlayer(game,id);
delete p.forces['hagga_basin:12']; p.forces['arrakeen:10'] = 3;
}
const a = caladanSneakPlayer(game,'a'), guild = caladanSneakPlayer(game,'g');
if (options.polarAlone) {
a.forces['hagga_basin:12'] = a.forces['polar_sink:0'];
delete a.forces['polar_sink:0'];
}
if (bg !== 'none') {
const p = caladanSneakPlayer(game,'bg');
Object.assign(p,{reserves:19,forces:bg === 'advisors' ? {'arrakeen:10':1} : {'sietch_tabr:14':1},
advisors:advanced && bg === 'advisors' ? {arrakeen:{}} : {}});
}
const token = game.moritaniTerror!.tokens.find(t => t.kind === 'sneakAttack')!.id;
game.moritaniTerror = placeTerror(game.moritaniTerror!,token,'arrakeen',1);
let ambassadorToken: string | undefined;
if (options.ambassador) {
game.ecazAmbassadors = createAmbassadors(() => 0);
const original = game.ecazAmbassadors.tokens.find(t => t.zone === 'supply')!;
ambassadorToken = original.id;
game.ecazAmbassadors = placeAmbassador(game.ecazAmbassadors,original.id,{turn:1,availableSpice:20,
destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state;
game.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec'];
if (bg !== 'none') game.order.push('bg');
}
const karama = holdVictoryCard(game,'g','karama');
// Keep another real controlled contest so entry assertions exclude unrelated
// automatic Collection income after this one battle finishes.
const contest = location('shield_wall',territory('shield_wall').sectors[0]);
const donor = options.polarAlone ? 'hagga_basin:12' : 'polar_sink:0';
a.forces[donor]--; a.forces[contest] = 1;
guild.reserves--; guild.forces[contest] = 1;
caladanSneakInventory(game);
const source = caladanSneakReload(game), sourceActions: CaladanSneakStep[] = [];
game = actCaladanSneak(game,'a',{type:'chooseBattle',territory:'arrakeen',target:'g'},sourceActions);
game = settleCaladanSneak(game,sourceActions);
assert.ok(game.battle && !game.battle.preparation && !game.decision && !game.response);
const aLeader = caladanSneakPlayer(game,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best);
const gLeader = caladanSneakPlayer(game,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best);
game = actCaladanSneak(game,'a',{type:'battlePlan',leader:aLeader.id,dial:1,support:advanced ? 1 : 0},sourceActions);
game = actCaladanSneak(game,'g',{type:'battlePlan',leader:gLeader.id,dial:0,support:0},sourceActions);
game = settleCaladanSneak(game,sourceActions);
assert.equal(game.decision?.kind,'caladanReinforcement');
assert.equal(caladanSneakPlayer(game,'a').forces['arrakeen:10'],2);
const event = game.homeworldVictoryReinforcement!.event;
caladanSneakInventory(game);
return {game,source,sourceActions,actions:[...sourceActions],sourceCount:sourceActions.length,
disclosure:'Controlled Homeworld preview initializer and conserved later phase/positions/purses/cards/original token supply and placement; only recorded battle/arrival actions are native chronology, with no installed victory or child receipt.',
advanced,bg,ambassador:options.ambassador,token,ambassadorToken,karama,event,
territory:'arrakeen',destination:'arrakeen:10',
action:{type:'decision',event,destination:'arrakeen:10',amount:1} as Action,
offer:viewGame(game,'a').caladanReinforcement!};
}
export type CaladanSneakFixture = ReturnType<typeof createCaladanSneakFixture>;
/** Reach the original revealed Sneak amount choice through actual decisions.
* If Ambassador was first, its actual decline leaves the original token in
* place so the later Moritani group can legitimately offer it again. */
export function openCaladanSneak(f: CaladanSneakFixture): CaladanSneakFixture {
const actions = [...f.sourceActions];
let game = actCaladanSneak(caladanSneakReload(f.game),'a',f.action,actions);
assert.equal(game.pendingSpiritualAdvisor,undefined,'A direct Caladan addition creates no free-shipment source.');
if (game.pendingAmbassador) {
assert.equal(game.pendingAmbassador.entrant,'a');
game = actCaladanSneak(game,'ec',{type:'decision',event:game.pendingAmbassador.event,decline:true},actions);
}
assert.equal(game.pendingTerrorEntry?.stage,'offer');
assert.equal(game.pendingTerrorEntry?.victoryEvent,f.event);
game = actCaladanSneak(game,'m',{type:'decision',reveal:true},actions);
assert.equal(game.pendingTerrorEntry?.stage,'sneakAttack');
return {...f,game,actions};
}
export function commitCaladanSneak(f: CaladanSneakFixture, amount: 0|1 = 1): CaladanSneakFixture {
const opened = openCaladanSneak(f), actions = [...opened.actions];
const game = actCaladanSneak(opened.game,'m',{type:'decision',amount},actions);
caladanSneakInventory(game);
return {...opened,game,actions};
}