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,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);
|
||||
}
|
||||
});
|
||||
@@ -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]]]));
|
||||
});
|
||||
@@ -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