Files
Dune/tests/advisor-matching-engine.test.ts
T
Quintine 438a5692a6 Counter incoming advisor matching before normal movement and shipment
Preserve residents and unused actions when one selected packet cannot match. Bind public matching receipts to private declarations and share packet-opportunity denials with legal bot filtering.
2026-10-24 21:50:02 +11:00

97 lines
5.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 { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorMatchingMoveFixture, createAdvisorMatchingShipmentFixture,
actAdvisorMatchingMove as act, advisorMatchingMovePlayer as player,
advisorMatchingMoveInventory as inventory, commitAdvisorMatchingMove as commit,
} from './fixture-advisor-matching-move';
const fixtures=[
['advisor-to-fighter',()=>createAdvisorMatchingMoveFixture()],
['fighter-to-advisor',()=>createAdvisorMatchingMoveFixture({direction:'fightersToAdvisors'})],
['reserve-to-advisor',()=>createAdvisorMatchingShipmentFixture()],
] as const;
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,
tanks:p.tanks,spice:p.spice,advisors:p.advisors,shipped:p.shipped,moved:p.moved}));
function allow(g:Game){
for(let n=0;g.response?.advisorMatchingSource&&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.ok(!g.response?.advisorMatchingSource);return g;
}
function rejected(g:Game,actor:string,action:Action){
const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);
}
for(const [kind,fixture] of fixtures)for(const canceled of [false,true]){
void test(`native ${kind} matching ${canceled?'prevention':'allowance'} preserves residents and commits only an allowed incoming packet`,()=>{
const f=fixture(),before=structuredClone(f.game),pending=commit(f.game,f).game;
const source=pending.response?.advisorMatchingSource;assert.ok(source);
assert.equal(source.owner,f.bg);assert.equal(source.destination,f.destination);
assert.equal(source.advisors,kind!=='advisor-to-fighter');
assert.deepEqual(physical(pending),physical(before),'The matching response must precede transfer/payment/use.');
assert.deepEqual(physical(normalizeAutomaticGame(pending)),physical(before));
assert.ok(viewGame(pending,f.holder).responseControls?.cancelCards.includes(f.karama));
assert.ok(!JSON.stringify(viewGame(pending,f.holder).response).includes('order'));
assert.deepEqual(f.game,before);
let done=canceled?act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}):allow(pending);
assert.equal(done.pendingAdvisorMatching,undefined);assert.equal(done.response?.advisorMatchingSource,undefined);
assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(canceled));
assert.deepEqual(inventory(done),f.originalInventory);
if(canceled){
assert.deepEqual(physical(done),physical(before));assert.equal(done.advisorMatchingDenied?.length,1);
rejected(done,f.bg,f.action);
if(kind==='advisor-to-fighter') rejected(done,f.bg,{...f.action,sector:10});
done=act(done,f.bg,kind==='reserve-to-advisor'
? {type:'ship',territory:'imperial_basin',sector:9,amount:1,elite:0,allyPayment:0}
: f.ordinaryAction);
assert.equal(done.pendingAdvisorMatching,undefined);
assert.equal(player(done,f.bg).moved,kind==='reserve-to-advisor'?0:1);
assert.equal(player(done,f.bg).shipped,true);
}else{
assert.equal(player(done,f.bg).forces[f.destinationLocation],2);
assert.equal(isAdvisor(player(done,f.bg),f.destination),source.advisors);
if(kind==='reserve-to-advisor'){
assert.equal(player(done,f.bg).reserves,player(before,f.bg).reserves-1);
assert.equal(player(done,f.bg).spice,player(before,f.bg).spice-1);
assert.equal(player(done,f.bg).shipped,true);assert.equal(player(done,f.bg).moved,0);
}else{
assert.equal(player(done,f.bg).forces[f.sourceLocation],undefined);
assert.equal(player(done,f.bg).moved,1);
assert.equal(player(done,f.bg).spice,player(before,f.bg).spice);
}
}
});
}
void test('an earned same-type fighter sector reposition creates no matching counter',()=>{
const f=createAdvisorMatchingMoveFixture(),g=act(f.game,f.bg,f.ordinaryAction);
assert.equal(g.pendingAdvisorMatching,undefined);assert.equal(g.response?.advisorMatchingSource,undefined);
assert.equal(player(g,f.bg).forces['imperial_basin:10'],1);
assert.equal(player(g,f.bg).moved,1);assert.ok(player(g,f.holder).hand.some(c=>c.id===f.karama));
});
for(const [kind,fixture] of fixtures)void test(`${kind} mismatched private arrival rejects before a matching counter spends Karama`,()=>{
const f=fixture(),g=commit(f.game,f).game;
g.pendingAdvisorMatching!.signature='different-uncommitted-order';
rejected(g,f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.ok(player(g,f.holder).hand.some(c=>c.id===f.karama));
});
void test('all existing profiles can answer matching counters and choose a legal replacement after prevention',()=>{
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 response=botActions(own);assert.ok(response.length);
assert.doesNotThrow(()=>allow(act(pending,f.holder,response[0])));
const canceled=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),bg=viewGame(canceled,f.bg);
bg.players.find(p=>p.id===f.bg)!.bot=profile;
const replacement=botActions(bg);assert.ok(replacement.length);
assert.doesNotThrow(()=>act(canceled,f.bg,replacement[0]));
}
});