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.
97 lines
5.5 KiB
TypeScript
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]));
|
|
}
|
|
});
|