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.
This commit is contained in:
Quintine committed 2026-10-24 21:50:02 +11:00
1 parent abf0305ddf
commit 438a5692a6
14 files changed
+992 -10

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
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]));
}
});