Files
Dune/tests/bureaucrat-betrayal-funding.test.ts

211 lines
13 KiB
TypeScript

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 });
});
}