Files
Dune/tests/advisor-relocation-flip-engine.test.ts
Quintine abf0305ddf Resolve advisor conversion after Ambassador and Discovery transfers
Preserve original transfer and arrival cursors, admit no new Discovery advisor sources, and offer one mandatory counter for empty Ambassador advisor arrivals.
2026-10-24 21:32:12 +11:00

131 lines
8.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { canceledResponseSource } from '../game/karama-context';
import { createAdvisorStormFlipFixture, commitAdvisorStormFlip } from './fixture-advisor-storm-flip';
import {
createAdvisorAmbassadorFlipFixture, createAdvisorDiscoveryFlipFixture,
actAdvisorRelocationFlip as act, advisorRelocationFlipPlayer as player,
advisorRelocationFlipInventory as inventory, finishAdvisorRelocationFlip as finish,
commitAdvisorRelocationFlip as commit,
} from './fixture-advisor-relocation-flip';
const fixtures = [
['ambassador', createAdvisorAmbassadorFlipFixture],
['discovery', createAdvisorDiscoveryFlipFixture],
] as const;
function allow(game: Game) {
for (let n = 0; game.response?.automaticAdvisorFlip && n < 30; n++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'passResponse' });
}
assert.ok(!game.response?.automaticAdvisorFlip); return game;
}
const physical = (game: Game) => game.players.map(p => ({ id:p.id, forces:p.forces,
reserves:p.reserves, tanks:p.tanks, spice:p.spice, shipped:p.shipped, moved:p.moved }));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('special relocation does not add a field to the saved source shape of an existing storm counter',()=>{
const f=createAdvisorStormFlipFixture(),pending=commitAdvisorStormFlip(f.game,f).game;
assert.ok(pending.response);
const selected=canceledResponseSource(pending,pending.response) as {source:Record<string,unknown>};
assert.deepEqual(Object.keys(selected.source).sort(),['battle','faceDance','gamont','group','homeworld','pending','source','storm','transport']);
assert.equal(JSON.stringify(selected).includes('"relocation":'),false);
});
for (const [kind, fixture] of fixtures) for (const prevented of [false,true]) {
void test(`native ${kind} ${prevented ? 'prevention' : 'allowance'} keeps the completed transfer and resumes original arrival once`, () => {
const f = fixture(), before = structuredClone(f.game), pending = commit(f.game,f).game;
assert.deepEqual(f.game,before);
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'specialRelocation');
assert.equal(source.relocation.kind,kind); assert.equal(source.territory,f.origin);
assert.equal(source.relocation.player,f.mover); assert.equal(source.relocation.destination,f.destination);
assert.deepEqual(Object.keys(source.relocation).sort(),['destination','event','kind','origin','player','sector']);
assert.equal(fighterCount(player(pending,f.bg),f.origin),0);
assert.equal(player(pending,f.bg).forces[f.location],1);
assert.equal(player(pending,f.mover).forces[f.from],undefined);
const amount = Object.entries(player(before,f.mover).forces).filter(([key]) => splitLocation(key).territory === f.origin)
.reduce((sum,[,count]) => sum+count,0);
assert.ok(amount > 0);
assert.equal(player(pending,f.mover).forces[f.destinationLocation],(player(before,f.mover).forces[f.destinationLocation]??0)+amount);
assert.deepEqual(pending.players.map(p => [p.spice,p.reserves,p.tanks,p.shipped,p.moved]),
before.players.map(p => [p.spice,p.reserves,p.tanks,p.shipped,p.moved]));
if (kind === 'ambassador') assert.equal(pending.pendingAmbassador?.relocation?.next,'flip');
else assert.equal(pending.discoveryEntry?.stage,'arrival');
assert.ok(viewGame(pending,f.holder).responseControls?.cancelCards.includes(f.karama));
assert.ok(!JSON.stringify(viewGame(pending,f.holder).response).includes('signature'));
const committed = physical(pending), stable = normalizeAutomaticGame(pending);
assert.deepEqual(physical(stable),committed);
assert.deepEqual(stable.pendingAutomaticAdvisorRelocation,pending.pendingAutomaticAdvisorRelocation);
let done = prevented ? act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}) : allow(pending);
done = finish(done);
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
assert.ok(!done.pendingAmbassador); assert.equal(done.discoveryEntry,undefined);
assert.equal(isAdvisor(player(done,f.bg),f.origin),prevented);
assert.equal(fighterCount(player(done,f.bg),f.origin),prevented?0:1);
assert.deepEqual(physical(done),committed);
assert.equal(done.discard.filter(c => c.id === f.karama).length,Number(prevented));
assert.deepEqual(inventory(done),f.originalInventory);
assert.deepEqual(physical(normalizeAutomaticGame(done)),committed);
});
}
for (const [kind, fixture] of fixtures) void test(`${kind} rejects a changed private continuation before spending the conversion card`, () => {
const f=fixture(),pending=commit(f.game,f).game;
pending.pendingAutomaticAdvisorRelocation!.signature='different-original-relocation';
reject(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.ok(player(pending,f.holder).hand.some(c=>c.id===f.karama));
});
void test('declining original native Discovery entry keeps the mixed parent group without a conversion',()=>{
const f=createAdvisorDiscoveryFlipFixture(),before=physical(f.game);
const done=finish(act(f.game,f.actor,{type:'decision',event:f.event,accept:false}));
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
assert.equal(done.discoveryEntry,undefined);assert.deepEqual(physical(done),before);
assert.equal(isAdvisor(player(done,f.bg),f.origin),true);
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(inventory(done),f.originalInventory);
});
for (const prevented of [false,true]) void test(`controlled allied BG empty Ambassador arrival ${prevented?'prevention':'allowance'} has one mandatory flip and no optional retry`, () => {
const f=createAdvisorAmbassadorFlipFixture(),source=structuredClone(f.game);
// Borrow original cards, pieces, consumed token and entrant from the native
// source, but stage alliance/beneficiary selection explicitly. This is not
// evidence of a native earned alliance journey.
const owner=source.pendingAmbassador!.owner;
player(source,owner).ally=f.bg; player(source,f.bg).ally=owner;
source.pendingAmbassador!.beneficiary=f.bg; source.decision!.player=f.bg;
const action:Action={...f.action,forces:{[f.location]:1},eliteForces:{},fighters:true};
const pending=act(source,f.bg,action),automatic=pending.response?.automaticAdvisorFlip;
assert.ok(automatic?.producer==='specialRelocation');
assert.equal(automatic.territory,f.destination); assert.equal(automatic.owner,f.bg);
assert.equal(pending.pendingAmbassador?.relocation?.order.wantsFighters,false);
assert.equal(pending.pendingAmbassador?.relocation?.order.advisors,false);
assert.equal(isAdvisor(player(pending,f.bg),f.destination),true);
assert.equal(fighterCount(player(pending,f.bg),f.destination),0);
const committed=physical(pending);
const done=finish(prevented?act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}):allow(pending));
assert.ok(!done.pendingAmbassador); assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
assert.equal(done.response?.kind==='advisorFlip',false,'An empty arrival cannot immediately retry the prevented occurrence.');
assert.equal(isAdvisor(player(done,f.bg),f.destination),prevented);
assert.deepEqual(physical(done),committed); assert.deepEqual(inventory(done),f.originalInventory);
});
void test('all four existing profiles resolve native Ambassador and Discovery conversion responses legally',()=>{
for(const [,fixture] of fixtures) for(const profile of DIFFICULTIES){
const f=fixture(),pending=commit(f.game,f).game,own=viewGame(pending,f.holder);
own.players.find(p=>p.id===f.holder)!.bot=profile;
const actions=botActions(own);assert.ok(actions.length);
const done=finish(allow(act(pending,f.holder,actions[0])));
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
assert.deepEqual(physical(done),physical(pending));assert.deepEqual(inventory(done),f.originalInventory);
}
});