import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { bureaucratPaymentSignature } from '../game/bureaucrat-payment'; import { validateNexusCards } from '../game/nexus-cards'; import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal'; import { betrayalFundingSeat as seat, chooseBetrayalFunding, createBureaucratBetrayalFundingFixture, settleBetrayalFunding, useBetrayalFunding, type BureaucratBetrayalFundingFixture, } from './fixture-bureaucrat-betrayal-funding'; function physicalShipment(game: Game) { return { forces: game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved })), phase: game.phase, active: game.active, remaining: game.movementRemaining }; } /** The Bank has no wallet field: its exact net receipt is the decrease in all * player-controlled spice, including deferred bribes and actual aid escrow. */ function playerControlledSpice(game: Game): number { return game.players.reduce((total, p) => total + p.spice + p.bribes, 0) + Object.values(game.aid).reduce((total, aid) => total + aid.amount, 0); } function rejectUnchanged(game: Game, actor: string, action: Action): void { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before, 'A rejected choice must not mutate any saved resource or continuation.'); } function proveReceipt(f: BureaucratBetrayalFundingFixture, done: Game, diverted: number): void { const invoice = f.game.pendingGuildBetrayal!.invoice; assert.equal(invoice.price, f.primary + f.donorAmount); assert.equal(invoice.ownPayment, f.primary); assert.equal(invoice.allyPayment, f.donorAmount); for (const p of done.players) { assert.equal(p.spice, seat(f.beforePledge, p.id).spice - (p.id === f.shipper ? f.primary : 0) - (p.id === f.donor ? f.donorAmount : 0) + (p.id === f.holder ? f.price - diverted : 0), `Exactly the original debit/receipt for ${p.id}`); assert.equal(p.bribes, seat(f.beforePledge, p.id).bribes); } if (f.donor) { assert.equal(seat(f.before, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorAmount); assert.equal(f.before.aid[f.donor].amount, f.donorAmount); assert.equal(done.aid[f.donor].amount, 0, 'The native donor escrow is consumed once, without a second wallet debit.'); } assert.equal(playerControlledSpice(f.before) - playerControlledSpice(done), diverted, 'Only the ruled diversion leaves player custody for the implicit Spice Bank.'); assert.deepEqual(physicalShipment(done), physicalShipment(f.original), 'The original native shipment suffix runs once.'); const destination = `${String(f.declaration.territory)}:${Number(f.declaration.sector)}`; assert.equal(seat(done, f.shipper).reserves, seat(f.before, f.shipper).reserves - f.price); assert.equal(seat(done, f.shipper).forces[destination], (seat(f.before, f.shipper).forces[destination] ?? 0) + f.price); assert.equal(seat(done, f.shipper).shipped, true); assert.equal(done.pendingGuildBetrayal, null); assert.equal(done.guildBetrayal!.status, 'completed'); assert.equal(done.nexusGuildBetrayalHistory!.length, f.before.nexusGuildBetrayalHistory!.length + 1); assert.equal(done.guildBetrayalSourceReceipts!.length, f.before.guildBetrayalSourceReceipts!.length + 1); const receipt = done.nexusGuildBetrayalHistory!.at(-1)!; assert.deepEqual(receipt.invoice, invoice, 'Bureaucrat does not rewrite the gross invoice or its original funding.'); assert.equal(receipt.recipient, f.holder); assert.equal(receipt.mode, 'betrayal'); assert.equal(receipt.nexusDiscardIndex, f.before.nexusCards!.cards!.discard.length); assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt); assert.equal(done.nexusCards!.cards!.hands[f.holder], null); assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']); validateNexusCards(done.nexusCards!.cards!, done.players); assert.equal(done.bureaucratPayments?.pending, undefined); rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event }); rejectUnchanged(done, f.shipper, f.declaration); const ended = applyAction(done, f.shipper, { type: 'endMovement' }); assert.notEqual(ended.active, f.shipper); assert.deepEqual(ended.nexusGuildBetrayalHistory, done.nexusGuildBetrayalHistory); assert.deepEqual(ended.guildBetrayalSourceReceipts, done.guildBetrayalSourceReceipts); assert.deepEqual(ended.players.map(p => p.forces), done.players.map(p => p.forces)); rejectUnchanged(ended, f.shipper, { type: 'endMovement' }); } for (const advanced of [false, true]) { for (const [primary, donorAmount] of [[5, 0], [5, 3], [3, 5], [5, 5], [0, 5]]) { for (const redirect of [false, true]) { void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal ${primary}+${donorAmount}: ${redirect ? 'holder gross minus two; Bank two' : 'holder retains gross'}`, () => { const f = createBureaucratBetrayalFundingFixture({ advanced, primary, donorAmount }); const pending = useBetrayalFunding(f); assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision?.player, f.trainer); assert.equal(seat(pending, f.holder).spice, seat(f.before, f.holder).spice + f.price); assert.deepEqual(physicalShipment(pending), physicalShipment(f.original)); const source = pending.bureaucratPayments!.pending!.source; assert.equal(source.kind, 'shipment'); assert.equal(source.payee, f.holder); assert.equal(source.amount, f.price); assert.equal(source.payer, primary >= 5 ? f.shipper : f.donor); if (f.donor) assert.deepEqual(source.contributions, [ ...(primary ? [{ payer: f.shipper, amount: primary }] : []), { payer: f.donor, amount: donorAmount }, ]); const done = settleBetrayalFunding(chooseBetrayalFunding(pending, redirect)); proveReceipt(f, done, redirect ? 2 : 0); assert.deepEqual(done.nexusGuildBetrayalHistory, pending.nexusGuildBetrayalHistory); assert.deepEqual(done.guildBetrayalSourceReceipts, pending.guildBetrayalSourceReceipts); assert.deepEqual(done.nexusCards!.cards, pending.nexusCards!.cards); assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, redirect); assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0); rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); }); } } for (const options of [{ own: true }, { primary: 3, donorAmount: 3 }, { alreadyUsed: true }]) { void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal no offer ${JSON.stringify(options)}`, () => { const f = createBureaucratBetrayalFundingFixture({ advanced, ...options }); const taken = useBetrayalFunding(f); assert.notEqual(taken.decision?.kind, 'bureaucratPayment'); assert.equal(taken.bureaucratPayments?.pending, undefined); proveReceipt(f, settleBetrayalFunding(taken), 0); assert.equal(viewGame(taken, f.trainer).bureaucrat.usedThisPhase, 'alreadyUsed' in options); if ('own' in options) assert.equal(f.holder, f.shipper); if ('donorAmount' in options) { assert.ok(f.price >= 5); assert.ok(f.primary < 5 && f.donorAmount < 5, 'Gross never pools sub-five native legs.'); } }); } void test(`${advanced ? 'Advanced' : 'Basic'} private receipt hides which native leg qualifies`, () => { const primary = createBureaucratBetrayalFundingFixture({ advanced, primary: 5, donorAmount: 3 }); const donor = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 }); const left = useBetrayalFunding(primary), right = useBetrayalFunding(donor); for (const viewer of left.players) { const a = viewGame(left, viewer.id), b = viewGame(right, viewer.id); for (const projected of [a, b]) { const prompt = projected.bureaucrat.pending!; assert.equal(prompt.payer, null); assert.equal(prompt.payee, primary.holder); assert.equal(prompt.amount, 8); assert.equal(prompt.owner, primary.trainer); assert.equal(prompt.kind, 'shipment'); assert.equal(prompt.redirect, 2); assert.equal('contributions' in prompt, false); assert.equal('signature' in prompt, false); assert.equal('bureaucratPayments' in projected, false); assert.equal('bureaucratUseEvents' in projected, false); } } }); void test(`${advanced ? 'Advanced' : 'Basic'} stale, cross-actor and malformed continuation choices reject immutably`, () => { const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 }); const pending = useBetrayalFunding(f); const choice: Action = { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }; for (const actor of pending.players.filter(p => p.id !== f.trainer)) rejectUnchanged(pending, actor.id, choice); rejectUnchanged(pending, f.trainer, { ...choice, event: f.event }); rejectUnchanged(pending, f.trainer, { ...choice, event: 'stale-payment' }); rejectUnchanged(pending, f.trainer, { ...choice, redirect: 'yes' }); rejectUnchanged(pending, f.trainer, { ...choice, amount: f.price }); rejectUnchanged(pending, f.shipper, { type: 'endMovement' }); rejectUnchanged(pending, f.holder, { type: 'guildBetrayalUse', event: f.event }); for (const field of ['event', 'recipient', 'amount', 'kind']) { const malformed = structuredClone(pending); const frame = malformed.bureaucratPayments!.pending!; assert.equal(frame.continuation.kind, 'guildBetrayal'); Object.assign(frame.continuation, { [field]: field === 'amount' ? f.price + 1 : field === 'recipient' ? f.guild : 'foreign' }); // Re-sign the outer frame to exercise the native receipt/continuation link, // not merely the generic changed-signature check. frame.signature = bureaucratPaymentSignature(frame); const saved = structuredClone(malformed); assert.throws(() => viewGame(malformed, f.trainer)); assert.throws(() => normalizeAutomaticGame(malformed)); rejectUnchanged(malformed, f.trainer, choice); assert.deepEqual(malformed, saved); } proveReceipt(f, settleBetrayalFunding(chooseBetrayalFunding(pending, true)), 2); }); void test(`${advanced ? 'Advanced' : 'Basic'} all four minimal owner bots legally resolve the original Betrayal receipt`, () => { const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 }); const pending = useBetrayalFunding(f); assert.equal(DIFFICULTIES.length, 4); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, f.trainer); view.players.find(p => p.id === f.trainer)!.bot = difficulty; const choices = botActions(view); assert.equal(choices.length, 1); const done = settleBetrayalFunding(applyAction(pending, f.trainer, choices[0])); proveReceipt(f, done, 2); assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true); rejectUnchanged(done, f.trainer, choices[0]); } }); void test(`${advanced ? 'Advanced' : 'Basic'} native no-skills Betrayal still credits its entire funded invoice without an offer`, () => { const f = createGuildBetrayalFixture({ advanced, allyPayment: 1 }); const taken = applyAction(f.game, f.holder, { type: 'guildBetrayalUse', event: f.event }); assert.notEqual(taken.decision?.kind, 'bureaucratPayment'); assert.equal(taken.bureaucratPayments?.pending, undefined); const done = settleBetrayalFunding(taken); assert.equal(seat(done, f.holder).spice, seat(f.before, f.holder).spice + f.price); assert.equal(seat(done, f.shipper).spice, seat(f.before, f.shipper).spice - f.game.pendingGuildBetrayal!.invoice.ownPayment); assert.equal(seat(done, f.donor!).spice, seat(f.before, f.donor!).spice); assert.equal(done.aid[f.donor!].amount, 0); assert.equal(playerControlledSpice(done), playerControlledSpice(f.before)); assert.deepEqual(physicalShipment(done), physicalShipment(f.original)); assert.deepEqual(done.nexusGuildBetrayalHistory!.at(-1)!.invoice, f.game.pendingGuildBetrayal!.invoice); assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, f.holder); assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']); rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event }); }); }