Connect native paired E2 Bureaucrat funding

This commit is contained in:
Quintine committed 2026-10-13 09:01:43 +11:00
1 parent c3d1896673
commit b207dd0479
13 files changed
+998 -49

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advanceBureaucratPairedFunding } from './fixture-bureaucrat-paired-funding';
import {
createBureaucratPairedAuctionFixture, payBureaucratPairedAuction,
allowPairedAuctionResponses, choosePairedAuctionBureaucrat,
pairedAuctionPlayer, pairedAuctionPhysicalCards,
type BureaucratPairedAuctionFixture, type PairedAuctionSource,
} from './fixture-bureaucrat-paired-auction';
function rejectUnchanged(game:Game,actor:string,action:Action) {
const before=JSON.stringify(game);
assert.throws(()=>applyAction(game,actor,action));
assert.equal(JSON.stringify(game),before);
}
function assertCosts(f:BureaucratPairedAuctionFixture,game:Game) {
assert.equal(pairedAuctionPlayer(game,f.payer).spice,pairedAuctionPlayer(f.beforePledge,f.payer).spice-f.payerShare);
if(f.donor!==f.payee) assert.equal(pairedAuctionPlayer(game,f.donor).spice,
pairedAuctionPlayer(f.beforePledge,f.donor).spice-f.donorShare);
assert.equal(game.aid[f.donor]?.amount ?? 0,0);
assert.equal(pairedAuctionPlayer(game,f.payer).hand.filter(c=>c.id===f.card).length,1);
assert.deepEqual(pairedAuctionPhysicalCards(game),f.physical);
}
function assertOutcome(f:BureaucratPairedAuctionFixture,done:Game,diverted:number) {
assertCosts(f,done);
assert.equal(pairedAuctionPlayer(done,f.payee).spice,
pairedAuctionPlayer(f.beforePledge,f.payee).spice-(f.donor===f.payee?f.donorShare:0)+f.amount-diverted);
const buyer=pairedAuctionPlayer(done,f.payer),before=pairedAuctionPlayer(f.beforeAction,f.payer);
assert.deepEqual(buyer.hand.map(c=>c.id),[...before.hand.map(c=>c.id),f.card,f.beforeAction.deck[0].id]);
assert.equal(done.currentAuctionSale,null);
assert.equal(done.bureaucratPayments?.used.length ?? 0,diverted?1:0);
assert.equal(viewGame(done,f.trainer).bureaucrat.usedThisPhase,diverted===2);
if(f.source==='normal') {
assert.equal(done.auction!.index,f.beforeAction.auction!.index+1);
assert.equal(done.auction!.cards[done.auction!.index].id,f.beforeAction.auction!.cards[f.beforeAction.auction!.index+1].id);
assert.equal(done.auction!.bid,0);
assert.equal(done.auction!.bidder,null);
} else if(f.source==='cache'&&f.position==='first') {
assert.equal(done.richeseAuction,null);
assert.equal(done.auction!.index,0);
assert.equal(done.auction!.cards.length,done.richeseBidding!.normalCount);
assert.equal(done.richeseCache!.some(c=>c.id===f.card),false);
} else if(f.source==='blackMarket') {
assert.equal(done.decision?.kind,'richeseDeclaration');
assert.equal(done.richeseBidding!.blackMarketSold,true);
assert.equal(pairedAuctionPlayer(done,f.richese).hand.some(c=>c.id===f.card),false);
}
}
for(const advanced of [false,true]) {
const sources:PairedAuctionSource[]=advanced?['normal','cache','blackMarket']:['normal','cache'];
for(const source of sources) {
for(const [payerShare,donorShare] of [[5,3],[3,5],[5,5],[0,5]]) {
for(const redirect of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} paired ${source} ${payerShare}+${donorShare}: ${redirect?'Bank two':'full native income'}, one purchase and original H bonus`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced,source,payerShare,donorShare});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
assert.equal(pending.decision?.kind,'bureaucratPayment');
assertCosts(f,pending);
assert.equal(pairedAuctionPlayer(pending,f.payee).spice,pairedAuctionPlayer(f.beforeAction,f.payee).spice);
assert.equal(pairedAuctionPlayer(pending,f.payer).hand.length,pairedAuctionPlayer(f.beforeAction,f.payer).hand.length+1);
const done=allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending,redirect));
assertOutcome(f,done,redirect?2:0);
rejectUnchanged(done,f.trainer,{type:'decision',event:pending.bureaucratPaymentEvent,redirect:true});
});
}
}
void test(`${advanced?'Advanced':'Basic'} paired ${source} three plus three never pools a qualifying contribution`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced,source,payerShare:3,donorShare:3});
const done=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
assert.equal(viewGame(done,f.trainer).bureaucrat.pending,null);
assertOutcome(f,done,0);
});
void test(`${advanced?'Advanced':'Basic'} paired ${source}: gross eight is public, allied shares and qualifying identity are not`,()=>{
const primary=createBureaucratPairedAuctionFixture({advanced,source,payerShare:5,donorShare:3});
const donor=createBureaucratPairedAuctionFixture({advanced,source,payerShare:3,donorShare:5});
const a=allowPairedAuctionResponses(payBureaucratPairedAuction(primary));
const b=allowPairedAuctionResponses(payBureaucratPairedAuction(donor));
for(const viewer of [primary.trainer,primary.guild,primary.payee]) {
const left=viewGame(a,viewer),right=viewGame(b,viewer);
const {event:_left,...invoice}=left.bureaucrat.pending!;
const {event:_right,...other}=right.bureaucrat.pending!;
assert.equal(invoice.payer,null);
assert.equal(invoice.payee,primary.payee);
assert.equal(invoice.amount,8);
assert.deepEqual(invoice,other);
assert.equal('contributions' in invoice,false);
assert.equal('signature' in invoice,false);
if(source==='normal') {
assert.equal(left.auction!.bid,8);
assert.equal(left.auction!.allyPayment,undefined);
} else {
assert.equal(left.richeseAuction!.revealedBids![primary.payer],8);
assert.deepEqual(left.richeseAuction!.revealedBids,right.richeseAuction!.revealedBids);
assert.equal('richeseFunding' in left,false);
assert.equal('contributions' in left.richeseAuction!,false);
}
}
assertOutcome(primary,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(a)),2);
assertOutcome(donor,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(b)),2);
});
void test(`${advanced?'Advanced':'Basic'} paired ${source}: cross-actor and stale choices cannot debit, refund or duplicate a native purchase`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced,source,payerShare:5,donorShare:5});
const bid=applyAction(f.game,f.payer,f.action);
rejectUnchanged(bid,f.donor,{type:'pledgeAid',amount:3});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
rejectUnchanged(pending,f.donor,{type:'decision',event:pending.bureaucratPaymentEvent,redirect:true});
rejectUnchanged(pending,f.trainer,{type:'decision',event:'old-richese-invoice',redirect:true});
rejectUnchanged(pending,f.payer,f.action);
assertOutcome(f,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
});
}
void test(`${advanced?'Advanced':'Basic'} original ordinary Emperor Karama cancellation banks the full funded invoice without a Bureaucrat use`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced,payerShare:5,donorShare:5,counter:true});
const paid=payBureaucratPairedAuction(f);
assert.equal(paid.response?.kind,'emperorIncome');
assertCosts(f,paid);
assert.equal(viewGame(paid,f.trainer).bureaucrat.pending,null);
const done=allowPairedAuctionResponses(applyAction(paid,f.trainer,{type:'card',card:f.counterCard,mode:'cancel'}));
assert.equal(pairedAuctionPlayer(done,f.emperor).spice,pairedAuctionPlayer(f.beforeAction,f.emperor).spice);
assert.equal(pairedAuctionPlayer(done,f.payer).hand.length,pairedAuctionPlayer(f.beforeAction,f.payer).hand.length+2);
assert.equal(done.discard.filter(c=>c.id===f.counterCard).length,1);
assert.equal(done.bureaucratPayments?.used.length ?? 0,0);
assert.equal(done.auction!.index,1);
assertCosts(f,done);
});
void test(`${advanced?'Advanced':'Basic'} original Karama purchases remain normal-only, not a free cache invoice`,()=>{
const normal=createBureaucratPairedAuctionFixture({advanced,counter:true});
const done=allowPairedAuctionResponses(applyAction(normal.game,normal.trainer,{type:'card',card:normal.counterCard,mode:'purchase'}));
assert.equal(pairedAuctionPlayer(done,normal.trainer).spice,pairedAuctionPlayer(normal.game,normal.trainer).spice);
assert.equal(pairedAuctionPlayer(done,normal.emperor).spice,pairedAuctionPlayer(normal.game,normal.emperor).spice);
assert.equal(pairedAuctionPlayer(done,normal.trainer).hand.filter(c=>c.id===normal.card).length,1);
assert.equal(done.discard.filter(c=>c.id===normal.counterCard).length,1);
assert.equal(done.auction!.index,1);
assert.equal(done.bureaucratPayments?.used.length ?? 0,0);
const cache=createBureaucratPairedAuctionFixture({advanced,source:'cache',counter:true});
rejectUnchanged(cache.game,cache.trainer,{type:'card',card:cache.counterCard,mode:'purchase'});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(cache));
assertOutcome(cache,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
});
void test(`${advanced?'Advanced':'Basic'} actual last-cache closing advances without another funded debit or purchase`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced,source:'cache',method:'onceAround',position:'last',payerShare:5,donorShare:5});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
const paid=allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending));
assertOutcome(f,paid,2);
const closed=advanceBureaucratPairedFunding(paid,state=>state.phase>3&&!state.phaseOpening&&!state.response&&!state.decision);
assert.equal(closed.turn,paid.turn);
assert.equal(pairedAuctionPlayer(closed,'p').spice,pairedAuctionPlayer(paid,'p').spice);
assert.equal(pairedAuctionPlayer(closed,'f').spice,pairedAuctionPlayer(paid,'f').spice);
assert.equal(pairedAuctionPlayer(closed,'r').spice,pairedAuctionPlayer(paid,'r').spice);
assert.deepEqual(pairedAuctionPlayer(closed,'p').hand,pairedAuctionPlayer(paid,'p').hand);
assert.equal(closed.bureaucratPayments?.used.length,1);
assert.equal(viewGame(closed,'b').bureaucrat.usedThisPhase,false);
assert.equal(closed.currentAuctionSale,null);
assert.equal(closed.auction,null);
assert.equal(closed.richeseAuction,null);
});
for(const donor of ['b','e'] as const) {
for(const [payerShare,donorShare] of [[3,5],[5,3]]) {
void test(`${advanced?'Advanced':'Basic'} original worm alliance: ${donor==='b'?'trainer':'Emperor payee'} contribution cannot qualify or mask H five`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced,donor,payerShare,donorShare});
const allowed=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
const qualifies=payerShare===5;
assert.equal(viewGame(allowed,f.trainer).bureaucrat.pending!==null,qualifies);
const done=qualifies?allowPairedAuctionResponses(choosePairedAuctionBureaucrat(allowed)):allowed;
assertOutcome(f,done,qualifies?2:0);
});
}
}
}
void test('Basic original Richese opening cannot declare a Black Market lot',()=>{
const f=createBureaucratPairedAuctionFixture({source:'cache'});
assert.equal(f.opening.decision?.kind,'richeseDeclaration');
rejectUnchanged(f.opening,f.richese,{type:'decision',event:f.opening.richeseBidding!.event,
card:pairedAuctionPlayer(f.opening,f.richese).hand[0].id,method:'silent'});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
assertOutcome(f,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
});
void test('sealed native Richese bid conceals the amount until original bidders finish',()=>{
const f=createBureaucratPairedAuctionFixture({advanced:true,source:'cache',payerShare:3,donorShare:5});
const submitted=applyAction(f.game,f.payer,f.action);
for(const viewer of [f.trainer,f.richese,f.guild]) {
const lot=viewGame(submitted,viewer).richeseAuction!;
assert.equal(lot.revealedBids,null);
assert.equal(lot.ownBid,null);
assert.equal(lot.ownAllyPayment,0);
}
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
assert.equal(viewGame(pending,f.trainer).richeseAuction!.revealedBids![f.payer],8);
assertOutcome(f,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
});
for(const difficulty of DIFFICULTIES) {
void test(`${difficulty} original paired Bureaucrat bot resolves Richese seller invoice and releases native compulsory declaration`,()=>{
const f=createBureaucratPairedAuctionFixture({advanced:true,source:'blackMarket',payerShare:5,donorShare:5});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
const view=viewGame(pending,f.trainer);
view.players.find(p=>p.id===f.trainer)!.bot=difficulty;
const action=botActions(view)[0];
const done=allowPairedAuctionResponses(applyAction(pending,f.trainer,action));
assertOutcome(f,done,action.redirect?2:0);
});
}
void test('one physical use covers both five contributions; original bonus cancellation leads to a genuine last-space purchase and cache closing',()=>{
const f=createBureaucratPairedAuctionFixture({advanced:true,payerShare:5,donorShare:5,counter:true});
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
let game=choosePairedAuctionBureaucrat(pending);
assert.equal(game.response?.kind,'harkonnenBonus');
game=allowPairedAuctionResponses(applyAction(game,f.trainer,{type:'card',card:f.counterCard,mode:'cancel'}));
assertCosts(f,game);
assert.equal(pairedAuctionPlayer(game,f.emperor).spice,pairedAuctionPlayer(f.beforeAction,f.emperor).spice+8);
assert.equal(pairedAuctionPlayer(game,f.payer).hand.length,3);
assert.equal(game.discard.filter(c=>c.id===f.counterCard).length,1);
assert.equal(game.bureaucratPayments!.used.length,1);
const used=structuredClone(game.bureaucratPayments!.used);
while(pairedAuctionPlayer(game,f.payer).hand.length<handLimit(pairedAuctionPlayer(game,f.payer))) {
game=advanceBureaucratPairedFunding(game,s=>!!s.auction&&s.auction.active===f.payer&&!s.response&&!s.decision);
const before=structuredClone(game),buyer=pairedAuctionPlayer(before,f.payer),index=before.auction!.index;
const card=before.auction!.cards[index].id;
game=applyAction(game,f.payer,{type:'bid',amount:1,allyPayment:0});
for(let n=0;!game.currentAuctionSale&&game.auction?.index===index&&n<game.players.length+2;n++) {
if(game.decision?.kind==='auctionPayment') {game=applyAction(game,f.payer,{type:'decision',karama:false});break;}
game=applyAction(game,game.auction!.active,{type:'passBid'});
}
game=allowPairedAuctionResponses(game);
const bought=pairedAuctionPlayer(game,f.payer);
assert.equal(bought.spice,buyer.spice-1);
assert.equal(bought.hand.filter(c=>c.id===card).length,1);
assert.equal(bought.hand.length,Math.min(handLimit(buyer),buyer.hand.length+2));
if(buyer.hand.length===handLimit(buyer)-1) {
assert.deepEqual(game.deck,before.deck,'Last-space purchase has no overflow bonus draw');
assert.equal(bought.hand.length,buyer.hand.length+1);
}
assert.equal(pairedAuctionPlayer(game,f.emperor).spice,pairedAuctionPlayer(before,f.emperor).spice+1);
assert.deepEqual(game.bureaucratPayments!.used,used);
assert.equal(viewGame(game,f.trainer).bureaucrat.pending,null);
}
assert.equal(pairedAuctionPlayer(game,f.payer).hand.length,8);
game=advanceBureaucratPairedFunding(game,s=>s.turn===2&&s.phase===4);
assert.equal(game.auction,null);
assert.equal(game.currentAuctionSale,null);
assert.equal(game.richeseAuction,null);
assert.deepEqual(pairedAuctionPhysicalCards(game),f.physical);
});
+276
View File
@@ -0,0 +1,276 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { advanceBureaucratPairedFunding } from './fixture-bureaucrat-paired-funding';
import {
allowBureaucratPairedShipmentIncome, createBureaucratPairedShipmentFixture,
declareBureaucratPairedShipment,
} from './fixture-bureaucrat-paired-shipment';
const seat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
const counters = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, elites: p.elites }));
const wallets = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, bribes: p.bribes }));
function rejectUnchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function choose(game: Game, redirect: boolean) {
assert.equal(game.decision?.kind, 'bureaucratPayment');
return applyAction(structuredClone(game), 'b', {
type: 'decision', event: game.decision!.event, redirect,
});
}
void test('paired Basic and Advanced native shipments retain every original army and debit full allied costs for one flat Guild diversion', () => {
for (const advanced of [false, true])
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]])
for (const redirect of [false, true]) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary, donor });
const original = f.beforePledge;
assert.equal(seat(f.beforeAction, 'p').spice, advanced ? 9 : 7);
assert.equal(seat(f.beforeAction, 'f').spice, 6 - donor);
assert.equal(f.beforeAction.aid.f.amount, donor);
assert.deepEqual(counters(f.beforeAction), counters(original));
assert.ok(f.actions.some(step => step.action.type === 'bribe' && step.actor === 'p' && step.action.amount === 3));
assert.equal(seat(f.afterFirstMentat, 'f').spice, 6);
assert.ok(f.actions.some(step => step.actor === 'r' && step.action.type === 'decision' && typeof step.action.position === 'string'));
const { declared, paid } = declareBureaucratPairedShipment(f);
if (advanced) {
assert.equal(declared.decision?.kind, 'guildShipment');
assert.equal(declared.decision?.player, 'g');
assert.deepEqual(wallets(declared), wallets(f.beforeAction));
assert.deepEqual(counters(declared), counters(f.beforeAction));
} else {
assert.equal(declared.response?.kind, 'guildIncome');
assert.deepEqual(counters(declared), counters(paid));
}
assert.equal(paid.response?.kind, 'guildIncome');
assert.equal(paid.response?.amount, f.amount);
assert.equal(seat(paid, 'p').spice, seat(original, 'p').spice - primary);
assert.equal(seat(paid, 'f').spice, 6 - donor);
assert.equal(paid.aid.f.amount, 0);
assert.equal(seat(paid, 'g').spice, advanced ? 6 : 5);
assert.equal(seat(paid, 'p').reserves, 10 - f.amount);
assert.deepEqual(seat(paid, 'p').forces, { 'carthag:11': 10, [f.target]: f.amount });
assert.equal(seat(paid, 'p').shipped, true);
for (const id of ['b', 'r', 'f', 'e', 'g']) {
assert.deepEqual(seat(paid, id).forces, seat(original, id).forces);
assert.equal(seat(paid, id).reserves, seat(original, id).reserves);
}
const pending = allowBureaucratPairedShipmentIncome(paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assert.deepEqual(counters(pending), counters(paid));
assert.deepEqual(wallets(pending), wallets(paid));
const after = choose(pending, redirect);
assert.equal(seat(after, 'g').spice, seat(original, 'g').spice + f.amount - (redirect ? 2 : 0));
for (const id of ['p', 'f', 'b', 'r', 'e']) {
assert.equal(seat(after, id).spice, seat(paid, id).spice);
assert.equal(seat(after, id).bribes, seat(paid, id).bribes);
}
assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0),
original.players.reduce((sum, p) => sum + p.spice, 0) - (redirect ? 2 : 0));
assert.deepEqual(counters(after), counters(paid));
assert.equal(seat(after, 'p').shipped, true);
assert.equal(after.bureaucratPayments?.used.length ?? 0, Number(redirect));
assert.equal(viewGame(after, 'b').bureaucrat.usedThisPhase, redirect);
assert.equal(after.bureaucratPayments?.pending, undefined);
assert.deepEqual(after.leaderSkills?.assignments, pending.leaderSkills?.assignments);
assert.deepEqual(after.deck, pending.deck);
assert.deepEqual(after.discard, pending.discard);
assert.deepEqual(after.players.map(p => p.hand), pending.players.map(p => p.hand));
assert.equal(after.phase, 5);
assert.equal(after.active, 'p');
rejectUnchanged(after, 'b', { type: 'decision', event: pending.decision!.event, redirect });
rejectUnchanged(after, 'p', f.action);
const ended = applyAction(structuredClone(after), 'p', { type: 'endMovement' });
assert.notEqual(ended.active, 'p');
assert.deepEqual(counters(ended), counters(after));
// Advanced can enter Collection here: its native income is not a refund.
rejectUnchanged(ended, 'p', { type: 'endMovement' });
}
});
void test('original Karama cancels the gross paired Guild income to Bank after full debits and army commitment, without an offer or use', () => {
for (const advanced of [false, true])
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary, donor });
const { paid } = declareBureaucratPairedShipment(f);
assert.equal(paid.response?.amount, f.amount);
assert.equal(viewGame(paid, f.responder).responseControls?.cancelCards.includes(f.karama.id), true);
const after = applyAction(structuredClone(paid), f.responder, {
type: 'card', mode: 'cancel', card: f.karama.id,
});
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice);
assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - primary);
assert.equal(seat(after, 'f').spice, 6 - donor);
assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0),
f.beforePledge.players.reduce((sum, p) => sum + p.spice, 0) - f.amount);
assert.deepEqual(counters(after), counters(paid));
assert.equal(seat(after, 'p').shipped, true);
assert.equal(after.discard.filter(card => card.id === f.karama.id).length, 1);
assert.equal(seat(after, f.responder).hand.some(card => card.id === f.karama.id), false);
assert.equal(after.bureaucratPayments?.pending, undefined);
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(viewGame(after, 'b').bureaucrat.pending, null);
assert.notEqual(after.decision?.kind, 'bureaucratPayment');
assert.equal(after.response, null);
rejectUnchanged(after, f.responder, { type: 'card', mode: 'cancel', card: f.karama.id });
}
});
void test('paired native receipts never pool sub-five shares or qualify the CHOAM owner contribution alone', () => {
for (const advanced of [false, true])
for (const options of [
{ primary: 3, donor: 3 }, { primary: 4, donor: 3 },
{ primary: 3, donor: 5, ownerFunding: true }, { primary: 0, donor: 5, ownerFunding: true },
]) {
const f = createBureaucratPairedShipmentFixture({ advanced, ...options });
if (options.ownerFunding) {
assert.equal(seat(f.beforePledge, 'p').ally, 'b');
assert.equal(seat(f.beforePledge, 'b').ally, 'p');
assert.equal(seat(f.beforePledge, 'f').ally, null);
}
const { paid } = declareBureaucratPairedShipment(f);
const after = allowBureaucratPairedShipmentIncome(paid);
assert.notEqual(after.decision?.kind, 'bureaucratPayment');
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount);
assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - options.primary);
assert.equal(seat(after, f.donor).spice, seat(f.beforePledge, f.donor).spice - options.donor);
assert.deepEqual(counters(after), counters(paid));
}
});
void test('actual H/CHOAM alliance excludes its native owner funding leg without masking Harkonnen five', () => {
for (const advanced of [false, true])
for (const donor of [3, 5]) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor, ownerFunding: true });
assert.equal(f.donor, 'b');
assert.equal(seat(f.alliance, 'p').ally, 'b');
assert.equal(seat(f.alliance, 'b').ally, 'p');
const { paid } = declareBureaucratPairedShipment(f);
const pending = allowBureaucratPairedShipmentIncome(paid);
const after = choose(pending, true);
assert.equal(seat(after, 'b').spice, 26 - donor);
assert.equal(seat(after, 'p').spice, (advanced ? 9 : 7) - 5);
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount - 2);
assert.equal(seat(after, 'f').spice, 6);
assert.equal(after.bureaucratPayments?.used.length, 1);
assert.deepEqual(counters(after), counters(paid));
}
});
void test('paired pending views reveal only public gross and payee with null payer; cross-actor and stale choices reject unchanged', () => {
for (const advanced of [false, true])
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary, donor });
const { paid } = declareBureaucratPairedShipment(f);
for (const viewer of paid.players) {
const response = viewGame(paid, viewer.id).response;
assert.equal(response?.amount, f.amount);
assert.equal(response?.owner, 'g');
assert.equal(response?.bureaucratPayment, undefined);
assert.equal(response?.guildContributions, undefined);
assert.equal(response?.occupiedPayment, undefined);
}
const pending = allowBureaucratPairedShipmentIncome(paid);
for (const viewer of pending.players) {
const view = viewGame(pending, viewer.id);
assert.deepEqual(view.bureaucrat.pending, {
event: pending.bureaucratPaymentEvent, owner: 'b', payer: null, payee: 'g',
amount: f.amount, kind: 'shipment', redirect: 2,
});
assert.equal('bureaucratPayments' in view, false);
assert.equal('bureaucratUseEvents' in view, false);
for (const other of view.players.filter(p => p.id !== viewer.id)) {
assert.equal(other.spice, undefined);
assert.equal(other.hand, undefined);
}
}
for (const id of ['p', 'f', 'r', 'e', 'g'])
rejectUnchanged(pending, id, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
rejectUnchanged(pending, 'b', { type: 'decision', event: 'stale-income', redirect: true });
rejectUnchanged(pending, 'p', { type: 'endMovement' });
rejectUnchanged(pending, 'f', { type: 'pledgeAid', amount: 1 });
}
});
void test('all four minimal bot choices settle the genuine paired private-funded receipt and resume native Movement', () => {
for (const advanced of [false, true])
for (const bot of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor: 5 });
const pending = allowBureaucratPairedShipmentIncome(declareBureaucratPairedShipment(f).paid);
const view = viewGame(pending, 'b');
view.players.find(p => p.id === 'b')!.bot = bot;
const actions = botActions(view);
assert.equal(actions.length, 1);
const after = applyAction(structuredClone(pending), 'b', actions[0]);
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + 8);
assert.equal(after.bureaucratPayments?.used.length, 1);
assert.deepEqual(counters(after), counters(pending));
for (const id of ['b', 'r', 'p', 'f', 'e']) assert.equal(seat(after, id).spice, seat(pending, id).spice);
assert.equal(after.phase, 5);
assert.equal(after.active, 'p');
const ended = applyAction(structuredClone(after), 'p', { type: 'endMovement' });
assert.notEqual(ended.active, 'p');
assert.deepEqual(counters(ended), counters(after));
rejectUnchanged(ended, 'b', actions[0]);
}
});
void test('decline preserves the physical Bureaucrat for the next real receipt; use is once per native phase and renews after real Mentat/Storm', () => {
for (const advanced of [false, true])
for (const redirect of [false, true]) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor: 3 });
const pending = allowBureaucratPairedShipmentIncome(declareBureaucratPairedShipment(f).paid);
const settled = choose(pending, redirect);
const following = applyAction(structuredClone(settled), 'e', { type: 'bribe', target: 'f', amount: 5 });
assert.equal(seat(following, 'e').spice, 5);
assert.deepEqual(counters(following), counters(settled));
let used: Game;
if (redirect) {
assert.notEqual(following.decision?.kind, 'bureaucratPayment');
assert.equal(seat(following, 'f').bribes, seat(settled, 'f').bribes + 5);
used = following;
} else {
assert.equal(following.decision?.kind, 'bureaucratPayment');
rejectUnchanged(following, 'b', { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
used = choose(following, true);
assert.equal(seat(used, 'f').bribes, seat(settled, 'f').bribes + 3);
assert.equal(seat(used, 'g').spice, seat(settled, 'g').spice);
}
assert.equal(used.bureaucratPayments?.used.length, 1);
assert.deepEqual(used.leaderSkills?.assignments, settled.leaderSkills?.assignments);
const ended = applyAction(used, 'p', { type: 'endMovement' });
const next = advanceBureaucratPairedFunding(ended, state => state.turn === 3 && state.phase === 1 &&
!state.phaseOpening && !state.response && !state.decision);
assert.equal(viewGame(next, 'b').bureaucrat.usedThisPhase, false);
const payment = applyAction(next, 'e', { type: 'bribe', target: 'f', amount: 5 });
assert.equal(payment.decision?.kind, 'bureaucratPayment');
const renewed = choose(payment, true);
assert.equal(renewed.bureaucratPayments?.used.length, 2);
assert.deepEqual(renewed.bureaucratPayments!.used.map(use => ({ turn: use.turn, phase: use.phase })),
[{ turn: 2, phase: 5 }, { turn: 3, phase: 1 }]);
assert.equal(viewGame(renewed, 'b').bureaucrat.usedThisPhase, true);
assert.deepEqual(counters(renewed), counters(next));
}
});
void test('actual pledged aid bounds reject unsupported shipment declarations without costs or counters', () => {
for (const advanced of [false, true]) {
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor: 3 });
assert.equal(viewGame(f.beforeAction, 'p').aid.available, 3);
assert.equal(viewGame(f.beforeAction, 'f').aid.pledged, 3);
assert.equal(viewGame(f.beforeAction, 'b').aid.available, 0);
rejectUnchanged(f.beforeAction, 'p', { ...f.action, allyPayment: 4 });
rejectUnchanged(f.beforeAction, 'f', f.action);
assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge));
assert.equal(seat(f.beforeAction, 'p').spice, advanced ? 9 : 7);
assert.equal(seat(f.beforeAction, 'f').spice, 3);
assert.equal(f.beforeAction.response, null);
assert.equal(f.beforeAction.decision, null);
}
});
+1 -36
View File
@@ -5,14 +5,7 @@ import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
basicChoamLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported,
} from '../game/leader-skill-profile';
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
import { sandmasterModeSupported } from '../game/sandmaster-movement';
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
import { basicChoamLeaderSkillsProfile } from '../game/leader-skill-profile';
import {
applyAction,
createGame,
@@ -30,7 +23,6 @@ import {
assertChoamSkillsCustody,
choamSkillsPlayer as player,
completedChoamSkillsGame,
initializedChoamSkillsOffers,
reloadChoamSkillsGame as reload,
rejectChoamSkillsAction as reject,
} from './choam-skills-fixture';
@@ -102,33 +94,6 @@ void test('all fourteen production skill faces deal through genuine Basic CHOAM
}
});
void test('all bot profiles make a legal private skill choice and exact CHOAM profile gates are shared', () => {
const offer = initializedChoamSkillsOffers({
requestedSkill: 'planetologist',
});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(offer), 'c');
view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty;
const action = botActions(view).find(
(candidate) => candidate.type === 'leaderSkill',
);
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(offer, 'c', action));
}
const game = completedChoamSkillsGame();
for (const gate of [
basicChoamLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported,
bureaucratPaymentModeSupported,
planetologistMovementModeSupported,
sandmasterModeSupported,
smugglerBattleModeSupported,
]) {
assert.equal(gate(game), true, gate.name);
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
}
});
void test('Advanced CHOAM and Basic Richese retain audit admission without entering the exact normal profile', () => {
for (const variant of ['advanced', 'richese'] as const) {
+154
View File
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createBureaucratPairedFundingSetup, advanceBureaucratPairedFunding,
type BureaucratPairedFundingStep,
} from './fixture-bureaucrat-paired-funding';
export type PairedAuctionSource = 'normal' | 'cache' | 'blackMarket';
export interface BureaucratPairedAuctionFixture {
game:Game;
opening:Game;
sourceSetup:Game;
beforePledge:Game;
beforeAction:Game;
payer:string;
donor:string;
trainer:string;
richese:string;
guild:string;
emperor:string;
source:PairedAuctionSource;
method:'silent'|'onceAround';
position:'first'|'last';
action:Action;
amount:number;
payerShare:number;
donorShare:number;
card:string;
counterCard?:string;
physical:string[];
actions:BureaucratPairedFundingStep[];
staging:string[];
payee:string;
}
export const pairedAuctionPlayer = (game:Game,id:string) => game.players.find(p=>p.id===id)!;
export function pairedAuctionPhysicalCards(game:Game):string[] {
return [...game.deck,...game.discard,...game.players.flatMap(p=>p.hand),
...(game.richeseCache ?? []),...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin==='normal')) ?? [])]
.map(card=>card.id).sort();
}
export function allowPairedAuctionResponses(state:Game):Game {
let game=state;
for(let n=0;game.response&&n<40;n++) {
const actor=game.players.find(p=>{
const controls=viewGame(game,p.id).responseControls;
return controls&&!controls.hasPassed&&controls.cancelCards.length>0;
});
assert.ok(actor,'An original unpassed counter holder must remain');
game=applyAction(game,actor.id,{type:'passResponse'});
}
assert.equal(game.response,null);
return game;
}
export function choosePairedAuctionOwner(game:Game,values:Record<string,unknown>):Game {
assert.ok(game.decision);
return applyAction(game,game.decision.player,{type:'decision',event:game.richeseBidding!.event,...values} as Action);
}
export function choosePairedAuctionBureaucrat(game:Game,redirect=true):Game {
assert.equal(game.decision?.kind,'bureaucratPayment');
return applyAction(game,game.decision!.player,{type:'decision',event:game.bureaucratPaymentEvent,redirect});
}
/** Only counter-card custody is controlled here: swap an original unplayed
* card without adding a card, hand space, wallet, army, receipt or turn. */
function counterKarama(game:Game,actor:string,staging:string[]):string {
const owner=pairedAuctionPlayer(game,actor),held=owner.hand.find(c=>c.effect==='karama');
if(held) return held.id;
const index=game.deck.findIndex(c=>c.effect==='karama');
assert.ok(index>=0&&owner.hand.length>0);
const outgoing=owner.hand[0],incoming=game.deck[index];
owner.hand[0]=incoming;game.deck[index]=outgoing;
staging.push(`Native counter window: swap original unplayed ${incoming.id} with held ${outgoing.id} for ${actor}; retain physical cards and hand size.`);
return incoming.id;
}
export function createBureaucratPairedAuctionFixture({
advanced=false,source='normal',payerShare=5,donorShare=3,counter=false,
method='silent',position='first',donor='f',
}:{advanced?:boolean;source?:PairedAuctionSource;payerShare?:number;donorShare?:number;
counter?:boolean;method?:'silent'|'onceAround';position?:'first'|'last';donor?:'f'|'b'|'e'}={}):BureaucratPairedAuctionFixture {
assert.ok(advanced||source!=='blackMarket','Basic has no Black Market');
const native=createBureaucratPairedFundingSetup(advanced);
let game=native.game;
const actions:BureaucratPairedFundingStep[]=[...native.actions],staging=[...native.staging];
if(donor!=='f') {
game=structuredClone(native.worm);
actions.splice(actions.findIndex(step=>step.action.type==='alliance'));
for(const [actor,target] of [['p',donor],[donor,'p']]) {
const action:Action={type:'alliance',target};
actions.push({actor,action});game=applyAction(game,actor,action);
}
game=advanceBureaucratPairedFunding(game,s=>s.turn===2&&s.phase===3,actions);
}
const opening=structuredClone(game);
const choose=(values:Record<string,unknown>)=>{game=choosePairedAuctionOwner(game,values);};
if(advanced) {
assert.equal(game.decision?.kind,'richeseBlackMarket');
if(source==='blackMarket') choose({card:pairedAuctionPlayer(game,'r').hand[0].id,method,direction:'clockwise'});
else choose({decline:true});
} else assert.equal(game.decision?.kind,'richeseDeclaration');
if(source!=='blackMarket') {
choose({position:source==='normal'?'last':position});
game=allowPairedAuctionResponses(game);
if(source==='cache') {
if(position==='last') game=advanceBureaucratPairedFunding(game,s=>s.decision?.kind==='richeseCache',actions);
assert.equal(game.decision?.kind,'richeseCache');
choose({card:game.richeseCache![0].id,method,direction:'clockwise'});
}
}
game=allowPairedAuctionResponses(game);
const sourceSetup=structuredClone(game),physical=pairedAuctionPhysicalCards(game);
const counterCard=counter?counterKarama(game,'b',staging):undefined;
if(source==='normal') {
for(let n=0;game.auction!.active!=='p'&&n<game.players.length;n++)
game=applyAction(game,game.auction!.active,{type:'passBid'});
assert.equal(game.auction!.active,'p');
} else if(method==='onceAround') {
for(let n=0;game.richeseAuction!.active!=='p'&&n<game.players.length;n++)
game=applyAction(game,game.richeseAuction!.active!,{type:'richeseBid',event:game.richeseAuction!.event,amount:null});
assert.equal(game.richeseAuction!.active,'p');
}
const beforePledge=structuredClone(game);
game=applyAction(game,donor,{type:'pledgeAid',amount:donorShare});
const beforeAction=structuredClone(game),amount=payerShare+donorShare;
const card=source==='normal'?game.auction!.cards[game.auction!.index].id:game.richeseAuction!.cardId;
const action:Action=source==='normal'?{type:'bid',amount,allyPayment:donorShare}:
{type:'richeseBid',event:game.richeseAuction!.event,amount,allyPayment:donorShare};
return {...native,game,donor,opening,sourceSetup,beforePledge,beforeAction,source,method,position,
action,amount,payerShare,donorShare,card,counterCard,physical,actions,staging,
payee:source==='normal'?'e':'r'};
}
export function payBureaucratPairedAuction(fixture:BureaucratPairedAuctionFixture,state=fixture.game):Game {
let game=applyAction(state,fixture.payer,fixture.action);
if(fixture.source==='normal') {
const index=fixture.beforeAction.auction!.index;
for(let n=0;!game.currentAuctionSale&&game.auction?.index===index&&n<game.players.length+2;n++) {
if(game.decision?.kind==='auctionPayment') {
game=applyAction(game,fixture.payer,{type:'decision',karama:false});break;
}
assert.ok(!game.decision&&!game.response);
game=applyAction(game,game.auction!.active,{type:'passBid'});
}
} else {
const event=fixture.beforeAction.richeseAuction!.event;
for(let n=0;game.richeseAuction?.event===event&&!game.richeseAuction.outcome&&n<game.players.length;n++) {
const lot=game.richeseAuction;
const actor=lot.method==='silent'?lot.order.find(id=>!(id in lot.sealed))!:lot.active!;
game=applyAction(game,actor,{type:'richeseBid',event,amount:lot.method==='silent'?0:null});
}
}
return game;
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { applyAction, type Action, type Game } from '../game/engine';
import {
initializePairedChoamNexusSkills, completePairedChoamSkillsSetup,
nextPairedChoamSkillsStep, type PairedChoamNexusSkillsOptions,
} from './fixture-paired-choam-nexus-skills';
export type BureaucratPairedFundingStep = {actor:string;action:Action};
const clean = (game:Game) => !game.phaseOpening && !game.response && !game.decision;
/** Original six-seat paired E2 setup; native CHOAM Bureaucrat, actual H/F
* bribe and worm alliance. Never replace armies, wallets or phase state. */
export function createBureaucratPairedFundingSetup(advanced=false) {
const options:PairedChoamNexusSkillsOptions = {advanced,
factions:['choam','richese','harkonnen','fremen','emperor','guild'],
seatIds:['b','r','p','f','e','g'],choamSkill:'bureaucrat'};
const actions:BureaucratPairedFundingStep[] = [];
const staging:string[] = [];
let game = completePairedChoamSkillsSetup(initializePairedChoamNexusSkills(options),options,actions);
const setup = structuredClone(game);
assert.ok(game.leaderSkills!.assignments.some(a=>a.owner==='b'&&a.skill==='bureaucrat'));
const act = (actor:string,action:Action) => {
actions.push({actor,action});
game = applyAction(game,actor,action);
};
const advance = (until:(state:Game)=>boolean) => {
game = advanceBureaucratPairedFunding(game,until,actions);
};
const orderSpice = (kinds:('land'|'worm')[]) => {
for (const [position,kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card,i)=>i>=position&&
(kind==='land'?'territory' in card:'worm' in card));
assert.ok(index>=position);
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
}
staging.push('Reorder only original still-unplayed Spice Cards.');
};
orderSpice(['land','land']);
advance(state=>state.phase===5&&state.active==='p'&&clean(state));
const beforeBribe = structuredClone(game);
act('p',{type:'bribe',target:'f',amount:3});
const bribe = structuredClone(game);
assert.equal(game.players.find(p=>p.id==='f')!.bribes,3);
advance(state=>state.turn===2&&state.phase===1&&clean(state));
const afterFirstMentat = structuredClone(game);
assert.equal(game.players.find(p=>p.id==='f')!.spice,6);
orderSpice(['worm','land','land']);
advance(state=>!!state.nexus&&!state.spiceWindow&&!state.spiceResolution&&clean(state));
const worm = structuredClone(game);
act('p',{type:'alliance',target:'f'});
act('f',{type:'alliance',target:'p'});
const alliance = structuredClone(game);
advance(state=>state.turn===2&&state.phase===3);
return {game,setup,beforeBribe,bribe,afterFirstMentat,worm,alliance,
payer:'p',donor:'f',trainer:'b',richese:'r',guild:'g',emperor:'e',actions,staging};
}
export function advanceBureaucratPairedFunding(state:Game,until:(game:Game)=>boolean,
actions?:BureaucratPairedFundingStep[]):Game {
let game = state;
for (let n=0;!until(game)&&n<1800;n++) {
assert.notEqual(game.status,'finished');
const next = nextPairedChoamSkillsStep(game);
assert.ok(next,'Original paired E2 controls must reach the payment boundary');
actions?.push(next);
game = applyAction(game,next.actor,next.action);
}
assert.ok(until(game));
return game;
}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
advanceBureaucratPairedFunding, createBureaucratPairedFundingSetup,
type BureaucratPairedFundingStep,
} from './fixture-bureaucrat-paired-funding';
import { holdPairedSkillsCard } from './fixture-paired-choam-nexus-skills';
export function createBureaucratPairedShipmentFixture({
advanced = false, primary = 5, donor = 3, ownerFunding = false,
}: { advanced?: boolean; primary?: number; donor?: number; ownerFunding?: boolean } = {}) {
const setup = createBureaucratPairedFundingSetup(advanced);
const allianceIndex = setup.actions.findIndex(step => step.action.type === 'alliance');
const actions = ownerFunding ? setup.actions.slice(0, allianceIndex) : [...setup.actions];
const staging = [...setup.staging];
const donorId = ownerFunding ? setup.trainer : setup.donor;
let game = structuredClone(ownerFunding ? setup.worm : setup.game);
if (ownerFunding) {
for (const [actor, target] of [[setup.payer, donorId], [donorId, setup.payer]]) {
const action: Action = { type: 'alliance', target };
actions.push({ actor, action });
game = applyAction(game, actor, action);
}
}
const alliance = ownerFunding ? structuredClone(game) : setup.alliance;
game = advanceBureaucratPairedFunding(game, state => state.phase === 5 &&
state.active === setup.payer && !state.phaseOpening && !state.response && !state.decision, actions);
assert.equal(game.turn, 2);
// Exact original six-seat prerequisite after native R/CHOAM auctions,
// Nexus choices and Revival; no force, wallet or phase staging.
assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, forces: p.forces })), [
{ id: 'b', spice: 26, reserves: 20, forces: {} },
{ id: 'r', spice: 5, reserves: 20, forces: {} },
{ id: 'p', spice: advanced ? 9 : 7, reserves: 10, forces: { 'carthag:11': 10 } },
{ id: 'f', spice: 6, reserves: 10, forces: { 'sietch_tabr:14': 10 } },
{ id: 'e', spice: 10, reserves: 20, forces: {} },
{ id: 'g', spice: advanced ? 6 : 5, reserves: 15, forces: { 'tueks_sietch:5': 5 } },
]);
assert.equal(game.players.find(p => p.id === setup.payer)!.ally, donorId);
assert.equal(game.players.find(p => p.id === donorId)!.ally, setup.payer);
const target = 'habbanya_ridge_sietch:17';
assert.ok(game.players.every(p => !p.forces[target]));
const sourceSetup = structuredClone(game);
const karama = holdPairedSkillsCard(game, setup.emperor, card => card.effect === 'karama', staging);
const beforePledge = structuredClone(game);
const pledge: Action = { type: 'pledgeAid', amount: donor };
actions.push({ actor: donorId, action: pledge });
game = applyAction(game, donorId, pledge);
assert.equal(game.aid[donorId].amount, donor);
const beforeAction = structuredClone(game);
const amount = primary + donor;
const action: Action = { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: donor };
return { ...setup, donor: donorId, alliance, game, sourceSetup, beforePledge, pledge, beforeAction,
action, primary, donorAmount: donor, amount, target, karama, responder: setup.emperor, actions, staging };
}
/** Declare once. Only the original Guild interception commits the queued ship. */
export function declareBureaucratPairedShipment(fixture: { beforeAction: Game; payer: string; action: Action }) {
const actions: BureaucratPairedFundingStep[] = [];
let game = applyAction(structuredClone(fixture.beforeAction), fixture.payer, fixture.action);
actions.push({ actor: fixture.payer, action: fixture.action });
const declared = structuredClone(game);
if (game.decision?.kind === 'guildShipment') {
const actor = game.decision.player;
const action: Action = { type: 'decision', allow: true };
actions.push({ actor, action });
game = applyAction(game, actor, action);
}
return { game, declared, paid: structuredClone(game), actions };
}
/** Allow the actual Karama window, never manufacture an income response. */
export function allowBureaucratPairedShipmentIncome(state: Game) {
let game = structuredClone(state);
for (let n = 0; game.response?.kind === 'guildIncome' && n < 12; n++) {
const actor = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(actor, 'Original Karama custody must yield a legal income responder');
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, 'guildIncome');
return game;
}