Files
Dune/tests/spiritual-advisor-engine.test.ts
Quintine 767bace774 Separate Spiritual Advisor shipment matching and alone conversion
Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
2026-10-24 22:28:42 +11:00

145 lines
9.9 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 { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createSpiritualAdvisorFlowFixture as fixture,actSpiritualAdvisorFlow as act,
spiritualAdvisorFlowPlayer as player,spiritualAdvisorFlowInventory as inventory,
finishSpiritualAdvisorFlow as finish,advanceSpiritualAdvisorFlow as advance,
spiritualAdvisorFlowClean as clean } from './fixture-spiritual-advisor-flow';
const physical=(g:Game)=>g.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 stage(g:Game){
const r=g.response;return r?.spiritualAdvisorSource?JSON.stringify([r.kind,r.spiritualAdvisorSource]):
r?.automaticAdvisorFlip?.producer==='spiritualArrival'?JSON.stringify(r.automaticAdvisorFlip):null;
}
function allow(g:Game){
const original=stage(g);assert.ok(original);
for(let n=0;stage(g)===original&&n<30;n++){
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);
g=act(g,actor.id,{type:'passResponse'});
}
assert.notEqual(stage(g),original);return g;
}
function reject(g:Game,actor:string,action:Action){
const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);
}
function cancel(g:Game,holder:string,card:string){return act(g,holder,{type:'card',card,mode:'cancel'});}
for(const parent of ['ordinary','ambassador'] as const){
void test(`native ${parent} retained non-Polar fighters block accompaniment in source, projected choices and settlement`,()=>{
const f=fixture({parent}),choices=viewGame(f.game,f.bg).spiritualAdvisorChoices;
assert.ok(choices);assert.ok(choices.choices.filter(c=>c.accompany).every(c=>c.blocked));
assert.ok(choices.choices.some(c=>!c.accompany&&!c.blocked));
reject(f.game,f.bg,f.accompany);
assert.equal(f.game.pendingSpiritualAdvisor,undefined);
});
void test(`native ${parent} shipping prevention preserves the original committed foreign shipment`,()=>{
const f=fixture({parent}),pending=act(f.game,f.bg,f.sink);
assert.equal(pending.response?.kind,'advisor');assert.ok(pending.response?.spiritualAdvisorSource);
assert.equal(pending.pendingSpiritualAdvisor?.stage,'shipping');assert.deepEqual(physical(pending),physical(f.game));
const done=finish(cancel(pending,f.holder,f.karama));
assert.equal(done.pendingSpiritualAdvisor,undefined);assert.equal(player(done,f.bg).forces['polar_sink:0'],undefined);
assert.deepEqual(physical(done),physical(f.game));assert.deepEqual(inventory(done),f.originalInventory);
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);
});
for(const prevented of [false,true])void test(`native ${parent} empty Polar arrival ${prevented?'prevents':'allows'} its separate committed alone conversion`,()=>{
const f=fixture({parent}),shipping=act(f.game,f.bg,f.sink),pending=allow(shipping);
assert.equal(pending.response?.automaticAdvisorFlip?.producer,'spiritualArrival');
assert.equal(pending.pendingSpiritualAdvisor?.stage,'arrival');assert.equal(player(pending,f.bg).forces['polar_sink:0'],1);
assert.equal(player(pending,f.bg).reserves,player(f.game,f.bg).reserves-1);
assert.equal(isAdvisor(player(pending,f.bg),'polar_sink'),true);
assert.equal(pending.log.filter(l=>l.text.startsWith(player(pending,f.bg).name+' sent 1 free')).length,1);
const committed=physical(pending);assert.deepEqual(physical(normalizeAutomaticGame(pending)),committed);
const done=finish(prevented?cancel(pending,f.holder,f.karama):allow(pending));
assert.equal(done.pendingSpiritualAdvisor,undefined);assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
assert.deepEqual(physical(done),committed);assert.equal(isAdvisor(player(done,f.bg),'polar_sink'),prevented);
assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(prevented));
assert.deepEqual(inventory(done),f.originalInventory);
});
void test(`native ${parent} accepted Intrusion permits same-type advisor accompaniment without another flip`,()=>{
const f=fixture({parent,stance:'advisors'}),source=physical(f.game),pending=act(f.game,f.bg,f.accompany);
assert.deepEqual(physical(pending),source);
const done=finish(allow(pending));assert.equal(done.pendingSpiritualAdvisor,undefined);
assert.equal(done.response?.kind==='advisorFlip',false);assert.equal(player(done,f.bg).forces[f.foreignLocation],2);
assert.equal(isAdvisor(player(done,f.bg),f.foreignDestination),true);
assert.equal(player(done,f.bg).reserves,player(f.game,f.bg).reserves-1);
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(`native ${parent} Polar fighter matching ${prevented?'restores the owned chooser':'commits the incoming fighter'} after distinct shipping allowance`,()=>{
const f=fixture({parent,stance:'polarFighters'}),shipping=act(f.game,f.bg,f.sink),pending=allow(shipping);
assert.equal(pending.response?.kind,'advisorFlip');assert.ok(pending.response?.spiritualAdvisorSource);
assert.equal(pending.response?.automaticAdvisorFlip,undefined);assert.equal(pending.pendingSpiritualAdvisor?.stage,'matching');
assert.deepEqual(physical(pending),physical(f.game));
let done=prevented?cancel(pending,f.holder,f.karama):allow(pending);
if(prevented){
assert.equal(done.decision?.kind,'advisor');assert.equal(done.pendingSpiritualAdvisor?.stage,'offer');
assert.deepEqual(physical(done),physical(f.game));reject(done,f.bg,f.sink);reject(done,f.bg,f.accompany);
assert.ok(viewGame(done,f.bg).spiritualAdvisorChoices?.choices.every(c=>c.blocked));
done=act(done,f.bg,f.decline);
}else{
assert.equal(player(done,f.bg).forces['polar_sink:0'],2);
assert.equal(player(done,f.bg).reserves,player(f.game,f.bg).reserves-1);
assert.equal(isAdvisor(player(done,f.bg),'polar_sink'),false);
}
done=finish(done);assert.equal(done.pendingSpiritualAdvisor,undefined);
assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(prevented));assert.deepEqual(inventory(done),f.originalInventory);
});
void test(`native ${parent} changed original parent rejects before a shipping counter spends Karama`,()=>{
const f=fixture({parent}),pending=act(f.game,f.bg,f.sink);
pending.pendingSpiritualAdvisor!.signature='different-original-parent';
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(`controlled ${parent} late retained-fighter change restores the legal chooser before any matching or placement`,()=>{
const f=fixture({parent,stance:'advisors'}),pending=act(f.game,f.bg,f.accompany);
// A controlled late stance change tests revalidation; it is not a native
// earned action while this declared power is pending.
delete player(pending,f.bg).advisors![f.foreignDestination];
const before=physical(pending),done=allow(pending);
assert.equal(done.decision?.kind,'advisor');assert.equal(done.pendingSpiritualAdvisor?.stage,'offer');
assert.equal(done.response,null);assert.deepEqual(physical(done),before);
assert.ok(viewGame(done,f.bg).spiritualAdvisorChoices?.choices.filter(c=>c.accompany).every(c=>c.blocked));
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));
});
}
void test('a second genuine foreign shipment joins the continuously-alone held advisor group without rearming conversion',()=>{
const f=fixture(),shipping=act(f.game,f.bg,f.sink),pending=allow(shipping);
let g=finish(cancel(pending,f.holder,f.karama));
const held=player(g,f.bg).advisors?.polar_sink?.aloneFlipHold;assert.ok(held);
g=act(g,f.holder,{type:'endMovement'});
g=advance(g,state=>state.phase===5&&state.active===f.other&&clean(state));
g=act(g,f.other,{type:'ship',territory:'carthag',sector:11,amount:1,elite:0,allyPayment:0});
g=advance(g,state=>state.decision?.kind==='advisor');
const before=player(g,f.bg).reserves;
g=act(g,f.bg,f.sink);
assert.equal(g.pendingSpiritualAdvisor,undefined);assert.equal(g.response?.automaticAdvisorFlip,undefined);
assert.equal(player(g,f.bg).forces['polar_sink:0'],2);
assert.equal(player(g,f.bg).reserves,before-1);
assert.equal(player(g,f.bg).advisors?.polar_sink?.aloneFlipHold,held);
assert.equal(isAdvisor(player(g,f.bg),'polar_sink'),true);assert.deepEqual(inventory(g),f.originalInventory);
});
void test('existing profiles choose projected legal targets and answer all applicable Spiritual layers',()=>{
for(const parent of ['ordinary','ambassador'] as const)for(const stance of ['fighters','advisors','polarFighters'] as const)
for(const profile of DIFFICULTIES){
const f=fixture({parent,stance}),own=viewGame(f.game,f.bg);own.players.find(p=>p.id===f.bg)!.bot=profile;
const choices=botActions(own);assert.ok(choices.length);const shipping=act(f.game,f.bg,choices[0]);
assert.ok(shipping.response?.spiritualAdvisorSource);
const counter=viewGame(shipping,f.holder);counter.players.find(p=>p.id===f.holder)!.bot=profile;
const answers=botActions(counter);assert.ok(answers.length);assert.doesNotThrow(()=>act(shipping,f.holder,answers[0]));
const next=allow(shipping);
if(stage(next)){
const reply=viewGame(next,f.holder);reply.players.find(p=>p.id===f.holder)!.bot=profile;
const actions=botActions(reply);assert.ok(actions.length);assert.doesNotThrow(()=>act(next,f.holder,actions[0]));
}
if(stance==='polarFighters'){
const restored=cancel(next,f.holder,f.karama),view=viewGame(restored,f.bg);view.players.find(p=>p.id===f.bg)!.bot=profile;
const alternatives=botActions(view);assert.ok(alternatives.length);assert.doesNotThrow(()=>act(restored,f.bg,alternatives[0]));
}
}
});