From 7d71647a90c3050d7d86b6484f21bb6ddf79bcf1 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:21:32 +1100 Subject: [PATCH] Support future-turn public deal payments --- components/public-deals.tsx | 34 +++-- docs/CURRENT_STATUS.md | 12 ++ docs/PUBLIC_DEALS.md | 16 ++ docs/RULE_DECISIONS.md | 8 + game/bots.ts | 5 +- game/engine.ts | 36 +++-- game/public-deal-obligations.ts | 17 ++- game/public-deals.ts | 7 +- game/reference.ts | 4 +- tests/public-deal-deferred.test.ts | 4 +- tests/public-deals-controls.test.tsx | 2 +- tests/public-deals-cross-turn-bot.test.ts | 92 ++++++++++++ .../public-deals-cross-turn-controls.test.tsx | 140 ++++++++++++++++++ tests/public-deals-cross-turn-http.test.ts | 104 +++++++++++++ tests/public-deals-cross-turn-model.test.ts | 68 +++++++++ tests/public-deals-cross-turn-runtime.test.ts | 127 ++++++++++++++++ 16 files changed, 641 insertions(+), 35 deletions(-) create mode 100644 tests/public-deals-cross-turn-bot.test.ts create mode 100644 tests/public-deals-cross-turn-controls.test.tsx create mode 100644 tests/public-deals-cross-turn-http.test.ts create mode 100644 tests/public-deals-cross-turn-model.test.ts create mode 100644 tests/public-deals-cross-turn-runtime.test.ts diff --git a/components/public-deals.tsx b/components/public-deals.tsx index bef192af..e8ce7fd4 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -28,20 +28,21 @@ export function publicDealRequests(game: GameView): string[][] { requests.push(['release', deal.id, ...deal.releaseVotes]); const pending = deal as unknown as { payment?: DealPayment; paymentStatus?: DeferredPaymentStatus }; if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' && - pending.payment.payer === game.me && pending.paymentStatus === 'pending') + pending.payment.payer === game.me && pending.paymentStatus === 'pending' && pending.payment.dueTurn <= game.turn) requests.push(['payment', deal.id]); return requests; }); } /** Keep the legacy single-recipient wire packet when only one seat is chosen. */ -export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string): Action | null { +export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string, currentTurn?: number): 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.kind !== 'deferredBribe') || payment.payer === payment.recipient || ![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) || !Number.isSafeInteger(payment.amount) || payment.amount < 1 || - (payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1)))) return null; + (payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 || + (currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null; const offeredPayment = payment ? { payment } : {}; return targets.length === 1 ? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment } @@ -75,7 +76,7 @@ export function PublicDealList({ game, act, disabled }: { const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus; const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1; const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null; - const paymentExpired = payment?.kind === 'deferredBribe' && game.turn !== payment.dueTurn; + const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn; const deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id]; const mayPay = payment?.kind === 'deferredBribe' && deal.status === 'active' && paymentStatus === 'pending' && payment.payer === game.me && canAct; @@ -83,7 +84,7 @@ export function PublicDealList({ game, act, disabled }: {

{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')} · Turn {deal.turn} · {deal.status}

{deal.terms}

{payment?.kind === 'immediateBribe' &&

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.

} - {payment?.kind === 'deferredBribe' &&

Deferred payment term: {name(payment.payer)} pays exactly {payment.amount} spice to {name(payment.recipient)} by the end of turn {payment.dueTurn}. The recipient collects their share at the next Mentat opening; a Bureaucrat may divert two spice to the bank. {paymentStatus ? `Payment status: ${paymentStatus}.` : 'Payment becomes binding on final acceptance.'}

} + {payment?.kind === 'deferredBribe' &&

Deferred payment term: {name(payment.payer)} pays exactly {payment.amount} spice to {name(payment.recipient)} on or before the end of turn {payment.dueTurn}. The recipient collects their share at the next Mentat opening; a Bureaucrat may divert two spice to the bank. {paymentStatus ? `Payment status: ${paymentStatus}.` : 'Payment becomes binding on final acceptance.'}

} {deal.status === 'offered' &&

Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.

} @@ -125,6 +126,7 @@ export function PublicDeals({ game, act, busy }: { const [payer, setPayer] = useState(''); const [paymentRecipient, setPaymentRecipient] = useState(''); const [paymentAmount, setPaymentAmount] = useState('1'); + const [paymentDueTurn, setPaymentDueTurn] = useState(() => String(game.turn)); const requests = publicDealRequests(game); const needsResponse = requests.length > 0; const paymentDue = requests.some(request => request[0] === 'payment'); @@ -142,15 +144,18 @@ export function PublicDeals({ game, act, busy }: { selectedPlayers.slice(index + 1).every(other => !allied(player, other))); const parties = me ? [me, ...selectedPlayers] : []; const amount = Number(paymentAmount); + const dueTurn = Number(paymentDueTurn); + const dueValid = Number.isSafeInteger(dueTurn) && dueTurn >= game.turn; const payment: DealPayment | undefined = includePayment ? paymentKind === 'immediateBribe' ? { kind: paymentKind, payer, recipient: paymentRecipient, amount } - : { kind: paymentKind, payer, recipient: paymentRecipient, amount, dueTurn: game.turn } + : { kind: paymentKind, payer, recipient: paymentRecipient, amount, dueTurn } : undefined; - const paymentValid = !includePayment || !!me && payer !== paymentRecipient && + const paymentPeopleAndAmountValid = !!me && payer !== paymentRecipient && parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) && Number.isSafeInteger(amount) && amount > 0; - const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id) : null; + const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid); + const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn) : null; const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot; const mayOffer = game.status === 'playing' && me && !me.bot; return
@@ -180,7 +185,7 @@ export function PublicDeals({ game, act, busy }: { {includePayment &&
Payment term @@ -188,7 +193,7 @@ export function PublicDeals({ game, act, busy }: { + {paymentKind === 'deferredBribe' && } {paymentKind === 'immediateBribe' ?

No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.

- :

The agreed amount becomes due when all participants accept. The payer must pay it by the end of turn {game.turn} while payment remains possible. A separate pay control appears on the active deal; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank.

} - {legalSelection && !paymentValid &&

Choose two different participants and a positive whole spice amount.

} + :

After all participants accept, the payer can pay on or before the end of the selected turn while payment remains possible. A separate pay control appears on the active deal; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank.

} + {legalSelection && !paymentPeopleAndAmountValid &&

Choose two different participants and a positive whole spice amount.

} + {paymentKind === 'deferredBribe' && !dueValid &&

Choose a whole due turn no earlier than turn {game.turn}.

}
}