Preserve Caladan Sneak Attack child arrivals

This commit is contained in:
Quintine committed 2026-10-27 15:05:53 +11:00
1 parent 2e4aba9630
commit f0ebdf9c87
16 files changed
+1065 -44

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
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 { isAdvisor } from '../game/advisors';
import {
createCaladanSneakFixture as fixture, openCaladanSneak as open,
commitCaladanSneak as commit, actCaladanSneak as act,
settleCaladanSneak as settle, passCaladanSneakResponse as pass,
allowCaladanSneakResponses as allow, caladanSneakPlayer as p,
caladanSneakInventory as inventory, caladanSneakReload as reload,
} from './fixture-caladan-sneak';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game,actor,action));
assert.deepEqual(game,before);
}
function complete(game: Game, event: string) {
assert.equal(game.homeworldVictoryReinforcement!.event,event);
assert.equal(game.homeworldVictoryReinforcement!.stage,'complete');
assert.equal(game.lastBattleContext!.caladanReinforcement!.completed,true);
assert.equal(p(game,'a').reserves,5);
assert.equal(p(game,'a').forces['arrakeen:10'],3);
inventory(game);
}
function declineChildAmbassador(game: Game) {
const entry = game.pendingAmbassador;
assert.ok(entry);
return act(game,'ec',{type:'decision',event:entry.event,decline:true});
}
void test('Caladan positive Sneak commits one distinct reserve group with one actual original receipt', () => {
const f = fixture({bg:'none'}), opened = open(f);
assert.match(f.disclosure,/Controlled.*no installed victory or child receipt/);
assert.equal(f.sourceCount,f.sourceActions.length);
assert.equal(f.sourceActions.filter(s => s.action.type === 'battlePlan').length,2);
assert.equal(f.sourceActions.filter(s => s.action.type === 'traitorCall').length,2);
assert.equal(f.source.homeworldVictoryReinforcement,undefined);
const before = reload(opened.game);
const game = settle(act(opened.game,'m',{type:'decision',amount:1}));
assert.equal(p(game,'m').reserves,p(before,'m').reserves-1);
assert.equal(p(game,'m').forces['arrakeen:10'],1);
for (const id of ['a','m']) for (const field of ['spice','shipped','moved'] as const)
assert.deepEqual(p(game,id)[field],p(before,id)[field]);
const receipts = game.homeworldVictoryReinforcement!.terrorArrivals!;
assert.equal(receipts.length,1);
assert.equal(receipts[0].packet.owner,'m');
assert.equal(receipts[0].packet.territory,'arrakeen');
assert.equal(receipts[0].packet.sector,10);
assert.equal(receipts[0].packet.amount,1);
assert.equal(receipts[0].packet.elite,0);
assert.notEqual(receipts[0].packet.event,f.event);
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed');
complete(game,f.event);
reject(game,'a',f.action);
});
void test('Caladan zero Sneak spends no reserve and creates no child or free advisor source', () => {
const f = fixture({bg:'away'}), before = open(f);
const game = settle(act(before.game,'m',{type:'decision',amount:0}));
assert.equal(p(game,'m').reserves,p(before.game,'m').reserves);
assert.equal(p(game,'m').forces['arrakeen:10'],undefined);
assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals?.length ?? 0,0);
assert.equal(game.pendingSpiritualAdvisor,undefined);
assert.equal(game.pendingArrivalReaction?.length ?? 0,0);
assert.equal(game.pendingAmbassador??null,null);
complete(game,f.event);
});
for (const order of ['first','second'] as const)
void test(`Caladan Sneak preserves the original Ambassador ${order} before its second child offer`, () => {
const f = fixture({bg:'none',ambassador:order}), opened = open(f);
const original = structuredClone(opened.game.pendingArrivalOverlap);
assert.ok(original);
assert.equal(original.victoryEvent,f.event);
let game = act(opened.game,'m',{type:'decision',amount:1});
if (order === 'second') {
assert.equal(game.pendingAmbassador?.entrant,'a');
assert.equal(game.pendingAmbassador?.event,game.pendingArrivalOverlap?.ambassadorEvent);
assert.equal(game.pendingArrivalOverlap?.event,original.event);
assert.equal(game.homeworldVictoryReinforcement!.stage,'arrival');
const queued = game.pendingArrivalReaction!.find(entry => entry.entrant === 'm')!;
assert.ok(queued);
assert.equal(queued.caladanSneakAdvisor,true);
assert.equal(queued.caladanArrival?.event,f.event);
assert.equal(queued.caladanArrival?.terror?.owner,'m');
game = declineChildAmbassador(game);
}
assert.equal(game.pendingArrivalOverlap,null);
assert.equal(game.pendingAmbassador?.entrant,'m');
assert.equal(game.pendingAmbassador?.token,f.ambassadorToken);
assert.equal(game.pendingAmbassador?.victoryEvent,f.event);
const records = game.homeworldVictoryReinforcement!.ambassadors!;
assert.equal(records.length,2);
assert.deepEqual(records.map(record => record.completed),[true,false]);
assert.equal(records[0].entrant,'a');
assert.equal(records[1].entrant,'m');
assert.equal(records[1].terror?.event,game.homeworldVictoryReinforcement!.terrorArrivals![0].packet.event);
assert.notEqual(records[0].event,records[1].event);
reject(game,'a',f.action);
game = settle(declineChildAmbassador(game));
assert.deepEqual(game.homeworldVictoryReinforcement!.ambassadors!.map(record => record.completed),[true,true]);
assert.equal(p(game,'m').reserves,19);
assert.equal(p(game,'m').forces['arrakeen:10'],1);
complete(game,f.event);
});
void test('Caladan Sneak Advanced accompaniment uses its own committed source with away or resident advisors', () => {
for (const bg of ['away','advisors'] as const) {
const f = fixture({bg}), committed = commit(f);
let game = committed.game;
assert.equal(game.decision?.kind,'advisor');
assert.ok(game.decision?.kind === 'advisor' && game.decision.caladanArrival);
if (game.decision?.kind !== 'advisor') assert.fail('The committed Sneak group owns its advisor offer.');
const parent = game.decision.caladanArrival!;
assert.equal(parent.event,f.event);
assert.equal(parent.terror?.owner,'m');
assert.equal(parent.terror?.amount,1);
const before = reload(game), original = structuredClone(game.homeworldVictoryReinforcement!.terrorArrivals);
game = act(game,'bg',{type:'decision',accept:true,accompany:true});
assert.equal(game.response?.kind,'advisor');
assert.equal(game.response?.spiritualAdvisorSource?.phase,6);
assert.equal(game.response?.spiritualAdvisorSource?.victoryEvent,f.event);
assert.deepEqual(game.pendingSpiritualAdvisor!.decision.caladanArrival,parent);
assert.equal(game.homeworldVictoryReinforcement!.stage,'arrival');
assert.equal(p(game,'bg').reserves,p(before,'bg').reserves);
game = allow(game);
assert.equal(p(game,'bg').forces['arrakeen:10'],bg === 'advisors' ? 2 : 1);
assert.equal(p(game,'bg').reserves,p(before,'bg').reserves-1);
assert.equal(isAdvisor(p(game,'bg'),'arrakeen'),true);
assert.deepEqual(game.homeworldVictoryReinforcement!.terrorArrivals,original);
complete(game,f.event);
}
});
for (const prevented of [false,true])
void test(`Caladan Sneak Polar arrival ${prevented ? 'prevents' : 'allows'} the distinct automatic conversion`, () => {
const f = fixture({bg:'away',polarAlone:true}), committed = commit(f);
let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:false});
assert.equal(game.pendingSpiritualAdvisor?.stage,'shipping');
game = pass(game);
assert.equal(game.response?.automaticAdvisorFlip?.producer,'spiritualArrival');
assert.equal(game.pendingSpiritualAdvisor?.stage,'arrival');
assert.equal(p(game,'bg').forces['polar_sink:0'],1);
assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),true);
const physical = structuredClone(p(game,'bg'));
game = prevented ? act(game,'g',{type:'card',card:f.karama,mode:'cancel'}) : pass(game);
game = settle(game);
assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),prevented);
assert.equal(p(game,'bg').forces['polar_sink:0'],physical.forces['polar_sink:0']);
assert.equal(p(game,'bg').reserves,physical.reserves);
assert.equal(p(game,'m').forces['arrakeen:10'],1);
assert.equal(game.discard.filter(c => c.id === f.karama).length,Number(prevented));
assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
complete(game,f.event);
});
void test('Caladan Sneak shipping prevention leaves the committed group and original cost without replay or refund', () => {
const f = fixture(), committed = commit(f);
const before = reload(committed.game);
let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:true});
assert.equal(game.response?.kind,'advisor');
game = settle(act(game,'g',{type:'card',card:f.karama,mode:'cancel'}));
assert.equal(p(game,'bg').reserves,p(before,'bg').reserves);
assert.equal(p(game,'bg').forces['arrakeen:10'],undefined);
assert.equal(p(game,'m').forces['arrakeen:10'],1);
assert.equal(p(game,'m').reserves,19);
assert.equal(game.discard.filter(c => c.id === f.karama).length,1);
assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
complete(game,f.event);
reject(game,'bg',{type:'decision',accept:true,accompany:true});
});
void test('Caladan Sneak decline consumes only the original optional advisor choice', () => {
const f = fixture(), committed = commit(f), before = structuredClone(p(committed.game,'bg'));
const game = settle(act(committed.game,'bg',{type:'decision',accept:false}));
assert.deepEqual(p(game,'bg'),before);
assert.ok(p(game,'g').hand.some(c => c.id === f.karama));
assert.equal(game.pendingSpiritualAdvisor,undefined);
assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
complete(game,f.event);
});
void test('Caladan Sneak Basic fallback uses the legal original free Polar shipment', () => {
const f = fixture({advanced:false}), committed = commit(f), before = reload(committed.game);
let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:false});
assert.equal(game.response?.spiritualAdvisorSource?.victoryEvent,f.event);
game = allow(game);
assert.equal(p(game,'bg').forces['polar_sink:0'],1);
assert.equal(p(game,'bg').reserves,p(before,'bg').reserves-1);
assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),false);
assert.equal(p(game,'bg').spice,p(before,'bg').spice);
complete(game,f.event);
});
void test('Caladan Sneak keeps native owner and amount validation while the original choice is busy', () => {
const f = fixture(), opened = open(f);
reject(opened.game,'a',{type:'decision',amount:1});
reject(opened.game,'m',{type:'decision',amount:-1});
reject(opened.game,'a',f.action);
const committed = commit(f);
reject(committed.game,'a',{...f.action,event:`${f.event}:stale`});
reject(committed.game,'g',{type:'decision',accept:true,accompany:true});
assert.equal(p(committed.game,'m').reserves,19);
assert.equal(committed.game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
inventory(committed.game);
});
void test('all four minimal profiles can answer the actual Caladan Sneak advisor opportunity legally', () => {
const f = fixture(), committed = commit(f);
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
let game = reload(committed.game);
const own = viewGame(game,'bg');
own.players.find(player => player.id === 'bg')!.bot = profile;
const action = botActions(own)[0];
assert.ok(action,`${profile} needs a legal original advisor choice.`);
game = allow(act(game,'bg',action));
assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
assert.equal(p(game,'m').reserves,19);
complete(game,f.event);
}
});
+166
View File
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { createTerrorState } from '../game/moritani-terror';
import { terrorEntrySignature, terrorSelectionSignature } from '../game/terror-entry-receipt';
import {
makeHomeworldVictoryReturn, homeworldVictoryOfferSignature, homeworldVictoryObligationSignature,
} from '../game/homeworld-victory-return';
import {
appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, homeworldArrivalSignature,
validateHomeworldArrival, type HomeworldArrivalFrame,
} from '../game/homeworld-arrival';
import {
createCaladanSneakReceipt, validateCaladanSneakHistory, validateCaladanTerrorPacket,
validCaladanEntryParent, caladanEntryParentTuple, validateCaladanChildArrival,
type CaladanEntryParent, type CaladanTerrorHistoryContext,
} from '../game/caladan-arrival';
const uuid = '00112233-4455-4677-8899-aabbccddeeff';
const otherUUID = '11112233-4455-4677-8899-aabbccddeeff';
/** Controlled provenance unit: signed victory/arrival records, selected removed
* Sneak token and private stacked candidates are scenario inputs. This does not
* claim a native battle, placement, reserve debit or second-token chronology. */
function fixture() {
const g = createGame('CALADANSNEAKSOURCE', newPlayer('a', 'Atreides', 'atreides'), true, ['ecaz']);
g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('bg', 'BG', 'beneGesserit'), newPlayer('ec', 'Ecaz', 'ecaz'));
Object.assign(g, { status: 'playing', turn: 2, phase: 6 });
Object.assign(g.players[0], { reserves: 5, tanks: 12, forces: { 'arrakeen:10': 3 } });
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const context = { event: 'original-victory', turn: 2, territory: 'arrakeen', winner: 'a', result: 'normal' } as NonNullable<Game['lastBattleContext']>;
const frame = makeHomeworldVictoryReturn({ event: context.event, turn: 2, player: 'a', territory: 'arrakeen', result: 'normal' });
frame.stage = 'arrival'; frame.destination = 'arrakeen:10'; frame.ambassadors = [];
frame.offer = { population: 6, survivors: 2, blocked: null,
signature: homeworldVictoryOfferSignature(frame, { population: 6, survivors: 2, blocked: null }) };
frame.arrivalSignature = homeworldArrivalSignature(frame);
context.caladanReinforcement = { event: frame.event, completed: false, stage: 'arrival',
signature: homeworldVictoryObligationSignature(context, false, 'arrival') };
g.lastBattleContext = context; g.homeworldVictoryReinforcement = frame;
g.moritaniTerror = createTerrorState(() => 0);
const token = g.moritaniTerror.tokens.find(candidate => candidate.kind === 'sneakAttack')!;
token.status = 'removed'; token.location = null;
const hidden = g.moritaniTerror.tokens.filter(candidate => candidate.id !== token.id).slice(0, 2);
for (const candidate of hidden) { candidate.status = 'placed'; candidate.location = 'arrakeen'; }
const entry: NonNullable<Game['pendingTerrorEntry']> = { token: token.id,
candidates: [token.id, ...hidden.map(candidate => candidate.id)], entrant: 'a', territory: 'arrakeen',
sector: 10, amount: 1, elite: 0, cause: 'caladanReinforcement', victoryEvent: frame.event,
turn: 2, phase: 6, stage: 'sneakAttack', resume: 'none' };
entry.selectionSignature = terrorSelectionSignature(entry); entry.entrySignature = terrorEntrySignature(entry);
g.pendingTerrorEntry = entry; g.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'a', territory: 'arrakeen' };
return { g, frame, entry, hidden: hidden.map(candidate => candidate.id) };
}
function committed(amount = 2) {
const f = fixture(), result = createCaladanSneakReceipt(f.g, f.entry, 'm', amount, uuid);
// Controlled ledger append represents the caller's post-transfer step. This
// unit does not claim to have operated the native transfer itself.
f.frame.terrorArrivals = [result.receipt];
return { ...f, ...result };
}
function history(f: ReturnType<typeof committed>): CaladanTerrorHistoryContext {
return { turn: f.frame.turn, phase: 6, players: f.g.players, victoryPlayer: f.frame.player,
terrorArrivals: f.frame.terrorArrivals! };
}
void test('Caladan Sneak quotes an opaque public packet and sanitized used-token history without private stack identities', () => {
const f = fixture(), before = structuredClone(f.g);
const result = createCaladanSneakReceipt(f.g, f.entry, 'm', 2, uuid);
assert.deepEqual(result.packet, { event: uuid, owner: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0 });
assert.notEqual(result.packet.event, f.entry.entrySignature);
assert.equal(result.receipt.token, f.entry.token);
assert.deepEqual(Object.keys(result.receipt.trigger).sort(), ['amount', 'cause', 'elite', 'entrant', 'sector', 'territory', 'victoryEvent']);
for (const id of f.hidden) {
assert.ok(!JSON.stringify(result.packet).includes(id));
assert.ok(!JSON.stringify(result.receipt).includes(id));
}
assert.doesNotMatch(JSON.stringify(result.receipt), /candidates|selectionSignature|entrySignature|"players"|"hand"/);
assert.notEqual(result.packet, result.receipt.packet);
assert.deepEqual(f.g, before);
f.g.decision = null; // applyAction clears its owned decision during dispatch.
assert.doesNotThrow(() => createCaladanSneakReceipt(f.g, f.entry, 'm', 2, otherUUID));
});
void test('the current selected Sneak stage owner source and positive packet count remain required before debit', () => {
for (const variant of ['owner', 'zero', 'oversize', 'raw-event', 'stage', 'source'] as const) {
const f = fixture();
const entry = variant === 'source' ? { ...f.entry, amount: 2 } : f.entry;
if (variant === 'stage') f.entry.stage = 'offer';
const before = structuredClone(f.g);
assert.throws(() => createCaladanSneakReceipt(f.g, entry, variant === 'owner' ? 'ec' : 'm',
variant === 'zero' ? 0 : variant === 'oversize' ? 6 : 2,
variant === 'raw-event' ? f.entry.entrySignature! : uuid), HomeworldCustodyError);
assert.deepEqual(f.g, before);
}
});
void test('opaque history validates its original source after resources token custody and the latest battle have changed', () => {
const f = committed();
validateCaladanTerrorPacket(history(f), f.packet, f.frame.destination!, f.frame.event);
validateCaladanSneakHistory(f.g);
f.g.players.find(player => player.id === 'm')!.reserves = 0;
f.g.players[0].forces = {};
f.g.moritaniTerror!.tokens = [];
f.frame.stage = 'complete'; f.g.lastBattleContext = undefined; f.g.turn = 3; f.g.phase = 8;
assert.doesNotThrow(() => validateCaladanSneakHistory(f.g));
const modified = { ...f.packet, amount: 3 };
assert.throws(() => validateCaladanTerrorPacket(history(f), modified, f.frame.destination!, f.frame.event), HomeworldCustodyError);
assert.throws(() => validateCaladanTerrorPacket(history(f), f.packet, 'carthag:11', f.frame.event), HomeworldCustodyError);
});
void test('new Moritani and Spiritual child entries bind their own packets and cannot reuse the original Caladan force', () => {
const f = committed(), parent: CaladanEntryParent = { event: f.frame.event, terror: f.packet };
const direct = { entrant: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, cause: 'shipment' };
assert.ok(validCaladanEntryParent(parent));
validateCaladanChildArrival(f.g, parent, direct);
assert.throws(() => validateCaladanChildArrival(f.g, parent, { ...direct, entrant: 'a', amount: 1, cause: 'caladanReinforcement' }), HomeworldCustodyError);
const spiritual: CaladanEntryParent = { ...parent, spiritual: { event: 'actual-bg-packet', owner: 'bg', turn: 2,
phase: 6, territory: 'polar_sink', sector: 0, amount: 1, victoryEvent: f.frame.event } };
const advisor = { entrant: 'bg', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, cause: 'advisor' };
validateCaladanChildArrival(f.g, spiritual, advisor);
assert.deepEqual(caladanEntryParentTuple(spiritual), ['caladanArrival', f.frame.event,
['actual-bg-packet', 'bg', 2, 6, 'polar_sink', 0, 1, f.frame.event],
[uuid, 'm', 'arrakeen', 10, 2, 0]]);
assert.equal(validCaladanEntryParent({ event: f.frame.event, spiritual: spiritual.spiritual }), false);
assert.equal(validCaladanEntryParent({ ...parent, spiritual: undefined }), false);
assert.throws(() => validateCaladanChildArrival(f.g, spiritual, { ...advisor, amount: 2 }), HomeworldCustodyError);
assert.throws(() => validateCaladanChildArrival(f.g, { ...parent, terror: { ...f.packet, event: otherUUID } }, direct), HomeworldCustodyError);
});
void test('phase-six Ambassador history resolves opaque Caladan packets and their Spiritual roots without storing private stack signatures', () => {
const f = committed();
const entry: NonNullable<Game['pendingAmbassador']> = { event: 'sneak-ambassador', owner: 'ec', entrant: 'm',
territory: 'arrakeen', sector: 10, turn: 2, phase: 6, victoryEvent: f.frame.event,
token: 'controlled-ambassador-token', stage: 'offer', copyChoices: [], resume: 'none' };
appendHomeworldArrivalAmbassador(f.frame, entry, undefined, undefined, f.packet);
f.g.pendingAmbassador = entry;
validateHomeworldArrival(f.g, f.frame, 'victoryEvent');
completeHomeworldArrivalAmbassador(f.frame, entry.event); f.g.pendingAmbassador = null;
const source = { event: 'spiritual-packet', owner: 'bg', turn: 2, phase: 6,
territory: 'polar_sink', sector: 0, amount: 1, victoryEvent: f.frame.event };
const bg = { ...entry, event: 'spiritual-ambassador', entrant: 'bg', territory: 'polar_sink', sector: 0 };
appendHomeworldArrivalAmbassador(f.frame, bg, undefined, source, f.packet); f.g.pendingAmbassador = bg;
validateHomeworldArrival(f.g, f.frame, 'victoryEvent');
for (const id of f.hidden) assert.ok(!f.frame.arrivalSignature!.includes(id));
assert.ok(!f.frame.arrivalSignature!.includes(f.entry.entrySignature!));
});
void test('legacy phase-four Spiritual bytes and phase-six Guild child bytes stay unchanged', () => {
const old: HomeworldArrivalFrame = { event: 'old-return', turn: 2, phase: 4, player: 't', stage: 'arrival',
signature: 'old-original-signature', destination: 'arrakeen:10', ambassadors: [] };
old.arrivalSignature = homeworldArrivalSignature(old);
const source = { event: 'old-spiritual', owner: 'bg', turn: 2, phase: 4, territory: 'arrakeen', sector: 10,
amount: 1, revivalEvent: old.event };
appendHomeworldArrivalAmbassador(old, { event: 'old-ambassador', entrant: 'bg', territory: 'arrakeen', sector: 10,
turn: 2, phase: 4 }, undefined, source);
assert.equal(old.arrivalSignature, JSON.stringify(['homeworldRevivalArrival', old.signature, old.destination,
[['old-ambassador', 'bg', 'arrakeen:10', null, false, ['old-spiritual', 'bg', 2, 4, 'arrakeen', 10, 1, old.event]]]]));
const guild: HomeworldArrivalFrame = { event: 'old-victory', turn: 2, phase: 6, player: 'a', stage: 'arrival',
signature: 'old-victory-signature', destination: 'arrakeen:10', ambassadors: [
{ event: 'guild-parent', entrant: 'a', destination: 'arrakeen:10', completed: true },
{ event: 'guild-bg-child', entrant: 'bg', destination: 'polar_sink:0', parent: 'guild-parent', completed: false },
] };
assert.equal(homeworldArrivalSignature(guild), JSON.stringify(['homeworldRevivalArrival', guild.signature, guild.destination,
[['guild-parent', 'a', 'arrakeen:10', null, true], ['guild-bg-child', 'bg', 'polar_sink:0', 'guild-parent', false]]]));
});
+155
View File
@@ -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};
}