Preserve Caladan Sneak Attack child arrivals
This commit is contained in:
1 parent
2e4aba9630
commit
f0ebdf9c87
16 files changed
+1065
-44
No files matched your search
@@ -0,0 +1,155 @@
|
||||
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};
|
||||
}
|
||||
Reference in new issue
Block a user