Files
Dune/tests/bureaucrat-payment-recovery.test.ts
T
Quintine d0dc7007d9 Connect Bureaucrat payment redirection and saved continuation
Add an optional owner-controlled two-spice diversion from eligible single-payer ordinary auctions, Guild shipment income, Richese auction proceeds and actual bribes in the bounded base/CHOAM Leader Skills profile. Debit the payer once, credit the recipient once, retain bribes until Mentat Pause and restore the original response/auction/advisor callback. Decline preserves later phase opportunities; successful physical-card usage persists ownership changes. Keep qualifying allied funding and unfinished payment families explicitly guarded or outside the connected batch. Record source authority and the out-of-battle Karama composition and physical-card usage inferences; no public mode or publication gate opens.

Connect readable owner controls and all four AI profiles using public payment/alliance information. Independent review found and fixed duplicate usage-event aliases and an overbroad guard on Bank-bound independent Karama shipments. Focused tests cover native/hidden/captured/dead eligibility, unchanged costs and card/force custody, normal and Richese auction callbacks, real Bene Gesserit advisor continuation, private views, JSON and production SQLite restore, stale/duplicate rejection and same-version competing writes.

Validation: typecheck, lint, 4631 offline tests, production build and 43 HTTP tests passed with unchanged source. Four controlled-shuffle genuine-setup samples completed: base Basic 718 actions/20 restores, base Advanced 551/15, CHOAM Basic 681/19 and CHOAM Advanced 174/5. All had zero rejected candidates and preserved full physical Treachery, force and skill custody. None reached a Bureaucrat payment decision; these are regression-continuation samples, not natural full-game redirection acceptance. The separate conserved staged browser exercise allowed one five-spice bribe and redirected two from a second; the payer spent ten, the payee retained eight deferred spice, and one skill use was recorded. Pending and resolved states survived refresh with unchanged physical custody and private projection.

After the reported outage, all 699 opening games and original seat/recovery records were preserved (733 after HTTP checks); database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation.

Verified source tree: a30615deed306e06ab4c153b9a7eabdc92d646ed79474e348a3500d525bcb8c8
2026-09-14 00:34:35 +10:00

355 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import {
bureaucratPaymentGame,
beginBureaucratAuction,
takeBureaucratCard,
} from './bureaucrat-payment-fixture';
const clock: Rooms.RoomsClock = { now: () => 81_000, sleep: async () => {} };
const hash = (s: string) => createHash('sha256').update(s).digest('hex');
const rows = (db: DatabaseSync) => ({
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all(),
});
function physical(game: Game) {
return {
players: game.players.map(
({ spice: _spice, bribes: _bribes, ...player }) => player,
),
deck: game.deck,
discard: game.discard,
skills: game.leaderSkills,
};
}
type PaymentKind = 'bribe' | 'shipment' | 'auction' | 'cache' | 'blackMarket';
function paidAuction(
kind: 'auction' | 'cache' | 'blackMarket',
advanced: boolean,
): Game {
let game = bureaucratPaymentGame({ advanced, choam: kind !== 'auction' });
const offered =
kind === 'blackMarket' ? takeBureaucratCard(game, 'r', 'Baliset') : null;
game = beginBureaucratAuction(game);
if (kind === 'auction') {
while (game.auction!.active !== 'p')
game = applyAction(game, game.auction!.active, { type: 'passBid' });
game = applyAction(game, 'p', { type: 'bid', amount: 5 });
for (let i = 0; !game.currentAuctionSale && i < 12; i++)
game = applyAction(game, game.auction!.active, { type: 'passBid' });
} else {
if (kind === 'cache')
game = applyAction(game, 'r', {
type: 'decision',
event: game.richeseBidding!.event,
position: 'first',
});
game = applyAction(game, 'r', {
type: 'decision',
event: game.richeseBidding!.event,
card: offered?.id ?? game.richeseCache![0].id,
method: 'silent',
});
for (const id of game.richeseAuction!.order)
game = applyAction(game, id, {
type: 'richeseBid',
event: game.richeseAuction!.event,
amount: id === 'p' ? 5 : 0,
});
}
return game;
}
async function fixture(
t: test.TestContext,
kind: PaymentKind,
advanced = false,
) {
const store = unitStore();
t.after(() => store.sqlite.close());
let game =
kind === 'bribe' || kind === 'shipment'
? applyAction(
bureaucratPaymentGame({ advanced }),
'p',
kind === 'bribe'
? { type: 'bribe', target: 'e', amount: 5 }
: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5 },
)
: paidAuction(kind, advanced);
if (game.decision?.kind === 'guildShipment')
game = applyAction(game, game.decision.player, {
type: 'decision',
allow: true,
});
assert.equal(game.decision?.kind, 'bureaucratPayment');
game.version = 41;
const code = game.code,
event = game.decision!.event!;
store.sqlite
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
.run(code, JSON.stringify(game), game.version, 80_000);
const tokens = Object.fromEntries(
game.players.map((p) => [p.id, hash('bureaucrat-test:' + p.id)]),
);
const auths: Record<string, Rooms.SeatAuth> = {};
for (const p of game.players) {
store.sqlite
.prepare(
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
)
.run(hash(tokens[p.id]), code, p.id);
auths[p.id] = await store.rooms.authenticate(code, tokens[p.id]);
}
return {
...store,
code,
event,
initial: game,
tokens,
auths,
payee:
kind === 'shipment'
? 'g'
: kind === 'cache' || kind === 'blackMarket'
? 'r'
: 'e',
kind,
};
}
async function views(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
const before = rows(f.sqlite),
room = f.restart();
for (const player of game.players) {
const auth = await room.authenticate(f.code, f.tokens[player.id]);
const view = await room.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(game, player.id));
assert.equal('bureaucratPayments' in view, false);
assert.equal('bureaucratUseEvents' in view, false);
for (const rival of view.players.filter((p) => p.id !== player.id)) {
assert.equal(rival.hand, undefined);
assert.equal(rival.traitors, undefined);
}
if (view.bureaucrat.pending)
assert.deepEqual(
Object.keys(view.bureaucrat.pending).sort(),
[
'event',
'owner',
'payer',
'payee',
'amount',
'kind',
'redirect',
].sort(),
);
}
assert.deepEqual(rows(f.sqlite), before);
}
void test('saved Bureaucrat bribe and Guild choices resume once with unchanged payer cost and physical custody', async (t) => {
for (const advanced of [false, true])
for (const kind of ['bribe', 'shipment'] as const)
for (const redirect of [false, true]) {
const f = await fixture(t, kind, advanced),
before = rows(f.sqlite),
original = f.initial.players.find((p) => p.id === f.payee)!;
const action = { type: 'decision', event: f.event, redirect };
await views(f, f.initial);
await assert.rejects(f.rooms.act(f.code, f.auths.p, 41, action));
await assert.rejects(
f.rooms.act(f.code, f.auths.b, 40, action),
/table changed/,
);
await assert.rejects(
f.rooms.act(f.code, f.auths.b, 41, {
...action,
event: 'stale-event',
}),
);
await assert.rejects(
f.rooms.act(f.code, f.auths.b, 41, { ...action, redirect: 'yes' }),
);
assert.deepEqual(rows(f.sqlite), before);
assert.equal(f.writes.length, 0);
await f.restart().act(f.code, f.auths.b, 41, action, clock);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, 42);
assert.deepEqual(physical(after), physical(f.initial));
const recipient = after.players.find((p) => p.id === f.payee)!,
amount = redirect ? 3 : 5;
assert.equal(
recipient.spice,
original.spice + (kind === 'shipment' ? amount : 0),
);
assert.equal(
recipient.bribes,
original.bribes + (kind === 'bribe' ? amount : 0),
);
assert.equal(
after.players.find((p) => p.id === 'p')!.spice,
f.initial.players.find((p) => p.id === 'p')!.spice,
);
assert.equal(after.bureaucratPayments?.used.length, Number(redirect));
assert.equal(
after.decision?.kind,
f.initial.bureaucratPayments!.pending!.resume.decision?.kind,
);
await views(f, after);
const saved = rows(f.sqlite);
await assert.rejects(
f.rooms.act(f.code, f.auths.b, 41, action),
/table changed/,
);
await assert.rejects(f.rooms.act(f.code, f.auths.b, 42, action));
assert.deepEqual(rows(f.sqlite), saved);
assert.deepEqual(
f.writes.map((w) => w.changes),
[1],
);
}
});
void test('competing Bureaucrat redirect and allowance commit one recipient share without duplicate debit', async (t) => {
const f = await fixture(t, 'bribe');
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let arrived = 0;
f.hooks.beforeWrite = async () => {
arrived++;
if (arrived === 2) release();
await gate;
};
const results = await Promise.allSettled(
[true, false].map((redirect) =>
f
.restart()
.act(
f.code,
f.auths.b,
41,
{ type: 'decision', event: f.event, redirect },
clock,
),
),
);
f.hooks.beforeWrite = undefined;
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, 42);
const amount = after.players.find((p) => p.id === 'e')!.bribes;
assert.ok(amount === 3 || amount === 5);
assert.equal(after.bureaucratPayments!.used.length, amount === 3 ? 1 : 0);
assert.deepEqual(physical(after), physical(f.initial));
assert.equal(after.players.find((p) => p.id === 'p')!.spice, 25);
await views(f, after);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
});
function auctionInventory(game: Game) {
return [
...game.deck,
...game.discard,
...game.players.flatMap((player) => player.hand),
...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(
game.auction.index +
(game.currentAuctionSale?.origin === 'normal' ? 1 : 0),
) ?? []),
]
.map((card) => card.id)
.sort((a, b) => a.localeCompare(b));
}
void test('saved normal, Richese cache and Black Market payments resume card custody and auction callbacks once after SQLite restart', async (t) => {
for (const kind of ['auction', 'cache', 'blackMarket'] as const) {
const redirect = kind !== 'blackMarket',
f = await fixture(t, kind, kind === 'blackMarket');
const source = f.initial.currentAuctionSale!;
const purchased =
kind === 'auction'
? f.initial.auction!.cards[f.initial.auction!.index].id
: f.initial.richeseAuction!.cardId;
const inventory = auctionInventory(f.initial),
payer = f.initial.players.find((player) => player.id === 'p')!;
assert.equal(source.winner, 'p');
assert.equal(source.amount, 5);
assert.equal(payer.spice, 25);
assert.equal(payer.hand.filter((card) => card.id === purchased).length, 1);
assert.equal(payer.hand.length, 1);
assert.equal(
f.initial.bureaucratPayments!.pending!.continuation.kind,
kind === 'auction' ? 'response' : 'auction',
);
await views(f, f.initial);
if (kind === 'blackMarket')
assert.ok(!JSON.stringify(viewGame(f.initial, 'g')).includes(purchased));
const action = { type: 'decision', event: f.event, redirect },
before = rows(f.sqlite);
await assert.rejects(f.rooms.act(f.code, f.auths.p, 41, action));
assert.deepEqual(rows(f.sqlite), before);
const restarted = f.restart(),
auth = await restarted.authenticate(f.code, f.tokens.b);
await restarted.act(f.code, auth, 41, action, clock);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, 42);
assert.equal(after.currentAuctionSale, null);
assert.equal(after.bureaucratPayments!.pending, undefined);
assert.deepEqual(auctionInventory(after), inventory);
for (const initial of f.initial.players) {
const actual = after.players.find((player) => player.id === initial.id)!;
assert.equal(
actual.spice,
initial.spice + (actual.id === f.payee ? (redirect ? 3 : 5) : 0),
);
const { spice: _initialSpice, hand: _initialHand, ...original } = initial;
const { spice: _actualSpice, hand: _actualHand, ...current } = actual;
assert.deepEqual(current, original);
}
const winner = after.players.find((player) => player.id === 'p')!;
assert.equal(winner.hand.length, 2);
assert.equal(winner.hand.filter((card) => card.id === purchased).length, 1);
assert.equal(
after.log.filter((entry) =>
entry.text.includes('drew one bonus treachery card'),
).length,
f.initial.log.filter((entry) =>
entry.text.includes('drew one bonus treachery card'),
).length + 1,
);
assert.equal(after.bureaucratPayments!.used.length, Number(redirect));
if (kind === 'auction') assert.equal(after.auction!.index, 1);
else if (kind === 'cache') {
assert.equal(after.richeseAuction, null);
assert.equal(after.auction!.index, 0);
} else {
assert.equal(after.richeseAuction, null);
assert.equal(after.decision?.kind, 'richeseDeclaration');
}
await views(f, after);
if (kind === 'blackMarket')
assert.ok(!JSON.stringify(viewGame(after, 'g')).includes(purchased));
const saved = rows(f.sqlite);
await assert.rejects(
f.restart().act(f.code, f.auths.b, 41, action),
/table changed/,
);
await assert.rejects(f.restart().act(f.code, f.auths.b, 42, action));
assert.deepEqual(rows(f.sqlite), saved);
assert.deepEqual(
f.writes.map((write) => write.changes),
[1],
);
}
});