Connect classic allied Bureaucrat payment redirection

This commit is contained in:
Quintine committed 2026-10-13 08:24:56 +11:00
1 parent ec574ab0f5
commit c3d1896673
17 files changed
+1057 -220

No files matched your search

@@ -1,19 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BureaucratPayment } from '../components/bureaucrat-payment';
import type { GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
context,
);
},
});
function view(overrides: Record<string, unknown> = {}) {
return {
@@ -50,15 +41,6 @@ function markup(game: GameView, busy = false) {
);
}
void test('owned matching Bureaucrat decision shows both payment costs and public names', () => {
const html = markup(view({ me: 'owner' }));
assert.match(html, /payment from House Payer to House Payee is 5 spice/);
assert.match(html, /House Payee receives 3 spice/);
assert.match(html, /Bank receives 2/);
assert.match(html, /still pays the full amount/);
assert.match(html, /Redirect 2 spice to the Bank/);
assert.match(html, /Allow full 5-spice payment/);
});
void test('non-owner and stale decisions render no Bureaucrat controls', () => {
assert.equal(markup(view()), '');
@@ -73,13 +55,3 @@ void test('busy Bureaucrat controls are disabled', () => {
assert.ok((html.match(/disabled=""/g) ?? []).length >= 2);
});
void test('bribe redirection keeps the remaining payment unavailable until Mentat', () => {
const game = view({ me: 'owner' });
game.bureaucrat.pending!.kind = 'bribe';
const html = markup(game);
assert.match(html, /recipient keeps this spice in front of their shield until the next Mentat Pause/);
assert.match(html, /cannot be spent before then/);
assert.match(html, /House Payee receives 3 spice/);
});
aliases.deregister();
+20 -16
View File
@@ -121,6 +121,26 @@ void test('Bureaucrat requires two other players and tracks the unique physical
);
});
void test('allied receipts qualify per other payer, redirect only two once, and reject invalid contribution totals', () => {
const base = {owner:'b',payer:'p',payee:'g',amount:8,turn:2,phase:5,used:[]};
const contributions = [{payer:'p',amount:3},{payer:'f',amount:5}];
assert.deepEqual(quoteBureaucratPayment({...base,contributions}), {redirect:2,income:6});
assert.equal(quoteBureaucratPayment({...base,owner:'f',contributions}), null);
assert.deepEqual(quoteBureaucratPayment({...base,owner:'p',contributions}), {redirect:2,income:6});
assert.equal(quoteBureaucratPayment({...base,payee:'b',contributions}), null);
assert.equal(quoteBureaucratPayment({...base,amount:6,
contributions:[{payer:'p',amount:3},{payer:'f',amount:3}]}), null);
assert.deepEqual(quoteBureaucratPayment({...base,amount:10,
contributions:[{payer:'p',amount:5},{payer:'f',amount:5}]}), {redirect:2,income:8});
assert.deepEqual(quoteBureaucratPayment({...base,payer:'f',amount:5,
contributions:[{payer:'f',amount:5}]}), {redirect:2,income:3});
for (const invalid of [
[{payer:'p',amount:3},{payer:'f',amount:4}],
[{payer:'p',amount:3},{payer:'p',amount:5}],
[{payer:'p',amount:3},{payer:'f',amount:-1}],
]) assert.throws(() => quoteBureaucratPayment({...base,contributions:invalid}));
});
void test('Emperor gifts keep private amounts and do not consume Bureaucrat before a later qualifying payment', () => {
let game = bureaucratPaymentGame({ advanced: true });
bureaucratPlayer(game, 'e').ally = 'g';
@@ -294,22 +314,6 @@ void test('a real simultaneous Bene Gesserit advisor choice survives the deferre
assert.deepEqual(done.decision, original);
assert.equal(bureaucratPlayer(done, 'g').spice, 33);
});
void test('individually qualifying mixed or fully allied-funded payments retain the integration guard before commitment', () => {
for (const allyPayment of [1, 6]) {
let g = bureaucratPaymentGame();
bureaucratPlayer(g, 'p').ally = 'e';
bureaucratPlayer(g, 'e').ally = 'p';
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 6 });
reject(g, 'p', { ...ship, amount: 6, allyPayment });
g = bureaucratPaymentGame();
bureaucratPlayer(g, 'p').ally = 'g';
bureaucratPlayer(g, 'g').ally = 'p';
g = beginBureaucratAuction(g);
g = applyAction(g, 'g', { type: 'pledgeAid', amount: 6 });
g = applyAction(g, 'b', { type: 'passBid' });
reject(g, 'p', { type: 'bid', amount: 6, allyPayment });
}
});
void test('saved Bureaucrat receipt, source, unique decision, continuation and phase-use mutations reject immutably before reads', () => {
const pending = applyAction(bureaucratPaymentGame(), 'p', ship),
done = choose(pending);
+262
View File
@@ -0,0 +1,262 @@
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 { advanceBureaucratQualifyingSplit } from './fixture-bureaucrat-qualifying-split';
import {
createBureaucratQualifyingAuctionFixture, payBureaucratQualifyingAuction,
allowQualifyingAuctionResponses, chooseQualifyingAuction, qualifyingAuctionPlayer,
qualifyingAuctionPhysicalCards, type BureaucratQualifyingAuctionFixture,
} from './fixture-bureaucrat-qualifying-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 assertPaid(fixture: BureaucratQualifyingAuctionFixture, game: Game) {
const payer = qualifyingAuctionPlayer(game, fixture.payer), donor = qualifyingAuctionPlayer(game, fixture.donor);
assert.equal(payer.spice, qualifyingAuctionPlayer(fixture.beforePledge, fixture.payer).spice - fixture.payerShare);
assert.equal(donor.spice, qualifyingAuctionPlayer(fixture.beforePledge, fixture.donor).spice - fixture.donorShare);
assert.equal(game.aid[fixture.donor].amount, 0);
assert.equal(payer.hand.length, qualifyingAuctionPlayer(fixture.beforeAction, fixture.payer).hand.length + 1);
assert.equal(payer.hand.filter(card => card.id === fixture.reservedCard).length, 1);
assert.equal(qualifyingAuctionPlayer(game, fixture.emperor).spice, qualifyingAuctionPlayer(fixture.beforeAction, fixture.emperor).spice);
assert.deepEqual(qualifyingAuctionPhysicalCards(game), fixture.physical);
assert.equal(game.auction!.index, fixture.beforeAction.auction!.index);
assert.deepEqual(game.auction!.cards, fixture.sourceSetup.auction!.cards);
for (const original of fixture.beforeAction.players) {
const actual = qualifyingAuctionPlayer(game, original.id);
assert.deepEqual(actual.forces, original.forces);
assert.equal(actual.reserves, original.reserves);
}
}
function assertCompleted(fixture: BureaucratQualifyingAuctionFixture, pending: Game, done: Game, diverted: number) {
assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice,
qualifyingAuctionPlayer(fixture.beforeAction, fixture.emperor).spice + fixture.amount - diverted);
for (const actor of [fixture.payer, fixture.donor, fixture.trainer])
assert.equal(qualifyingAuctionPlayer(done, actor).spice, qualifyingAuctionPlayer(pending, actor).spice);
const payer = qualifyingAuctionPlayer(done, fixture.payer);
assert.deepEqual(payer.hand.map(card => card.id), [
...qualifyingAuctionPlayer(pending, fixture.payer).hand.map(card => card.id), fixture.bonusCard,
]);
assert.deepEqual(done.deck.map(card => card.id), pending.deck.slice(1).map(card => card.id));
assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical);
assert.equal(done.currentAuctionSale, null);
assert.equal(done.auction!.index, fixture.beforeAction.auction!.index + 1);
assert.equal(done.auction!.cards[done.auction!.index].id, fixture.beforeAction.auction!.cards[fixture.beforeAction.auction!.index + 1].id);
assert.equal(done.auction!.bid, 0);
assert.equal(done.auction!.bidder, null);
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
assert.equal(viewGame(done, fixture.trainer).bureaucrat.usedThisPhase, diverted === 2);
}
for (const advanced of [false, true]) {
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
for (const redirect of [true, false]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native ordinary auction ${payerShare}+${donorShare}: ${redirect ? 'Bank two' : 'full Emperor income'} and one Harkonnen bonus`, () => {
const fixture = createBureaucratQualifyingAuctionFixture({ advanced, payerShare, donorShare });
const paid = payBureaucratQualifyingAuction(fixture);
assertPaid(fixture, paid);
const pending = allowQualifyingAuctionResponses(paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assertPaid(fixture, pending);
const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(JSON.parse(JSON.stringify(pending)) as Game, redirect));
assertCompleted(fixture, pending, done, redirect ? 2 : 0);
rejectUnchanged(done, fixture.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
});
}
}
for (const [payerShare, donorShare] of [[3, 3], [4, 3]]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native ordinary auction ${payerShare}+${donorShare} cannot pool sub-five shares`, () => {
const fixture = createBureaucratQualifyingAuctionFixture({ advanced, payerShare, donorShare });
const paid = payBureaucratQualifyingAuction(fixture);
assertPaid(fixture, paid);
const done = allowQualifyingAuctionResponses(paid);
assertCompleted(fixture, paid, done, 0);
assert.equal(viewGame(done, fixture.trainer).bureaucrat.pending, null);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} original Emperor Karama cancellation banks the whole allied invoice, not just the primary contribution`, () => {
const fixture = createBureaucratQualifyingAuctionFixture({ advanced, payerShare: 5, donorShare: 5, counter: true });
const paid = payBureaucratQualifyingAuction(fixture);
assertPaid(fixture, paid);
assert.equal(paid.response?.kind, 'emperorIncome');
assert.equal(viewGame(paid, fixture.trainer).bureaucrat.pending, null);
const canceled = applyAction(paid, fixture.trainer, { type: 'card', mode: 'cancel', card: fixture.counterCard });
const done = allowQualifyingAuctionResponses(canceled);
assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice, qualifyingAuctionPlayer(fixture.beforeAction, fixture.emperor).spice);
assert.equal(qualifyingAuctionPlayer(done, fixture.payer).spice, qualifyingAuctionPlayer(paid, fixture.payer).spice);
assert.equal(qualifyingAuctionPlayer(done, fixture.donor).spice, qualifyingAuctionPlayer(paid, fixture.donor).spice);
assert.equal(qualifyingAuctionPlayer(done, fixture.payer).hand.length, qualifyingAuctionPlayer(paid, fixture.payer).hand.length + 1);
assert.equal(done.discard.filter(card => card.id === fixture.counterCard).length, 1);
assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical);
assert.equal(done.auction!.index, 1);
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(viewGame(done, fixture.trainer).bureaucrat.pending, null);
});
}
void test('equal public eight-spice invoices conceal which allied contribution qualified from Bureaucrat and outside audiences', () => {
const primary = createBureaucratQualifyingAuctionFixture({ payerShare: 5, donorShare: 3 });
const ally = createBureaucratQualifyingAuctionFixture({ payerShare: 3, donorShare: 5 });
const a = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(primary));
const b = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(ally));
for (const viewer of [primary.trainer, primary.guild, primary.emperor]) {
const left = viewGame(a, viewer), right = viewGame(b, viewer);
assert.equal(left.bureaucrat.pending!.payer, null);
assert.equal(left.bureaucrat.pending!.amount, 8);
assert.equal(left.bureaucrat.pending!.payee, primary.emperor);
assert.equal(left.auction!.bid, 8);
assert.equal(left.auction!.bidder, primary.payer);
assert.equal(left.auction!.allyPayment, undefined);
const { event: leftEvent, ...leftPayment } = left.bureaucrat.pending!;
const { event: rightEvent, ...rightPayment } = right.bureaucrat.pending!;
assert.notEqual(leftEvent, rightEvent);
assert.deepEqual(leftPayment, rightPayment);
assert.equal('contributions' in leftPayment, false);
assert.equal('contributions' in rightPayment, false);
assert.deepEqual(left.log.slice(primary.beforePledge.log.length).map(entry => entry.text),
right.log.slice(ally.beforePledge.log.length).map(entry => entry.text));
}
});
void test('a real original pledge is private funding, not a gift or a second income/payment action', () => {
const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 3, donorShare: 5 });
assert.equal(qualifyingAuctionPlayer(fixture.pledged, fixture.donor).spice, 1);
assert.equal(qualifyingAuctionPlayer(fixture.pledged, fixture.payer).spice, qualifyingAuctionPlayer(fixture.beforePledge, fixture.payer).spice);
assert.equal(qualifyingAuctionPlayer(fixture.pledged, fixture.payer).bribes, 0);
assert.equal(fixture.pledged.aid[fixture.donor].amount, 5);
assert.equal(fixture.pledged.decision, null);
assert.equal(fixture.pledged.response, null);
assert.equal(fixture.pledged.bureaucratPayments?.used.length ?? 0, 0);
const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture));
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assertPaid(fixture, pending);
});
void test('stale and cross-actor choices cannot spend, refund, duplicate, or modify the reserved ordinary lot', () => {
const fixture = createBureaucratQualifyingAuctionFixture();
const bid = applyAction(fixture.game, fixture.payer, fixture.action);
rejectUnchanged(bid, fixture.donor, { type: 'pledgeAid', amount: fixture.donorShare - 1 });
const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture));
rejectUnchanged(pending, fixture.donor, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
rejectUnchanged(pending, fixture.trainer, { type: 'decision', event: 'old-auction-invoice', redirect: true });
rejectUnchanged(pending, fixture.payer, { type: 'bid', amount: 1 });
rejectUnchanged(pending, fixture.donor, { type: 'pledgeAid', amount: 0 });
const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending));
assertCompleted(fixture, pending, done, 2);
});
void test('an original purchase using the last hand space has no premature or overflow Harkonnen bonus', () => {
const fixture = createBureaucratQualifyingAuctionFixture({ lastHandSpace: true });
const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture));
assertPaid(fixture, pending);
assert.equal(qualifyingAuctionPlayer(pending, fixture.payer).hand.length, handLimit(qualifyingAuctionPlayer(pending, fixture.payer)));
const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending));
assert.deepEqual(qualifyingAuctionPlayer(done, fixture.payer).hand, qualifyingAuctionPlayer(pending, fixture.payer).hand);
assert.deepEqual(done.deck, pending.deck);
assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice, qualifyingAuctionPlayer(pending, fixture.emperor).spice + 6);
assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical);
assert.equal(done.auction!.index, 1);
assert.equal(done.bureaucratPayments!.used.length, 1);
});
for (const difficulty of DIFFICULTIES) {
void test(`${difficulty} Bureaucrat bot resolves the actual concealed allied invoice and hands control to the native next lot`, () => {
const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 5, donorShare: 5 });
const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture));
const view = viewGame(pending, fixture.trainer);
view.players.find(player => player.id === fixture.trainer)!.bot = difficulty;
const action = botActions(view)[0];
const done = allowQualifyingAuctionResponses(applyAction(pending, fixture.trainer, action));
assertCompleted(fixture, pending, done, action.redirect ? 2 : 0);
const next = advanceBureaucratQualifyingSplit(done, game => !game.decision && !game.response && !!game.auction?.peekKnown);
assert.equal(next.auction!.index, 1);
assert.equal(next.active, next.auction!.active);
assert.ok(next.order.includes(next.active!));
assert.equal(viewGame(next, fixture.trainer).bureaucrat.pending, null);
});
}
void test('one split use covers both qualifying shares, blocks a later same-phase native payment, and refreshes only at a real phase boundary', () => {
const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 5, donorShare: 5 });
const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture));
let game = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending));
const use = structuredClone(game.bureaucratPayments!.used);
const secondLot = game.auction!.cards[1].id;
game = advanceBureaucratQualifyingSplit(game, state => !!state.auction && state.auction.active === fixture.guild && !state.decision && !state.response);
const guildBefore = qualifyingAuctionPlayer(game, fixture.guild).spice;
const emperorBefore = qualifyingAuctionPlayer(game, fixture.emperor).spice;
game = applyAction(game, fixture.guild, { type: 'bid', amount: 5 });
for (let n = 0; game.auction?.index === 1 && !game.response && !game.decision && n < game.players.length + 2; n++)
game = applyAction(game, game.auction!.active, { type: 'passBid' });
if (game.decision?.kind === 'auctionPayment') game = applyAction(game, fixture.guild, { type: 'decision', karama: false });
game = allowQualifyingAuctionResponses(game);
assert.equal(viewGame(game, fixture.trainer).bureaucrat.pending, null);
assert.deepEqual(game.bureaucratPayments!.used, use);
assert.equal(qualifyingAuctionPlayer(game, fixture.guild).spice, guildBefore - 5);
assert.equal(qualifyingAuctionPlayer(game, fixture.guild).hand.filter(card => card.id === secondLot).length, 1);
assert.equal(qualifyingAuctionPlayer(game, fixture.emperor).spice, emperorBefore + 5);
assert.equal(game.auction!.index, 2);
game = advanceBureaucratQualifyingSplit(game, state => state.phase === 5 && !state.phaseOpening && !state.response && !state.decision);
assert.equal(game.turn, pending.turn);
assert.equal(viewGame(game, fixture.trainer).bureaucrat.usedThisPhase, false);
const fresh = applyAction(game, fixture.emperor, { type: 'bribe', target: fixture.guild, amount: 5 });
assert.equal(fresh.decision?.kind, 'bureaucratPayment');
assert.notEqual(fresh.bureaucratPaymentEvent, pending.bureaucratPaymentEvent);
const done = chooseQualifyingAuction(fresh);
assert.equal(qualifyingAuctionPlayer(done, fixture.guild).bribes, 3);
assert.equal(done.bureaucratPayments!.used.length, 2);
assert.equal(viewGame(done, fixture.trainer).bureaucrat.usedThisPhase, true);
assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical);
});
for (const donor of ['b', 'e'] as const) {
for (const [payerShare, donorShare] of [[3, 5], [5, 3]]) {
void test(`native worm alliance with ${donor === 'b' ? 'Bureaucrat owner' : 'Emperor payee'} excludes only that contributor's threshold`, () => {
const fixture = createBureaucratQualifyingAuctionFixture({ donor, payerShare, donorShare });
assert.equal(qualifyingAuctionPlayer(fixture.alliance, fixture.payer).ally, donor);
assert.equal(qualifyingAuctionPlayer(fixture.alliance, donor).ally, fixture.payer);
const paid = payBureaucratQualifyingAuction(fixture);
assertPaid(fixture, paid);
const allowed = allowQualifyingAuctionResponses(paid);
const qualifies = payerShare === 5;
assert.equal(allowed.decision?.kind, qualifies ? 'bureaucratPayment' : undefined);
const done = qualifies ? allowQualifyingAuctionResponses(chooseQualifyingAuction(allowed)) : allowed;
assert.equal(qualifyingAuctionPlayer(done, fixture.payer).spice,
qualifyingAuctionPlayer(fixture.beforePledge, fixture.payer).spice - payerShare);
assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice,
qualifyingAuctionPlayer(fixture.beforePledge, fixture.emperor).spice - (donor === 'e' ? donorShare : 0) +
fixture.amount - (qualifies ? 2 : 0));
if (donor === 'b') assert.equal(qualifyingAuctionPlayer(done, donor).spice,
qualifyingAuctionPlayer(fixture.beforePledge, donor).spice - donorShare);
assert.equal(done.bureaucratPayments?.used.length ?? 0, qualifies ? 1 : 0);
assert.equal(qualifyingAuctionPlayer(done, fixture.payer).hand.filter(card => card.id === fixture.reservedCard).length, 1);
assert.equal(qualifyingAuctionPlayer(done, fixture.payer).hand.filter(card => card.id === fixture.bonusCard).length, 1);
assert.equal(done.auction!.index, 1);
assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical);
});
}
}
void test('declining a native split invoice leaves the original card available for a distinct payment in the same Bidding phase', () => {
const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 3, donorShare: 5 });
const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture));
const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending, false));
assertCompleted(fixture, pending, done, 0);
const next = advanceBureaucratQualifyingSplit(done, state => !!state.auction?.peekKnown &&
!state.phaseOpening && !state.response && !state.decision);
assert.equal(next.auction!.index, 1);
const paid = applyAction(next, fixture.emperor, { type: 'bribe', target: fixture.guild, amount: 5 });
assert.equal(paid.phase, pending.phase);
assert.equal(paid.decision?.kind, 'bureaucratPayment');
assert.notEqual(paid.bureaucratPaymentEvent, pending.bureaucratPaymentEvent);
const redirected = chooseQualifyingAuction(paid);
assert.equal(qualifyingAuctionPlayer(redirected, fixture.guild).bribes, 3);
assert.equal(redirected.bureaucratPayments!.used.length, 1);
assert.equal(redirected.auction!.index, 1);
assert.equal(qualifyingAuctionPlayer(redirected, fixture.payer).spice, qualifyingAuctionPlayer(done, fixture.payer).spice);
assert.deepEqual(qualifyingAuctionPhysicalCards(redirected), fixture.physical);
});
@@ -0,0 +1,247 @@
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 {
allowBureaucratQualifyingShipmentIncome, createBureaucratQualifyingShipmentFixture,
declareBureaucratQualifyingShipment,
} from './fixture-bureaucrat-qualifying-shipment';
const seat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
const physical = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, elites: p.elites, shipped: p.shipped, moved: p.moved }));
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 ownerChoice(game: Game, redirect: boolean) {
assert.equal(game.decision?.kind, 'bureaucratPayment');
return applyAction(structuredClone(game), 'b', {
type: 'decision', event: game.decision!.event, redirect,
});
}
void test('native Basic and Advanced allied shipments pay every original leg and divert at most two from Guild income', () => {
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 = createBureaucratQualifyingShipmentFixture({ advanced, primary, donor });
const original = f.beforePledge;
assert.equal(seat(original, 'f').spice, 6);
assert.equal(seat(f.beforeAction, 'f').spice, 6 - donor);
assert.equal(f.beforeAction.aid.f.amount, donor);
assert.deepEqual(physical(f.beforeAction), physical(original));
const { declared, paid } = declareBureaucratQualifyingShipment(f);
if (declared.decision?.kind === 'guildShipment') {
assert.deepEqual(wallets(declared), wallets(f.beforeAction));
assert.deepEqual(physical(declared), physical(f.beforeAction));
assert.equal(declared.decision.player, 'g');
}
assert.equal(paid.response?.kind, 'guildIncome');
assert.equal(paid.response?.amount, primary + donor);
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', '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 = allowBureaucratQualifyingShipmentIncome(paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assert.deepEqual(physical(pending), physical(paid));
assert.deepEqual(wallets(pending), wallets(paid));
const after = ownerChoice(pending, redirect);
assert.equal(seat(after, 'g').spice, (advanced ? 6 : 5) + f.amount - (redirect ? 2 : 0));
for (const id of ['p', 'f', 'b', '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(physical(after), physical(paid));
assert.equal(after.bureaucratPayments?.used.length ?? 0, Number(redirect));
assert.equal(after.bureaucratPayments?.pending, undefined);
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));
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(physical(ended), physical(after));
rejectUnchanged(ended, 'p', { type: 'endMovement' });
}
});
void test('Karama cancels original gross Guild income after the same shipment and full allied debits, without spending Bureaucrat', () => {
for (const advanced of [false, true])
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
const f = createBureaucratQualifyingShipmentFixture({ advanced, primary, donor });
const { paid } = declareBureaucratQualifyingShipment(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(physical(after), physical(paid));
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.notEqual(after.decision?.kind, 'bureaucratPayment');
assert.equal(after.response, null);
rejectUnchanged(after, f.responder, { type: 'card', mode: 'cancel', card: f.karama.id });
}
});
void test('neither pooled sub-five shares nor the native Bureaucrat owner contribution qualifies on its own', () => {
for (const advanced of [false, true])
for (const options of [
{ primary: 3, donor: 3 }, { primary: 4, donor: 3 },
{ primary: 3, donor: 5, ownerFunding: true },
]) {
const f = createBureaucratQualifyingShipmentFixture({ 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 } = declareBureaucratQualifyingShipment(f);
const after = allowBureaucratQualifyingShipmentIncome(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(physical(after), physical(paid));
}
});
void test('an excluded owner funding leg does not mask the other actual qualifying third-party payer', () => {
for (const advanced of [false, true])
for (const donor of [3, 5]) {
const f = createBureaucratQualifyingShipmentFixture({ 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 } = declareBureaucratQualifyingShipment(f);
const pending = allowBureaucratQualifyingShipmentIncome(paid);
const after = ownerChoice(pending, true);
assert.equal(seat(after, 'b').spice, seat(f.beforePledge, 'b').spice - donor);
assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - 5);
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount - 2);
assert.equal(after.bureaucratPayments?.used.length, 1);
assert.deepEqual(physical(after), physical(paid));
}
});
void test('pending allied-funded income exposes public gross and recipient, never which private contribution qualified', () => {
for (const advanced of [false, true])
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
const f = createBureaucratQualifyingShipmentFixture({ advanced, primary, donor });
const { paid } = declareBureaucratQualifyingShipment(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 = allowBureaucratQualifyingShipmentIncome(paid);
for (const viewer of pending.players) {
const view = viewGame(pending, viewer.id);
const payment = view.bureaucrat.pending;
assert.ok(payment);
assert.equal(payment.amount, f.amount);
assert.equal(payment.payee, 'g');
assert.equal(payment.payer, null);
assert.equal('contributions' in payment, false);
assert.equal('source' in payment, false);
assert.equal('signature' in payment, false);
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', 'e', 'g'])
rejectUnchanged(pending, id, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
rejectUnchanged(pending, 'b', { type: 'decision', event: 'stale-income', redirect: true });
rejectUnchanged(pending, 'b', { type: 'endMovement' });
rejectUnchanged(pending, 'f', { type: 'pledgeAid', amount: 1 });
}
});
void test('all four minimal Bureaucrat bot responses settle the original private-funded payment and resume native movement', () => {
for (const advanced of [false, true])
for (const bot of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = createBureaucratQualifyingShipmentFixture({ advanced, primary: 5, donor: 5 });
const pending = allowBureaucratQualifyingShipmentIncome(declareBureaucratQualifyingShipment(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(physical(after), physical(pending));
for (const id of ['b', 'p', 'f', 'e']) assert.equal(seat(after, id).spice, seat(pending, id).spice);
const ended = applyAction(after, 'p', { type: 'endMovement' });
assert.notEqual(ended.active, 'p');
assert.deepEqual(physical(ended), physical(after));
rejectUnchanged(ended, 'b', actions[0]);
}
});
void test('declining the shipment preserves the physical card for the next real payment, while redirecting spends it once per phase', () => {
for (const advanced of [false, true])
for (const redirect of [false, true]) {
const f = createBureaucratQualifyingShipmentFixture({ advanced, primary: 5, donor: 3 });
const pending = allowBureaucratQualifyingShipmentIncome(declareBureaucratQualifyingShipment(f).paid);
const settled = ownerChoice(pending, redirect);
const following = applyAction(structuredClone(settled), 'e', { type: 'bribe', target: 'f', amount: 5 });
assert.equal(seat(following, 'e').spice, seat(settled, 'e').spice - 5);
assert.deepEqual(physical(following), physical(settled));
if (redirect) {
assert.notEqual(following.decision?.kind, 'bureaucratPayment');
assert.equal(seat(following, 'f').bribes, seat(settled, 'f').bribes + 5);
assert.equal(following.bureaucratPayments?.used.length, 1);
} else {
assert.equal(following.decision?.kind, 'bureaucratPayment');
rejectUnchanged(following, 'b', { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
const second = ownerChoice(following, true);
assert.equal(seat(second, 'f').bribes, seat(settled, 'f').bribes + 3);
assert.equal(second.bureaucratPayments?.used.length, 1);
assert.equal(seat(second, 'g').spice, seat(settled, 'g').spice);
}
}
});
void test('native shipment funding rejects more than the actual pledged aid without committing payment or counters', () => {
for (const advanced of [false, true]) {
const f = createBureaucratQualifyingShipmentFixture({ 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(physical(f.beforeAction), physical(f.beforePledge));
assert.equal(seat(f.beforeAction, 'p').spice, seat(f.beforePledge, 'p').spice);
assert.equal(seat(f.beforeAction, 'f').spice, 3);
assert.equal(f.beforeAction.response, null);
assert.equal(f.beforeAction.decision, null);
}
});
-80
View File
@@ -1,80 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments';
import { createBureaucratSplitThresholdFixture, advanceBureaucratThresholdToMovement } from './fixture-bureaucrat-split-threshold';
function settle(state: Game): Game {
let game = state;
for (let n = 0; (game.response || game.decision) && n < 40; n++) {
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(!game.response && !game.decision);
return game;
}
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
for (const advanced of [false, true]) {
for (const personal of [3, 4]) void test(`${advanced ? 'Advanced' : 'Basic'} shipment preserves ${personal}+3 allied payments below Bureaucrat’s individual threshold`, () => {
const f = createBureaucratSplitThresholdFixture(advanced);
let game = advanceBureaucratThresholdToMovement(f.game, f.payer);
const before = structuredClone(game);
game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 });
const amount = personal + 3, key = 'habbanya_ridge_sietch:17';
const paid = settle(applyAction(game, f.payer, {
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: 3,
}));
assert.equal(player(paid, f.payer).spice, player(before, f.payer).spice - personal);
assert.equal(player(paid, f.donor).spice, player(before, f.donor).spice - 3);
assert.equal(player(paid, f.guild).spice, player(before, f.guild).spice + amount);
assert.equal(player(paid, f.trainer).spice, player(before, f.trainer).spice);
assert.equal(player(paid, f.payer).forces[key], amount);
assert.equal(player(paid, f.payer).reserves, player(before, f.payer).reserves - amount);
assert.equal(paid.aid[f.donor]?.amount ?? 0, 0);
assert.equal(viewGame(paid, f.trainer).bureaucrat.usedThisPhase, false);
assert.throws(() => applyAction(paid, f.payer, {
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: 3,
}));
const finished = applyAction(paid, f.payer, { type: 'endMovement' });
assert.ok(!finished.movementRemaining?.includes(f.payer));
});
void test(`${advanced ? 'Advanced' : 'Basic'} auction preserves separate three-spice payer costs and full Emperor income`, () => {
const f = createBureaucratSplitThresholdFixture(advanced);
let game = f.game;
while (game.auction!.active !== f.payer) {
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
const card = game.auction!.cards[game.auction!.index].id;
const before = structuredClone(game);
game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 });
game = applyAction(game, f.payer, { type: 'bid', amount: 6, allyPayment: 3 });
for (let n = 0; !player(game, f.payer).hand.some(c => c.id === card) && n < 40; n++) {
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
const paid = settle(game);
assert.ok(player(paid, f.payer).hand.some(c => c.id === card));
assert.equal(player(paid, f.payer).spice, player(before, f.payer).spice - 3);
assert.equal(player(paid, f.donor).spice, player(before, f.donor).spice - 3);
assert.equal(player(paid, f.emperor).spice, player(before, f.emperor).spice + 6);
assert.equal(player(paid, f.trainer).spice, player(before, f.trainer).spice);
assert.equal(viewGame(paid, f.trainer).bureaucrat.usedThisPhase, false);
assert.equal(paid.auction!.index, before.auction!.index + 1);
assert.ok(!paid.auction!.cards.slice(paid.auction!.index).some(c => c.id === card));
});
void test(`${advanced ? 'Advanced' : 'Basic'} an individual five-spice contribution retains the unresolved mixed-payment guard`, () => {
const f = createBureaucratSplitThresholdFixture(advanced);
let game = advanceBureaucratThresholdToMovement(f.game, f.payer);
game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 });
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.payer, {
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount: 8, allyPayment: 3,
}));
assert.deepEqual(game, before, 'No payer, escrow or physical force changes on the unsupported qualifying split');
});
}
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { createBureaucratQualifyingSplitSetup, advanceBureaucratQualifyingSplit, type BureaucratQualifyingSplitStep } from './fixture-bureaucrat-qualifying-split';
export interface BureaucratQualifyingAuctionFixture {
game: Game;
setup: Game;
beforeBribe: Game;
bribe: Game;
afterFirstMentat: Game;
worm: Game;
alliance: Game;
sourceSetup: Game;
beforePledge: Game;
originalPledge: BureaucratQualifyingSplitStep;
pledged: Game;
beforeAction: Game;
action: Action;
payer: string;
donor: string;
trainer: string;
guild: string;
emperor: string;
payerShare: number;
donorShare: number;
amount: number;
counterCard?: string;
physical: string[];
reservedCard: string;
bonusCard?: string;
actions: BureaucratQualifyingSplitStep[];
staging: string[];
}
export const qualifyingAuctionPlayer = (game: Game, id: string) => game.players.find(player => player.id === id)!;
export function qualifyingAuctionPhysicalCards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale?.origin === 'normal' ? 1 : 0)) ?? [])]
.map(card => card.id).sort();
}
/** Swap conserved physical cards, retaining the original hand size and auction
* quantity. Harkonnen's Karama makes the native winning payment a real choice;
* no purchase bonus is dealt until the original income continuation finishes. */
function positionKarama(game: Game, actor: string, staging: string[]): string {
const player = qualifyingAuctionPlayer(game, actor);
const held = player.hand.find(card => card.effect === 'karama');
if (held) return held.id;
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0, 'An original undealt physical Karama must be available');
const outgoing = player.hand[0];
assert.ok(outgoing);
const incoming = game.deck[index];
game.deck[index] = outgoing;
player.hand[0] = incoming;
staging.push(`Swap original held ${outgoing.id} with original undealt ${incoming.id} for ${actor}; retain hand size and committed lots.`);
return incoming.id;
}
export function createBureaucratQualifyingAuctionFixture({
advanced = false, payerShare = 5, donorShare = 3, donor = 'f', counter = false, lastHandSpace = false,
}: { advanced?: boolean; payerShare?: number; donorShare?: number; donor?: 'f' | 'b' | 'e'; counter?: boolean; lastHandSpace?: boolean } = {}): BureaucratQualifyingAuctionFixture {
const native = createBureaucratQualifyingSplitSetup(advanced);
let game = native.game, alliance = native.alliance;
let actions = native.actions;
const staging = native.staging;
if (donor !== native.donor) {
// Branch at the actual worm, before either mutual alliance proposal.
game = structuredClone(native.worm);
actions = actions.slice(0, actions.findIndex(step => step.action.type === 'alliance'));
for (const [actor, target] of [[native.payer, donor], [donor, native.payer]]) {
const action: Action = { type: 'alliance', target };
actions.push({ actor, action });
game = applyAction(game, actor, action);
}
alliance = structuredClone(game);
game = advanceBureaucratQualifyingSplit(game, state => state.phase === 3 && !!state.auction &&
!state.phaseOpening && !state.response && !state.decision, actions);
}
const sourceSetup = structuredClone(game);
const physical = qualifyingAuctionPhysicalCards(game);
positionKarama(game, native.payer, staging);
const counterCard = counter ? positionKarama(game, native.trainer, staging) : undefined;
if (lastHandSpace) {
const payer = qualifyingAuctionPlayer(game, native.payer);
while (payer.hand.length < handLimit(payer) - 1) {
const incoming = game.deck.shift();
assert.ok(incoming);
payer.hand.push(incoming);
staging.push(`Conserve original undealt ${incoming.id} into ${payer.id} after original auction quantity committed.`);
}
}
assert.deepEqual(qualifyingAuctionPhysicalCards(game), physical);
const originalPledge: BureaucratQualifyingSplitStep = { actor: donor, action: { type: 'pledgeAid', amount: donorShare } };
const beforePledge = structuredClone(game);
actions.push(originalPledge);
game = applyAction(game, originalPledge.actor, originalPledge.action);
const pledged = structuredClone(game);
for (let n = 0; game.auction!.active !== native.payer && n < game.players.length; n++) {
const step: BureaucratQualifyingSplitStep = { actor: game.auction!.active, action: { type: 'passBid' } };
actions.push(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.auction!.active, native.payer);
const beforeAction = structuredClone(game), amount = payerShare + donorShare;
const action: Action = { type: 'bid', amount, allyPayment: donorShare };
return { ...native, donor, alliance, game, sourceSetup, beforePledge, originalPledge, pledged, beforeAction, action,
payerShare, donorShare, amount, counterCard, physical, reservedCard: game.auction!.cards[game.auction!.index].id,
bonusCard: game.deck[0]?.id, actions, staging };
}
export function payBureaucratQualifyingAuction(fixture: BureaucratQualifyingAuctionFixture, state = fixture.game): Game {
let game = applyAction(state, fixture.payer, fixture.action);
fixture.actions.push({ actor: fixture.payer, action: fixture.action });
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < game.players.length + 2; n++) {
assert.ok(game.auction && !game.currentAuctionSale);
const step: BureaucratQualifyingSplitStep = { actor: game.auction.active, action: { type: 'passBid' } };
fixture.actions.push(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision!.player, fixture.payer);
const action: Action = { type: 'decision', karama: false };
fixture.actions.push({ actor: fixture.payer, action });
return applyAction(game, fixture.payer, action);
}
export function allowQualifyingAuctionResponses(state: Game): Game {
let game = state;
for (let n = 0; game.response && n < 40; n++) {
const actor = game.players.find(player => {
const controls = viewGame(game, player.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(actor, 'The original response must have a legal unpassed counter holder');
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
export function chooseQualifyingAuction(game: Game, redirect = true): Game {
assert.equal(game.decision?.kind, 'bureaucratPayment');
return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect });
}
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
advanceBureaucratQualifyingSplit, createBureaucratQualifyingSplitSetup,
type BureaucratQualifyingSplitStep,
} from './fixture-bureaucrat-qualifying-split';
export interface BureaucratQualifyingShipmentDeclaration {
beforeAction: Game;
payer: string;
action: Action;
}
export function createBureaucratQualifyingShipmentFixture({
advanced = false, primary = 5, donor = 3, ownerFunding = false,
}: { advanced?: boolean; primary?: number; donor?: number; ownerFunding?: boolean } = {}) {
const setup = createBureaucratQualifyingSplitSetup(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 = advanceBureaucratQualifyingSplit(game, state => state.phase === 5 &&
state.active === setup.payer && !state.phaseOpening && !state.response && !state.decision, actions);
assert.equal(game.turn, 2);
const sourceSetup = structuredClone(game);
const sourceArmy = structuredClone(game.players.find(p => p.id === setup.payer)!);
assert.equal(sourceArmy.reserves, 10);
assert.deepEqual(sourceArmy.forces, { 'carthag:11': 10 });
assert.ok(game.players.every(p => !p.forces['habbanya_ridge_sietch:17']));
assert.equal(game.players.find(p => p.id === setup.donor)!.spice, 6);
assert.equal(game.players.find(p => p.id === setup.guild)!.spice, advanced ? 6 : 5);
// Keep the original singleton in physical custody so the original income
// cancellation window is exercised, rather than manufacturing a response.
const responder = game.players.find(p => p.id === setup.emperor)!;
let karama = responder.hand.find(card => card.effect === 'karama');
if (!karama) {
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
karama = game.deck.splice(index, 1)[0];
responder.hand.push(karama);
staging.push(`Conserved original ${karama.id}: undealt deck to Emperor hand for native income cancellation.`);
}
const beforePledge = structuredClone(game);
const pledge: Action = { type: 'pledgeAid', amount: donor };
actions.push({ actor: donorId, action: pledge });
game = applyAction(game, donorId, pledge);
const beforeAction = structuredClone(game);
const amount = primary + donor;
const action: Action = { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: donor };
assert.equal(game.aid[donorId].amount, donor);
return { ...setup, donor: donorId, alliance, game, sourceSetup, sourceArmy, beforePledge, pledge, beforeAction,
action, primary, donorAmount: donor, amount, target: 'habbanya_ridge_sietch:17',
karama, responder: responder.id, actions, staging };
}
/** Declare once; the original Guild interception, when present, commits it. */
export function declareBureaucratQualifyingShipment(
fixture: BureaucratQualifyingShipmentDeclaration,
) {
const actions: BureaucratQualifyingSplitStep[] = [];
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 };
}
/** Pass only original legal Karama responders; stop at the owner choice. */
export function allowBureaucratQualifyingShipmentIncome(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, 'The conserved Karama must retain its original income response');
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, 'guildIncome');
return game;
}
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments';
export type BureaucratQualifyingSplitStep = { actor: string; action: Action };
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Keep every original army and wallet. A real three-spice bribe collects into
* Fremen's shield at the first Mentat; the actual second-turn worm forms H/F. */
export function createBureaucratQualifyingSplitSetup(advanced = false) {
const lobby = createGame('BUREAUCRATQUALIFY', newPlayer('b', 'Bureaucrat', 'atreides'), advanced);
for (const [id, faction] of [['p', 'harkonnen'], ['f', 'fremen'], ['e', 'emperor'], ['g', 'guild']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
let game = initializeClassicNexusSkillsPaymentsSetup({ initial: lobby, skill: 'bureaucrat' });
const actions: BureaucratQualifyingSplitStep[] = [];
const staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = applyAction(game, actor, action);
};
const advance = (until: (state: Game) => boolean) => {
for (let n = 0; !until(game) && n < 2400; n++) {
assert.notEqual(game.status, 'finished');
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
assert.ok(next, 'Original skill and phase controls must reach the payment boundary');
act(next.actor, next.action);
}
assert.ok(until(game));
};
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.');
};
advance(state => state.status === 'playing');
const setup = structuredClone(game);
assert.ok(game.leaderSkills!.assignments.some(a => a.owner === 'b' && a.skill === 'bureaucrat'));
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.phase === 3 && !!state.auction && clean(state));
return { game, setup, beforeBribe, bribe, afterFirstMentat, worm, alliance,
payer: 'p', donor: 'f', trainer: 'b', guild: 'g', emperor: 'e', actions, staging };
}
export function advanceBureaucratQualifyingSplit(state: Game, until: (game: Game) => boolean,
actions?: BureaucratQualifyingSplitStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished');
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
assert.ok(next);
actions?.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game));
return game;
}
@@ -1,69 +0,0 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments';
/** Original skill offers/setup and actual turn-two worm alliance. Conserved
* starting counters are staged into reserves to isolate payment, not combat.
* Wallets, alliances, clocks and payment records are never assigned. */
export function createBureaucratSplitThresholdFixture(advanced = false) {
const lobby = createGame('BUREAUCRATTHRESHOLD', newPlayer('b', 'Bureaucrat', 'atreides'), advanced);
for (const [id, faction] of [['p', 'harkonnen'], ['f', 'fremen'], ['e', 'emperor'], ['g', 'guild']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
let game = initializeClassicNexusSkillsPaymentsSetup({ initial: lobby, skill: 'bureaucrat' });
const actions: { actor: string; action: Action }[] = [];
const step = () => {
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
assert.ok(next, `Native payment setup/progression stalled at ${game.phase}/${game.setupStage}`);
actions.push(next);
game = applyAction(game, next.actor, next.action);
};
for (let n = 0; game.status === 'setup' && n < 240; n++) step();
assert.equal(game.status, 'playing');
assert.ok(game.leaderSkills!.assignments.some(a => a.owner === 'b' && a.skill === 'bureaucrat'));
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
const orderSpice = (kinds: ('land' | 'worm')[]) => {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...front);
};
orderSpice(['land', 'land']);
for (let n = 0; !(game.turn === 2 && game.phase === 1) && n < 1800; n++) step();
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
orderSpice(['worm', 'land', 'land']);
let allied = false;
for (let n = 0; !(allied && game.phase === 3 && game.auction && !game.phaseOpening && !game.response && !game.decision) && n < 600; n++) {
if (game.nexus && !game.phaseOpening && !game.response && !game.decision && !game.spiceWindow && !allied) {
for (const [actor, target] of [['p', 'f'], ['f', 'p']]) {
const action: Action = { type: 'alliance', target };
actions.push({ actor, action }); game = applyAction(game, actor, action);
}
allied = true;
} else step();
}
assert.ok(allied && game.auction);
assert.equal(game.players.find(p => p.id === 'p')!.ally, 'f');
assert.equal(game.players.find(p => p.id === 'f')!.ally, 'p');
return { game, actions, payer: 'p', donor: 'f', trainer: 'b', guild: 'g', emperor: 'e' };
}
export function advanceBureaucratThresholdToMovement(state: Game, actor: string): Game {
let game = state;
for (let n = 0; !(game.phase === 5 && game.active === actor && !game.phaseOpening && !game.response && !game.decision) && n < 600; n++) {
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
assert.ok(next, 'Native payment continuation stalled');
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 5); assert.equal(game.active, actor);
return game;
}