Connect ordinary bribe payments to player controls

Players can select a non-allied recipient and explicitly pay a whole-spice bribe
outside Mentat and open decision/response windows. The server derives private
payment limits from uncommitted spice and existing joint Truthtrance completion
checks on isolated copies. Target-specific Bureaucrat guards preserve supported
redirect/allow continuations. Reserved bids, sealed spending and incoming escrow
cannot be spent twice. No strategic AI negotiation or mode/publication changes.

The panel separates deferred incoming bribes from spendable supply, disables
stale/invalid selections and uses existing request/version fencing. The chronicle
now describes actual payment and next-Mentat spendability rather than a promise.
Internal guidance and the implementation checklist record this boundary. The
separate Emperor gift/Bureaucrat source ambiguity remains explicitly pending.

Independent review found no material issue. Focused tests cover out-of-turn
payment, JSON escrow/collection, private budgets, invalid targets/amounts,
phase/response gates, reserved bids, joint promises, supported redirection and
rendered controls. Existing Bureaucrat SQLite/all-four legal response tests pass.
The new HTTP test races duplicate submissions and verifies one debit, refreshed
private balances and stale/over-budget rejection.

Types, lint, all 4968 offline tests, production build and 49 HTTP tests
pass on stable source. Preliminary checks caught a typed fixture opener and
semantic-status/internal-link/template lint requirements and the new SSR test
needed the existing Vinext Link alias. All were corrected before the
final4 run. Browser QA used genuine fresh Basic setup with a human payer and
Easy AI recipient: 11 rejected by controls, 4 paid from10 leaving6, one public
payment entry and unchanged recipient spendable10 with4 in escrow after refresh.
Keyboard selection and amount access passed. At390px the panel fits with16px
text and44px-or-larger controls; linked rules and back navigation work. Screenshot
capture timed out; full visual/assistive and all-configuration acceptance remain
open. Browser transport loss was not injected; HTTP concurrency is separately
covered. This is playability evidence, not difficulty calibration.

All 1418 original rooms, 2991 seats and auxiliary rows are
unchanged. SQLite integrity and foreign keys pass. The healthy server was reused
without restart, migration, reset or recurring maintenance.

Private evidence: 20260921-bribe-controls.
Verified source tree: b9a8594c5dcd85f78974c9647f24f5f6eba60a38c9dcd2badcf4529d1e825ae1
This commit is contained in:
Quintine committed 2026-09-21 10:22:18 +10:00
1 parent 7814f740ef
commit 91dcca46fb
16 files changed
+482 -26

No files matched your search

+6
View File
@@ -276,6 +276,12 @@ void test('a player cannot give away support spice to escape their own battle pr
initial.players[1].spice = 3;
const g = promise(initial, { kind: 'support', compare: 'gte', value: 3 });
const before = JSON.stringify(g);
assert.equal(viewGame(g, 'e').bribeOptions.targets.find(p => p.id === 'b')!.maximum, 0);
const extra = structuredClone(g);
extra.players[1].spice += 2;
assert.equal(viewGame(extra, 'e').bribeOptions.targets.find(p => p.id === 'b')!.maximum, 2);
assert.ok(applyAction(extra, 'e', { type: 'bribe', target: 'b', amount: 2 }));
assert.throws(() => applyAction(extra, 'e', { type: 'bribe', target: 'b', amount: 3 }));
assert.throws(
() => applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 1 }),
/voluntarily/,
+61
View File
@@ -0,0 +1,61 @@
// @dune-suite integration
import assert from 'node:assert/strict';
import test from 'node:test';
import type { GameView } from '../game/engine';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(path: string, cookie?: string, body?: unknown) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
signal: AbortSignal.timeout(15_000),
headers: { ...(cookie ? { cookie } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
data: await response.json() as GameView };
}
void test('bribe HTTP payment is atomic, rejects duplicate/stale writes and restores owner-only budgets', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Bribe HTTP payer', faction: 'atreides', advanced: false, expansions: [],
});
assert.equal(host.status, 201); assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const peer = await request(path, undefined, { type: 'join', name: 'Bribe HTTP recipient', faction: 'harkonnen' });
assert.equal(peer.status, 200); assert.ok(peer.cookie);
let view = peer.data;
async function act(cookie: string, action: object) {
const next = await request(path, cookie, { version: view.version, action });
assert.equal(next.status, 200); view = next.data;
}
await act(host.cookie, { type: 'ready' });
await act(peer.cookie, { type: 'ready' });
await act(host.cookie, { type: 'start' });
const own = view.players.find(p => p.id === host.data.me)!;
await act(host.cookie, { type: 'traitor', leader: own.traitorChoices![0] });
assert.equal(view.status, 'playing');
assert.equal(view.bribeOptions.available, 10);
const version = view.version;
const action = { type: 'bribe', target: peer.data.me, amount: 4 };
const replies = await Promise.all([
request(path, host.cookie, { version, action }), request(path, host.cookie, { version, action }),
]);
assert.equal(replies.filter(reply => reply.status === 200).length, 1);
assert.equal(replies.filter(reply => reply.status >= 400).length, 1);
for (const [cookie, id, available, incoming] of [
[host.cookie, host.data.me, 6, 0], [peer.cookie, peer.data.me, 10, 4],
] as const) {
const restored = await request(path, cookie);
assert.equal(restored.status, 200);
assert.equal(restored.data.version, version + 1);
assert.equal(restored.data.me, id);
assert.equal(restored.data.bribeOptions.available, available);
assert.equal(restored.data.bribeOptions.incoming, incoming);
for (const rival of restored.data.players.filter(p => p.id !== id)) {
assert.equal(rival.spice, undefined); assert.equal(rival.bribes, undefined);
assert.equal(rival.hand, undefined); assert.equal(rival.traitors, undefined);
}
}
assert.ok((await request(path, host.cookie, { version: version + 1, action: { ...action, amount: 7 } })).status >= 400);
assert.equal((await request(path, host.cookie)).data.version, version + 1);
});
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { registerHooks } from 'node:module';
import { bribeAction } from '../game/bribe-options';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { bureaucratPaymentGame } from './bureaucrat-payment-fixture';
const aliases = registerHooks({ resolve(specifier, context, next) {
return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
} });
const { Bribes } = await import('../components/bribes');
aliases.deregister();
function game() {
let g = createGame('BRIBES22', newPlayer('a', 'Atreides payer', 'atreides'));
joinGame(g, newPlayer('h', 'Harkonnen recipient', 'harkonnen'));
joinGame(g, newPlayer('e', 'Emperor observer', 'emperor'));
g.players.forEach(p => { p.ready = true; });
g = applyAction(g, 'a', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
assert.equal(g.status, 'playing');
return g;
}
function options(g: Game, id = 'a') { return viewGame(g, id).bribeOptions; }
function reject(g: Game, actor: string, action: object) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, actor, action as Parameters<typeof applyAction>[2]));
assert.equal(JSON.stringify(g), before);
}
void test('out-of-turn bribes debit once, stay private as escrow and collect once at Mentat after JSON restoration', () => {
let g = game();
g.active = 'e';
const before = JSON.stringify(g);
const quote = options(g);
assert.equal(quote.blocked, null);
assert.equal(quote.available, 10);
assert.deepEqual(quote.targets.map(p => p.id), ['h', 'e']);
assert.equal(JSON.stringify(g), before);
const action = bribeAction(quote, 'h', 4)!;
assert.deepEqual(action, { type: 'bribe', target: 'h', amount: 4 });
g = JSON.parse(JSON.stringify(applyAction(g, 'a', action)));
assert.equal(g.players[0].spice, 6);
assert.equal(g.players[1].spice, 10);
assert.equal(options(g, 'h').incoming, 4);
assert.equal(options(g, 'h').available, 10);
assert.equal(options(g, 'e').incoming, 0);
assert.equal(viewGame(g, 'e').players[1].bribes, undefined);
assert.match(g.log.at(-1)!.text, /paid a 4-spice bribe/);
g.phase = 7; // Controlled later collection boundary, not a full-game sample.
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 8);
assert.equal(g.players[1].spice, 14);
assert.equal(options(g, 'h').incoming, 0);
assert.match(options(g).blocked!, /Mentat/);
assert.deepEqual(options(JSON.parse(JSON.stringify(g)), 'h'), options(g, 'h'));
});
void test('invalid amounts, self/allied recipients and stale client options never authorize a payment', () => {
const g = game();
g.players[0].ally = 'h'; g.players[1].ally = 'a';
const quote = options(g);
assert.deepEqual(quote.targets.map(p => p.id), ['e']);
for (const target of ['a', 'h', 'missing']) {
assert.equal(bribeAction(quote, target, 1), null);
reject(g, 'a', { type: 'bribe', target, amount: 1 });
}
for (const amount of [0, -1, 1.5, 11, NaN, Infinity]) {
assert.equal(bribeAction(quote, 'e', amount), null);
reject(g, 'a', { type: 'bribe', target: 'e', amount });
}
for (const paused of [
{ phase: 8 },
{ inflation: { side: 'double', turn: 2 } },
{ phaseOpening: { passed: [], initialize: false } },
{ response: { kind: 'emperorGift', owner: 'e', recipient: 'h', amount: 1, passed: [] } },
{ truthtrance: { stage: 'priority', queue: [], passed: [], question: null } },
]) {
const waiting = Object.assign(structuredClone(g), paused) as Game;
assert.ok(options(waiting).blocked);
assert.equal(bribeAction(options(waiting), 'e', 1), null);
reject(waiting, 'a', { type: 'bribe', target: 'e', amount: 1 });
}
});
void test('own auction commitment limits bribes without revealing a rivals supply', () => {
const g = game();
g.phase = 3;
const card = g.deck.pop()!;
g.auction = { cards: [card], index: 0, bid: 7, bidder: 'a', allyPayment: 0, active: 'e', opener: 0, passed: [] };
assert.equal(options(g).available, 3);
const other = structuredClone(g);
other.players[1].spice = 999; other.players[1].bribes = 88;
assert.deepEqual(options(other), options(g));
assert.equal(options(applyAction(g, 'a', { type: 'bribe', target: 'h', amount: 3 })).available, 0);
reject(g, 'a', { type: 'bribe', target: 'h', amount: 4 });
});
void test('Bureaucrat eligibility stays recipient-specific and a saved payment offers its existing decision', () => {
const g = bureaucratPaymentGame();
const paid = applyAction(g, 'p', bribeAction(options(g, 'p'), 'e', 5)!);
assert.equal(paid.decision?.kind, 'bureaucratPayment');
assert.ok(options(paid, 'p').blocked);
const settled = applyAction(JSON.parse(JSON.stringify(paid)), 'b', {
type: 'decision', event: paid.bureaucratPaymentEvent, redirect: true,
});
assert.equal(options(settled, 'e').incoming, 3);
assert.equal(options(settled, 'p').available, 25);
const unsupported = structuredClone(g);
unsupported.expansions.push('ix');
assert.equal(options(unsupported, 'p').targets.find(p => p.id === 'e')!.maximum, 4);
assert.equal(options(unsupported, 'p').targets.find(p => p.id === 'b')!.maximum, 30);
reject(unsupported, 'p', { type: 'bribe', target: 'e', amount: 5 });
});
void test('bribe controls render recipient choices, private escrow and disabled unselected or busy payment', () => {
const g = game(); g.players[0].bribes = 4;
const render = (state: Game, busy = false) => renderToStaticMarkup(createElement(Bribes, { game: viewGame(state, 'a'), busy, act() {} }));
const html = render(g);
assert.match(html, /4 spice awaiting Mentat/);
assert.match(html, /Bribe recipient/);
assert.match(html, /Harkonnen recipient/);
assert.match(html, /the next Mentat Pause/);
assert.match(html, /disabled=""[^>]*>Pay bribe/);
assert.match(html, /rules\?topic=bribes#bribes/);
assert.ok((render(g, true).match(/disabled=""/g) ?? []).length >= 3);
g.phase = 8;
assert.match(render(g), /Bribes cannot be made during Mentat Pause/);
g.status = 'lobby';
assert.equal(render(g), '');
});
+8
View File
@@ -378,6 +378,14 @@ void test('voluntary bribes and spending the only required Ghola or Karama canno
const plain = fixture();
plain.players[1].spice = 6;
const g = bind(plain);
const original = JSON.stringify(g);
assert.equal(viewGame(g, 'p').bribeOptions.targets.find(p => p.id === 'o')!.maximum, 0);
const extra = structuredClone(g);
extra.players[1].spice += 2;
assert.equal(viewGame(extra, 'p').bribeOptions.targets.find(p => p.id === 'o')!.maximum, 2);
assert.ok(applyAction(extra, 'p', { type: 'bribe', target: 'o', amount: 2 }));
reject(extra, 'p', { type: 'bribe', target: 'o', amount: 3 });
assert.equal(JSON.stringify(g), original);
reject(g, 'p', { type: 'bribe', target: 'o', amount: 1 });
const discounted = fixture();
discounted.players[1].spice = 3;