import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, } from '../game/bureaucrat-payment'; import { bureaucratPaymentGame, bureaucratPlayer, bureaucratReload, beginBureaucratAuction, takeBureaucratCard, allowBureaucratIncome, } from './bureaucrat-payment-fixture'; const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5, }; const bribe: Action = { type: 'bribe', target: 'e', amount: 5 }; function choose(g: Game, redirect = true): Game { return applyAction(g, 'b', { type: 'decision', event: g.bureaucratPaymentEvent, redirect, }); } function reject(g: Game, id: string, action: Action, pattern?: RegExp) { const before = JSON.stringify(g); if (pattern) assert.throws(() => applyAction(g, id, action), pattern); else assert.throws(() => applyAction(g, id, action)); assert.equal(JSON.stringify(g), before); } function cards(g: Game) { return [ ...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...(g.auction?.cards.slice( g.auction.index + (g.currentAuctionSale?.origin === 'normal' ? 1 : 0), ) ?? []), ] .map((card) => card.id) .sort(); } function normalSale(g: Game, amount = 5): Game { while (g.auction?.active !== 'p') g = applyAction(g, g.auction!.active, { type: 'passBid' }); g = applyAction(g, 'p', { type: 'bid', amount }); for (let i = 0; g.auction && !g.currentAuctionSale && i < 12; i++) g = applyAction(g, g.auction.active, { type: 'passBid' }); return g; } function richeseSale(source: 'cache' | 'blackMarket', advanced = false) { let g = bureaucratPaymentGame({ choam: true, advanced }); const saleCard = source === 'blackMarket' ? takeBureaucratCard(g, 'r', 'Baliset') : g.richeseCache![0]; g = beginBureaucratAuction(g); if (source === 'cache') g = applyAction(g, 'r', { type: 'decision', event: g.richeseBidding!.event, position: 'first', }); g = applyAction(g, 'r', { type: 'decision', event: g.richeseBidding!.event, card: saleCard.id, method: 'silent', }); for (const id of g.richeseAuction!.order) g = applyAction(g, id, { type: 'richeseBid', event: g.richeseAuction!.event, amount: id === 'p' ? 5 : 0, }); return { g, saleCard }; } void test('Bureaucrat requires two other players and tracks the unique physical skill once per phase, independent of later owner', () => { const input = { owner: 'b', payer: 'p', payee: 'g', amount: 5, turn: 2, phase: 5, used: [], }; assert.deepEqual(quoteBureaucratPayment(input), { redirect: 2, income: 3 }); for (const patch of [ { owner: null }, { payer: 'b' }, { payee: 'b' }, { payer: 'g' }, { amount: 4 }, ]) assert.equal(quoteBureaucratPayment({ ...input, ...patch }), null); const used = [ { event: 'spent', owner: 'former', turn: 2, phase: 5, signature: '' }, ]; assert.equal(quoteBureaucratPayment({ ...input, owner: 'new', used }), null); assert.equal(bureaucratUsed(used, 2, 6), false); assert.ok( !bureaucratPaymentModeSupported({ expansions: ['choam'], homeworlds: {} }), ); }); 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'; bureaucratPlayer(game, 'g').ally = 'e'; takeBureaucratCard(game, 'p', 'Karama'); const pending = applyAction(game, 'e', { type: 'emperorGift', amount: 7 }); assert.equal(pending.response?.kind, 'emperorGift'); for (const id of ['b', 'p']) assert.equal(viewGame(pending, id).response!.amount, undefined); for (const id of ['e', 'g']) assert.equal(viewGame(pending, id).response!.amount, 7); game = allowBureaucratIncome(bureaucratReload(pending)); assert.equal(bureaucratPlayer(game, 'e').spice, 23); assert.equal(bureaucratPlayer(game, 'g').spice, 37); assert.equal(bureaucratPlayer(game, 'b').spice, 30); assert.equal(game.decision, null); assert.equal(viewGame(game, 'b').bureaucrat.usedThisPhase, false); const paid = applyAction(game, 'p', bribe); assert.equal(paid.decision?.kind, 'bureaucratPayment'); const redirected = choose(paid); assert.equal(bureaucratPlayer(redirected, 'p').spice, 25); assert.equal(bureaucratPlayer(redirected, 'e').bribes, 3); assert.equal(viewGame(redirected, 'b').bureaucrat.usedThisPhase, true); }); for (const advanced of [false, true]) for (const choam of [false, true]) void test(`single-payer ${choam ? 'CHOAM' : 'base'} ${advanced ? 'Advanced' : 'Basic'} shipment diverts two after payment without replaying arrival`, () => { const start = bureaucratPaymentGame({ advanced, choam }), inventory = cards(start); let pending = applyAction(start, 'p', ship); if (pending.decision?.kind === 'guildShipment') pending = applyAction(pending, 'g', { type: 'decision', allow: true }); assert.equal(bureaucratPlayer(pending, 'p').spice, 25); assert.equal(bureaucratPlayer(pending, 'g').spice, 30); assert.equal(bureaucratPlayer(pending, 'p').forces['arrakeen:10'], 5); assert.equal(pending.response, null); assert.equal( pending.bureaucratPayments!.pending!.resume.response!.kind, 'guildIncome', ); const original = structuredClone(bureaucratPlayer(pending, 'p')); const done = choose(bureaucratReload(pending)); assert.deepEqual(bureaucratPlayer(done, 'p'), original); assert.equal(bureaucratPlayer(done, 'g').spice, 33); assert.deepEqual(cards(done), inventory); assert.equal(viewGame(done, 'b').bureaucrat.usedThisPhase, true); assert.deepEqual(normalizeAutomaticGame(bureaucratReload(done)), done); reject(done, 'b', { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true, }); }); void test('bribe diversion reduces only recipient escrow and never runs again on Mentat collection', () => { let g = applyAction(bureaucratPaymentGame(), 'p', bribe); assert.equal(bureaucratPlayer(g, 'p').spice, 25); assert.equal(bureaucratPlayer(g, 'e').bribes, 0); g = choose(g); assert.equal(bureaucratPlayer(g, 'e').bribes, 3); assert.equal(bureaucratPlayer(g, 'e').spice, 30); const uses = structuredClone(g.bureaucratPayments!.used); // Conserved phase staging reaches the actual collection callback. Object.assign(g, { phase: 7, active: null, ready: [], phaseOpening: null }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.phase, 8); assert.equal(bureaucratPlayer(g, 'e').bribes, 0); assert.equal(bureaucratPlayer(g, 'e').spice, 33); assert.deepEqual(g.bureaucratPayments!.used, uses); assert.equal(viewGame(g, 'b').bureaucrat.pending, null); }); void test('decline keeps the next opportunity, while a consumed card cannot gain another use after a saved controller/assignment change', () => { let g = applyAction(bureaucratPaymentGame(), 'p', bribe); g = choose(g, false); assert.equal(bureaucratPlayer(g, 'e').bribes, 5); assert.equal(viewGame(g, 'b').bureaucrat.usedThisPhase, false); g = applyAction(g, 'p', bribe); assert.equal(g.decision?.kind, 'bureaucratPayment'); g = choose(g); const assignments = g.leaderSkills!.assignments, old = assignments.find((a) => a.owner === 'b')!, next = assignments.find((a) => a.owner === 'p')!; // A conserved saved redraw/custody state still refers to the same physical card. [old.skill, next.skill] = [next.skill, old.skill]; g = applyAction(bureaucratReload(g), 'e', { type: 'bribe', target: 'g', amount: 5, }); assert.equal(g.decision, null); assert.equal(g.bureaucratPayments!.used.length, 1); assert.equal(bureaucratPlayer(g, 'g').bribes, 5); }); void test('native income cancellation sends the whole payment to the bank without a Bureaucrat opportunity or use', () => { for (const kind of ['shipment', 'auction'] as const) { let g = bureaucratPaymentGame(); const karama = takeBureaucratCard(g, 'b'); g = kind === 'shipment' ? applyAction(g, 'p', ship) : normalSale(beginBureaucratAuction(g)); assert.equal( g.response?.kind, kind === 'shipment' ? 'guildIncome' : 'emperorIncome', ); assert.equal(g.bureaucratPayments?.pending, undefined); const payer = bureaucratPlayer(g, 'p').spice; g = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: karama.id }); assert.equal(bureaucratPlayer(g, 'p').spice, payer); assert.equal( bureaucratPlayer(g, kind === 'shipment' ? 'g' : 'e').spice, 30, ); assert.equal(viewGame(g, 'b').bureaucrat.pending, null); assert.equal(g.bureaucratPayments?.used.length ?? 0, 0); } }); void test('normal auction resumes Emperor credit, Harkonnen bonus and the next lot once after Bureaucrat', () => { const start = beginBureaucratAuction(bureaucratPaymentGame()), inventory = cards(start), card = start.auction!.cards[0].id; const pending = normalSale(start); assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(bureaucratPlayer(pending, 'p').spice, 25); assert.equal(bureaucratPlayer(pending, 'p').hand.length, 1); const done = choose(pending); assert.equal(bureaucratPlayer(done, 'e').spice, 33); assert.equal(bureaucratPlayer(done, 'p').hand.length, 2); assert.ok(bureaucratPlayer(done, 'p').hand.some((c) => c.id === card)); assert.deepEqual(cards(done), inventory); assert.equal(done.auction!.index, 1); assert.equal(done.currentAuctionSale, null); }); void test('Richese cache and concealed Black Market seller credits preserve their original physical sale and next callback', () => { for (const source of ['cache', 'blackMarket'] as const) for (const advanced of source === 'cache' ? [false, true] : [true]) { const { g, saleCard } = richeseSale(source, advanced), inventory = cards(g); assert.equal(g.decision?.kind, 'bureaucratPayment'); assert.equal(bureaucratPlayer(g, 'r').spice, 30); assert.equal(viewGame(g, 'b').bureaucrat.pending!.kind, 'auction'); // The native Atreides Bureaucrat has its independently earned auction peek. if (source === 'blackMarket') assert.ok(!JSON.stringify(viewGame(g, 'g')).includes(saleCard.id)); const done = choose(bureaucratReload(g)); assert.equal(bureaucratPlayer(done, 'r').spice, 33); assert.equal(bureaucratPlayer(done, 'e').spice, 30); assert.equal( bureaucratPlayer(done, 'p').hand.filter((c) => c.id === saleCard.id) .length, 1, ); assert.deepEqual(cards(done), inventory); assert.equal(done.currentAuctionSale, null); assert.equal( done.decision?.kind, source === 'blackMarket' ? 'richeseDeclaration' : undefined, ); } }); void test('a real simultaneous Bene Gesserit advisor choice survives the deferred Guild payment', () => { const pending = applyAction( bureaucratPaymentGame({ advisor: true }), 'p', ship, ); const original = structuredClone( pending.bureaucratPayments!.pending!.resume.decision, ); assert.equal(original!.kind, 'advisor'); const done = choose(bureaucratReload(pending)); assert.deepEqual(done.decision, original); assert.equal(bureaucratPlayer(done, 'g').spice, 33); }); 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); const cases: [Game, (g: Game) => void][] = [ [ pending, (g) => { g.decision = null; }, ], [ pending, (g) => { delete g.bureaucratPaymentEvent; }, ], [ pending, (g) => { delete g.bureaucratPayments!.pending; }, ], [ pending, (g) => { g.bureaucratPayments!.pending!.source.amount = 6; }, ], [ pending, (g) => { g.bureaucratPayments!.pending!.resume.response!.amount = 6; }, ], [ pending, (g) => { bureaucratPlayer(g, 'p').spice++; }, ], [ pending, (g) => { g.bureaucratPayments!.pending!.leader = 'wrong'; }, ], [ pending, (g) => { g.pendingExchange = { response: null, decision: g.decision }; }, ], [ done, (g) => { g.bureaucratPayments!.used[0].phase = 6; }, ], [ done, (g) => { g.bureaucratUseEvents = []; }, ], ]; for (const [source, mutate] of cases) { const g = bureaucratReload(source); mutate(g); const before = JSON.stringify(g); for (const operation of [ () => viewGame(g, 'b'), () => normalizeAutomaticGame(g), () => applyAction(g, 'b', { type: 'advanceBots' }), ]) { assert.throws(operation); assert.equal(JSON.stringify(g), before); } } reject(pending, 'p', { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true, }); reject(pending, 'b', { type: 'decision', event: 'stale', redirect: true }); }); void test('legacy unstamped income finishes unchanged; active source evidence stays out of private seat projections', () => { let g = bureaucratPaymentGame(); takeBureaucratCard(g, 'b'); g = applyAction(g, 'p', ship); for (const p of g.players) { const response = viewGame(g, p.id).response!; assert.equal(response.bureaucratPayment, undefined); assert.equal(response.bureaucratPaymentEvent, undefined); } delete g.response!.bureaucratPayment; delete g.response!.bureaucratPaymentEvent; g = allowBureaucratIncome(bureaucratReload(g)); assert.equal(bureaucratPlayer(g, 'g').spice, 35); assert.equal(g.bureaucratPayments?.pending, undefined); assert.equal(g.bureaucratPayments?.used.length ?? 0, 0); }); void test('independent Karama sends every allied shipment share to Bank without a Bureaucrat split guard or opportunity', () => { let g = bureaucratPaymentGame(); bureaucratPlayer(g, 'p').ally = 'e'; bureaucratPlayer(g, 'e').ally = 'p'; g = applyAction(g, 'e', { type: 'pledgeAid', amount: 5 }); const karama = takeBureaucratCard(g, 'b'); g = applyAction(g, 'b', { type: 'card', mode: 'shipment', target: 'p', card: karama.id, }); const before = cards(g); g = applyAction(g, 'p', { ...ship, amount: 12, allyPayment: 3 }); assert.equal(bureaucratPlayer(g, 'p').spice, 27); assert.equal(g.aid.e.amount, 2); assert.equal(bureaucratPlayer(g, 'g').spice, 30); assert.equal(viewGame(g, 'b').bureaucrat.pending, null); assert.equal(bureaucratPlayer(g, 'p').forces['arrakeen:10'], 12); assert.deepEqual(cards(g), before); }); void test('captured, dead and genuinely concealed native Bureaucrats cannot redirect another payment', () => { for (const state of ['captured', 'dead', 'hidden', 'faceUp'] as const) { let g = bureaucratPaymentGame(); if (state === 'captured') bureaucratPlayer(g, 'b').leaders[0].capturedBy = 'p'; else if (state === 'dead') { bureaucratPlayer(g, 'b').leaders[0].dead = true; bureaucratPlayer(g, 'b').leaders[0].deaths = 1; g.leaderSkills!.assignments = g.leaderSkills!.assignments.filter( (a) => a.owner !== 'b', ); g.leaderSkills!.deck.push('bureaucrat'); } else { Object.assign(g, { phase: 6, active: 'b' }); for (const id of ['b', 'p']) { bureaucratPlayer(g, id).forces = { 'arrakeen:10': 3 }; bureaucratPlayer(g, id).reserves = 17; } g = applyAction(g, 'b', { type: 'chooseBattle', territory: 'arrakeen', target: 'p', }); while (g.decision?.kind === 'leaderSkillVisibility') { const decision = g.decision; g = applyAction(g, decision.player, { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === 'b' && state === 'hidden', }); } while (g.battle?.preparation) g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower', }); } g = applyAction(g, 'e', { type: 'bribe', target: 'g', amount: 5 }); assert.equal( g.decision?.kind, state === 'faceUp' ? 'bureaucratPayment' : undefined, ); assert.equal(bureaucratPlayer(g, 'g').bribes, state === 'faceUp' ? 0 : 5); } }); void test('a later phase grants one new use, and duplicated source events cannot pad the independent physical-card history', () => { let g = choose(applyAction(bureaucratPaymentGame(), 'p', bribe)); g.phase = 6; g = choose(applyAction(g, 'p', bribe)); assert.equal(g.bureaucratPayments!.used.length, 2); const broken = bureaucratReload(g); broken.bureaucratPayments!.used[1].event = broken.bureaucratPayments!.used[0].event; broken.bureaucratPayments!.used[1].signature = bureaucratPaymentSignature( broken.bureaucratPayments!.used[1], ); assert.throws(() => viewGame(broken, 'b')); assert.throws(() => normalizeAutomaticGame(broken)); });