From 6da6ff7f8372f1520d641b97b63c3d5bbd88baac Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:54:20 +1100 Subject: [PATCH] Support immediate payments in public deals --- README.md | 5 +- components/public-deals.tsx | 61 ++++++++- docs/BRIBE_CONTROLS.md | 5 +- docs/CURRENT_STATUS.md | 17 ++- docs/IMPLEMENTATION_STATUS.md | 12 ++ docs/PUBLIC_DEALS.md | 21 ++- docs/RULE_DECISIONS.md | 7 +- game/engine.ts | 35 ++++- game/public-deals.ts | 31 ++++- game/reference.ts | 7 +- tests/public-deals-controls.test.tsx | 42 +++++- tests/public-deals-payment-engine.test.ts | 149 ++++++++++++++++++++++ tests/public-deals-payment-http.test.ts | 129 +++++++++++++++++++ tests/public-deals.test.ts | 56 +++++++- 14 files changed, 545 insertions(+), 32 deletions(-) create mode 100644 tests/public-deals-payment-engine.test.ts create mode 100644 tests/public-deals-payment-http.test.ts diff --git a/README.md b/README.md index 1cdeeb47..b35bac6f 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ accept or decline an offer, and agree to release it. Every party must accept, and every pair of parties must be nonallied when the offer is made or accepted. Terms are visible to the table and rely on players to honor them; the game does not interpret promises -or determine impossibility. Use **Bribes** separately for an actual spice -payment. AI negotiation and automatic enforcement remain unfinished. See +or determine impossibility. An optional immediate spice payment is executed +using the normal bribe rules when the final participant accepts. AI negotiation +and enforcement of future promises remain unfinished. See [public deals](docs/PUBLIC_DEALS.md). For the provisional two-person setup, the host enables **Two players, two diff --git a/components/public-deals.tsx b/components/public-deals.tsx index 93451c38..0de851d1 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -7,14 +7,19 @@ import type { Action, GameView } from '@/game/engine'; import { Button } from '@/components/ui/button'; type Deal = GameView['deals'][number]; +type ImmediateBribe = { kind: 'immediateBribe'; payer: string; recipient: string; amount: number }; /** Keep the legacy single-recipient wire packet when only one seat is chosen. */ -export function publicDealOfferAction(targets: readonly string[], terms: string): Action | null { +export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: ImmediateBribe, proposer?: string): Action | null { const text = terms.trim(); if (!targets.length || targets.length > 5 || new Set(targets).size !== targets.length || !text || text.length > 500) return null; + if (payment && (!proposer || payment.kind !== 'immediateBribe' || payment.payer === payment.recipient || + ![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) || + !Number.isSafeInteger(payment.amount) || payment.amount < 1)) return null; + const offeredPayment = payment ? { payment } : {}; return targets.length === 1 - ? { type: 'dealOffer', target: targets[0], terms: text } - : { type: 'dealOffer', targets: [...targets], terms: text }; + ? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment } + : { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment }; } /** The whole table can read an offer and its history; only its parties act. */ @@ -40,18 +45,23 @@ export function PublicDealList({ game, act, disabled }: { const seats = participants.map(id => game.players.find(player => player.id === id)); const eligible = seats.every((player, index) => player && seats.slice(index + 1).every(other => other && player.ally !== other.id && other.ally !== player.id)); + const payment = (deal as Deal & { payment?: ImmediateBribe }).payment; + const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1; + const paymentBlocked = payment && finalAcceptance ? game.bribeOptions?.blocked : null; return
{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')} · Turn {deal.turn} · {deal.status}
{deal.terms}
+ {payment &&Immediate payment term: {name(payment.payer)} pays {payment.amount} spice to {name(payment.recipient)} on final acceptance. The payer loses the gross amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before that share is allocated. Releasing the deal later does not refund the payment.
} {deal.status === 'offered' &&Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
} {deal.status === 'offered' && !eligible &&Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.
} + {deal.status === 'offered' && paymentBlocked &&Final acceptance must wait: {paymentBlocked} The offer may still be declined or withdrawn.
} {deal.status === 'active' && deal.releaseVotes.length > 0 &&Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
} {canAct && deal.status === 'offered' && recipient &&