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

+41
View File
@@ -0,0 +1,41 @@
import type { Action, Game } from './engine';
export interface BribeOptions {
available: number;
incoming: number;
blocked: string | null;
targets: { id: string; maximum: number; limitation: string | null }[];
}
/** Mirror only the existing bribe timing gates; bribes are allowed out of turn. */
export function bribeTimingBlock(g: Game): string | null {
if (g.status !== 'playing') return 'Bribes require a game in progress.';
if (g.phase === 8) return 'Bribes cannot be made during Mentat Pause.';
if (g.inflation?.side === 'double') return 'Bribes are prohibited while Inflation shows Double.';
if (g.pendingTreacheryDiscard || g.pendingNullentropy || g.bureaucratPayments?.pending ||
['name', 'reveal'].includes(g.battle?.mentatQuestion?.stage ?? '') ||
g.nexusCards?.phase?.stage === 'drawing' || g.truthtrance ||
g.nexusTraitorExchanges?.some(exchange => exchange.stage === 'return') ||
g.phaseOpening || g.response || g.decision)
return 'Finish the current decision or response before paying a bribe.';
return null;
}
/** Payment only removes spendable spice, so a preserved completion is monotonic. */
export function maximumBribe(available: number, preservesCommitments: (amount: number) => boolean): number {
let low = 0, high = Math.max(0, Math.floor(available));
while (low < high) {
const amount = low + Math.ceil((high - low) / 2);
if (preservesCommitments(amount)) low = amount;
else high = amount - 1;
}
return low;
}
/** Client-side guard against a stale selection; the server revalidates every payment. */
export function bribeAction(options: BribeOptions, target: string, amount: number): Action | null {
const recipient = options.targets.find(candidate => candidate.id === target);
if (options.blocked || !recipient || !Number.isSafeInteger(amount) || amount < 1 || amount > recipient.maximum)
return null;
return { type: 'bribe', target, amount };
}
+45 -9
View File
@@ -1,3 +1,4 @@
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
@@ -7725,6 +7726,46 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
g.aid[p.id] = { recipient: p.ally, amount };
log(g, `${p.name} updated the spice available to their ally this phase.`);
}
function projectedBribes(g: Game, p: Player): BribeOptions {
const available = Math.max(0, uncommittedSpice(g, p));
const blocked = bribeTimingBlock(g);
const battlePromises = g.battle?.truthPromises?.some(promise =>
promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]);
const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0;
// Reuse the authoritative promise checks on an isolated resource-only copy.
// No payment, receipt or live continuation is created by this projection.
const maximum = !blocked && (battlePromises || shipmentPromises)
? maximumBribe(available, amount => {
const probe = structuredClone(g);
getPlayer(probe, p.id).spice -= amount;
try {
if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } });
if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } });
return true;
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
})
: available;
return {
available, incoming: p.bribes, blocked,
targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => {
let limit = maximum;
let limitation: string | null = maximum < available
? 'Some spice must remain available to fulfill your Truthtrance answer.' : null;
if (!blocked && limit >= 5) {
try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); }
catch (error) {
if (!(error instanceof RuleError)) throw error;
limit = 4;
limitation = error.message;
}
}
return { id: target.id, maximum: limit, limitation };
}),
};
}
function uncommittedSpice(g: Game, p: Player) {
return (
p.spice -
@@ -22734,14 +22775,8 @@ function applyActionInner(
return g;
}
if (t === 'bribe') {
requireRule(
g.inflation?.side !== 'double',
'Bribes are prohibited while Inflation shows Double.',
);
requireRule(
g.phase !== 8,
'Bribes cannot be made during the Mentat pause.',
);
const blocked = bribeTimingBlock(g);
requireRule(!blocked, blocked ?? 'Bribes are unavailable.');
const target = getPlayer(g, stringField(action.target));
requireRule(
target.id !== id && target.id !== p.ally,
@@ -22752,7 +22787,7 @@ function applyActionInner(
p.spice -= n;
log(
g,
`${p.name} promised ${n} spice to ${target.name}; collect it at the Mentat pause.`,
`${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable at the next Mentat Pause.`,
);
const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment;
if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n;
@@ -24455,6 +24490,7 @@ export function viewGame(state: Game, id: string) {
setupPending: setupPending(g),
leaderSkills: projectedLeaderSkills(g, id),
bureaucrat: projectedBureaucrat(g),
bribeOptions: projectedBribes(g, me),
mentat: projectedMentat(g, id),
rihani: projectedRihani(g, id),
advanced: g.advanced,
+24
View File
@@ -254,6 +254,30 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused checks compare all 24 offered storm dials and four spice scenarios with real engine actions, preserve v5 lesson identities, and compare resource payments, allowances and leader custody with engine actions, validate paid-save continuation and prior lesson migration, and cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts', 'tests/introduction-resources.test.ts', 'tests/introduction-resources-migration.test.ts', 'tests/introduction-opening.test.ts'] },
],
},
{
id: 'bribes',
title: 'Paying and collecting bribes',
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Pay a non-allied faction now; its recipient share becomes spendable at the next Mentat Pause.',
steps: [
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
'The recipient keeps paid spice separately until the next Mentat Pause. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
'You may pay out of turn when no decision or response is pending. You cannot bribe yourself or your ally, during Mentat Pause, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
'AI seats can receive bribes but do not negotiate promises yet. Discuss an agreement with human players, then use the corresponding game controls; prose deals are not automatically enforced.',
],
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing payment and escrow are connected to private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/bribe-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Named recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/bribes.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' },
{ area: 'Documentation', status: 'Implemented', detail: 'Payment, escrow, restrictions, saved continuation and remaining boundaries are recorded.', evidence: ['docs/BRIBE_CONTROLS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Projection/JSON, committed budgets, privacy, target validation, Bureaucrat decisions, rendered controls and concurrent HTTP payment are covered. Full integrated acceptance remains open.', evidence: ['tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts', 'tests/battle-promises.test.ts', 'tests/shipment-promises.test.ts'] },
],
},
{
id: 'table-discussion',
title: 'Table discussion and private messages',