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:
1 parent
7814f740ef
commit
91dcca46fb
16 files changed
+482
-26
No files matched your search
@@ -57,6 +57,10 @@ duplicates. Private history follows the seat through recovery or handover.
|
||||
Messages do not execute game actions, and AI opponents do not answer them yet.
|
||||
See [table discussion](docs/TABLE_DISCUSSION.md).
|
||||
|
||||
Use **Bribes** beneath the table to pay a non-allied faction. The recipient's
|
||||
share becomes spendable at the next Mentat Pause; the panel shows your own
|
||||
incoming bribes and the current payment limit. See [bribe controls](docs/BRIBE_CONTROLS.md).
|
||||
|
||||
The table header offers optional **Sound effects**, with mute, volume and a test
|
||||
sample. Short cues mark phase changes and automatic action notices. Sound starts
|
||||
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
|
||||
|
||||
@@ -1942,3 +1942,10 @@ footer {
|
||||
.table-talk-messages p { white-space: pre-wrap; unicode-bidi: plaintext; margin: 0.5rem 0 0; font-size: 1rem; }
|
||||
.table-talk-author { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.25rem 1rem; }
|
||||
.table-talk-author time { font-size: 0.875rem; }
|
||||
.bribe-panel { font-size: 1rem; }
|
||||
.bribe-panel > summary { cursor: pointer; min-height: 44px; padding-block: .65rem; font-weight: 600; }
|
||||
.bribe-panel p, .bribe-status { display: block; margin-block: .7rem; }
|
||||
.bribe-fields { display: flex; flex-wrap: wrap; align-items: end; gap: .8rem; }
|
||||
.bribe-fields label { display: grid; gap: .35rem; flex: 1 1 12rem; min-width: 0; }
|
||||
.bribe-fields select, .bribe-fields input { min-height: 44px; width: 100%; min-width: 0; font-size: 1rem; }
|
||||
.bribe-fields button { font-size: 1rem; min-height: 44px; max-width: 100%; height: auto; white-space: normal; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
import { useId, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Bribes({ game: g, act, busy }: {
|
||||
game: GameView; act: (action: Action) => void; busy: boolean;
|
||||
}) {
|
||||
const [target, setTarget] = useState('');
|
||||
const [amount, setAmount] = useState('1');
|
||||
const description = useId();
|
||||
if (g.status !== 'playing') return null;
|
||||
const options = g.bribeOptions;
|
||||
const recipient = options.targets.find(candidate => candidate.id === target);
|
||||
const player = recipient && g.players.find(candidate => candidate.id === recipient.id);
|
||||
const action = bribeAction(options, target, Number(amount));
|
||||
return (
|
||||
<details className="notice bribe-panel">
|
||||
<summary>Bribes{options.incoming > 0 ? ` · ${options.incoming} spice awaiting Mentat` : ''}</summary>
|
||||
<p id={description}>Pay another non-allied faction now. The amount is public; the recipient can spend their share only from the next Mentat Pause. A message or promise alone does not pay spice.</p>
|
||||
<p>Your incoming bribes: <strong>{options.incoming} spice</strong>, separate from your spendable supply.</p>
|
||||
{options.blocked ? <output className="bribe-status">{options.blocked}</output> : null}
|
||||
{options.targets.length === 0 ? <p>No non-allied recipient is available.</p> : (
|
||||
<div className="bribe-fields">
|
||||
<label>Bribe recipient
|
||||
<select value={recipient ? target : ''} onChange={event => setTarget(event.target.value)} disabled={busy || !!options.blocked}>
|
||||
<option value="">Choose a faction</option>
|
||||
{options.targets.map(candidate => {
|
||||
const seat = g.players.find(player => player.id === candidate.id)!;
|
||||
return <option key={seat.id} value={seat.id}>{seat.name} · {faction(seat.faction).name}</option>;
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label>Bribe amount
|
||||
<input type="number" inputMode="numeric" min={1} max={recipient?.maximum ?? options.available}
|
||||
step={1} value={amount} onChange={event => setAmount(event.target.value)}
|
||||
disabled={busy || !!options.blocked || !recipient} aria-describedby={description} />
|
||||
</label>
|
||||
<Button className="game-action" disabled={busy || !action} onClick={() => { if (action) act(action); }}>
|
||||
{player && action ? `Pay ${Number(amount)} spice to ${player.name}` : 'Pay bribe'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p>{recipient ? `You may pay up to ${recipient.maximum} spice to this recipient.` : `Uncommitted spice: ${options.available}. Choose a recipient to see the payment limit.`}</p>
|
||||
{recipient?.limitation ? <p>{recipient.limitation}</p> : null}
|
||||
{recipient && !options.blocked && !action ? <output className="bribe-status">{recipient.maximum < 1 ? 'No spice is available for this payment.' : `Enter a whole number from 1 to ${recipient.maximum}.`}</output> : null}
|
||||
<p>Eligible Bureaucrats may divert two spice to the Bank. AI opponents can receive spice, but do not negotiate deals yet. <Link href="/rules?topic=bribes#bribes">Bribe rules</Link></p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from '@/game/smu
|
||||
import { RihaniChoice, RihaniHistory } from './rihani-decipherer';
|
||||
import { MentatQuestion, MentatHistory } from './mentat-question';
|
||||
import { BureaucratPayment } from './bureaucrat-payment';
|
||||
import { Bribes } from './bribes';
|
||||
import { SukGraduatePanel } from './suk-graduate';
|
||||
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
|
||||
import { OrnithopterMovement } from './ornithopter-movement';
|
||||
@@ -4823,6 +4824,7 @@ export function GameTable({
|
||||
: ' · Flips at the next Mentat Pause'}
|
||||
</p>
|
||||
)}
|
||||
<Bribes game={g} act={act} busy={busy} />
|
||||
<TruthHistory game={g} />
|
||||
<section className="player-console">
|
||||
<nav className="console-tabs">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Ordinary bribe controls
|
||||
|
||||
21 September 2026. A connected prototype exposes the existing authoritative
|
||||
ordinary bribe action beneath the table. This does not complete negotiation,
|
||||
Leader Skills or expansion acceptance.
|
||||
|
||||
## Payment and controls
|
||||
|
||||
Choose a non-allied recipient and a positive whole amount, then explicitly pay.
|
||||
The payer loses spice immediately. The recipient's share is deferred until the
|
||||
next Mentat Pause; it is not current spending money. The public chronicle records
|
||||
the amount and recipient, while the panel exposes only its owning seat's supply
|
||||
and incoming escrow. Self/allied targets, Mentat payments and Inflation Double
|
||||
remain prohibited. Bribes are allowed out of turn at otherwise open boundaries.
|
||||
|
||||
These preserve the existing rules and [Bureaucrat source contract](BUREAUCRAT_PAYMENTS.md).
|
||||
The official [November FAQ, p. 9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=9)
|
||||
places paid bribes in front of the recipient's shield until the next Mentat Pause.
|
||||
Chat messages and verbal promises do not themselves transfer spice. Emperor
|
||||
gifts to allies remain a separate power.
|
||||
|
||||
The server derives the owner's limits from existing uncommitted spice, excluding
|
||||
sealed bids, paid support, Banker spending and other reserved costs. Existing
|
||||
Truthtrance completion checks further constrain voluntary spending on isolated
|
||||
copies; this projection does not commit actions or live continuations. The
|
||||
target-specific Bureaucrat guard can cap unsupported combinations below five.
|
||||
Supported payments use the existing saved redirect/allow decision. A pending
|
||||
response, decision or forced continuation disables the payment controls.
|
||||
|
||||
The client retains an invalid amount or removed recipient as a disabled draft
|
||||
rather than silently changing a payment. The explicit action uses existing
|
||||
request/version handling: concurrent or stale submissions cannot debit twice.
|
||||
After an uncertain result, reconcile the game before making another payment.
|
||||
Only committed game state survives refresh; unsubmitted form drafts do not.
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Focused tests cover out-of-turn transfer, JSON escrow/collection, private owner
|
||||
budgets, target and integer validation, phase/response restrictions, auction
|
||||
reservations, max/max-plus-one Truthtrance spending, Bureaucrat continuation and
|
||||
rendered controls. Production HTTP checks exercise concurrent duplicate payment,
|
||||
stale rejection, refresh and private views; existing Bureaucrat SQLite and all
|
||||
four legal AI response tests remain applicable. Source-bound checks, browser
|
||||
observations, preservation, independent review and Git delivery are retained
|
||||
with the private checkpoint.
|
||||
|
||||
AI seats can receive and later spend bribes through ordinary resource handling;
|
||||
existing Bureaucrat choices remain legal. Automated negotiations and strategic
|
||||
bribe initiation wait for the post-feature AI stage. Textual deals are not
|
||||
automatically enforced. Complete negotiation, responsive/assistive acceptance
|
||||
and all legal expansion combinations remain open.
|
||||
@@ -39,6 +39,23 @@ CHOAM-only skill profile. Other optional modules retain their existing gates.
|
||||
Tleilaxu revival, other expansion payment sources and direct gifts remain outside
|
||||
this connected batch. Their absence does not establish a rule prohibition.
|
||||
|
||||
### 21 September gift classification review
|
||||
|
||||
A bounded primary-source review did not resolve whether Emperor gifts count as
|
||||
the card's "payment." The [November FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=5)
|
||||
describes Emperor giving/sharing spice with allies separately from the ally's
|
||||
later auction payment. The designer's examples name income recipients, not
|
||||
outgoing gifts. The [base secrecy rule, p. 12](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=12)
|
||||
does not settle gift-amount disclosure for this combination. Current gift
|
||||
amounts are projected only to Emperor and recipient. Even a threshold-only
|
||||
Bureaucrat prompt would reveal that the gift qualifies; the existing exact-amount
|
||||
payment projection must not be applied blindly.
|
||||
|
||||
The user has been asked whether to retain this boundary or apply Bureaucrat
|
||||
with only the qualifying threshold public. No answer is assumed. Gifts remain
|
||||
outside the connected Bureaucrat payment paths while independent ordinary
|
||||
[bribe controls](BRIBE_CONTROLS.md) proceed.
|
||||
|
||||
For native income, the prototype resolves the existing Karama income window
|
||||
first, then offers Bureaucrat before crediting the eligible recipient and
|
||||
continuing the original auction or shipment. A canceled income payment goes to
|
||||
|
||||
+16
-17
@@ -6,24 +6,23 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest delivered checkpoint: `b2fcef8`, [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md).
|
||||
Types, lint, **4,954 offline tests** and production build passed. All 1,373 rooms,
|
||||
2,883 seats and existing auxiliary rows stayed unchanged. The running server was
|
||||
reused. Earlier checkpoints include `66c41b2` for Kwisatz Haderach inspection,
|
||||
`ed53eb7` for Storm Cards and `67e429e` for Charity/Revival practice.
|
||||
The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement
|
||||
until feature completion, with three adjacent 75% targets. See the
|
||||
[implementation history](IMPLEMENTATION_STATUS.md).
|
||||
Latest delivered checkpoint: `7814f74`, [durable table discussion](TABLE_DISCUSSION.md).
|
||||
Types, lint, **4,963 offline tests**, production build and **48 HTTP tests** passed.
|
||||
All 1,373 opening rooms, 2,883 seats and existing auxiliary rows stayed unchanged;
|
||||
new isolated QA brought the local baseline to 1,418 rooms and 2,991 seats. The
|
||||
running server was reused. Earlier checkpoints include `b2fcef8` for Storm/Spice
|
||||
practice and `66c41b2` for Kwisatz Haderach inspection. The `8f7a62c` policy
|
||||
checkpoint removed the usage cutoff and deferred AI refinement until feature
|
||||
completion, with three adjacent 75% targets.
|
||||
|
||||
Current work: [table discussion](TABLE_DISCUSSION.md) adds public conversation and
|
||||
private human-seat messages, separate from game state and decision continuation.
|
||||
Durable history, audience-filtered pagination, exact retries and active-session
|
||||
fences support communication through refresh and seat recovery. AI conversation
|
||||
and prose-deal enforcement remain unfinished. Source-bound checks, independent
|
||||
review, browser and preservation/Git evidence belong to the private checkpoint.
|
||||
A fresh bounded Terror/Kull audit confirmed existing pending rulings; independent
|
||||
multiplayer communication proceeds without inventing their outcomes. The full
|
||||
rules, multiplayer and component acceptance gates remain open.
|
||||
Current work: [ordinary bribe controls](BRIBE_CONTROLS.md) connect existing engine
|
||||
payments to named recipients, private deferred balances and authoritative
|
||||
spending limits. Binding Truthtrance answers, reserved costs and Bureaucrat
|
||||
continuations remain intact. Source-bound checks, independent review, browser
|
||||
and preservation/Git evidence belong to the private checkpoint. Strategic AI
|
||||
negotiation and complete rules/multiplayer acceptance remain open. The independent
|
||||
Bureaucrat/Emperor gift source review found an unresolved payment-classification
|
||||
and disclosure boundary; the existing user question remains pending.
|
||||
|
||||
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
||||
still cross recorded pending rulings. Continue independent features rather than
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 21 September 2026 — ordinary bribe controls
|
||||
|
||||
Players can now initiate the existing out-of-turn bribe action, with server-derived
|
||||
recipient limits, private incoming escrow and public payment history. Limits
|
||||
preserve reserved funds and binding Truthtrance completions; existing Bureaucrat
|
||||
redirection and saved version handling remain authoritative. See
|
||||
[bribe controls](BRIBE_CONTROLS.md). Focused rules/projection/rendering and HTTP
|
||||
concurrency checks accompany independent review and private source-bound browser,
|
||||
preservation and Git evidence. Strategic AI negotiation remains deferred.
|
||||
The separately reviewed Emperor gift/Bureaucrat interaction remains pending;
|
||||
this work does not open mode or publication gates.
|
||||
|
||||
## 21 September 2026 — durable table discussion prototype
|
||||
|
||||
Public table conversation and direct human-seat messages now have usable controls,
|
||||
|
||||
@@ -49,6 +49,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
|
||||
| Boundary | Still unresolved | Existing record |
|
||||
| --- | --- | --- |
|
||||
| Bureaucrat / Emperor gifts | Whether an allied gift is a qualifying payment and whether its threshold may be revealed. Existing gift amounts are private to payer/recipient; the card and retrieved primary sources do not explicitly settle this combination. The user question is pending; no answer or exact-amount disclosure is assumed. | [Gift classification review](BUREAUCRAT_PAYMENTS.md#21-september-gift-classification-review) |
|
||||
| Reinforcements cost | A user question asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost. No answer is assumed. Advanced Atreides loss accounting and same-plan Harass ordering are separate unresolved combinations. Eligibility must not depend on concealed matching Traitors. | [Fresh cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) |
|
||||
| Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) |
|
||||
| Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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), '');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user