Connect native paired E2 Bureaucrat funding
This commit is contained in:
1 parent
c3d1896673
commit
b207dd0479
13 files changed
+998
-49
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user