From a1b2aacbac733fb07a88b6272610098a768cd893 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:49:56 +1100 Subject: [PATCH] Support paid current-turn travel deals --- components/public-deals.tsx | 16 ++- docs/CURRENT_STATUS.md | 21 ++- docs/PUBLIC_DEALS.md | 8 ++ game/engine.ts | 4 +- game/public-deals.ts | 16 ++- tests/public-deals-paid-travel-pledge.test.ts | 50 +++++++ .../public-deals-paid-travel-runtime.test.ts | 127 ++++++++++++++++++ tests/public-deals-travel-controls.test.tsx | 102 +++++++++++++- tests/public-deals-travel-schema.test.ts | 50 ++++++- 9 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 tests/public-deals-paid-travel-pledge.test.ts create mode 100644 tests/public-deals-paid-travel-runtime.test.ts diff --git a/components/public-deals.tsx b/components/public-deals.tsx index dc27ff24..b1e3a8a4 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -60,7 +60,8 @@ export function publicDealOfferAction(targets: readonly string[], terms: string, !Number.isSafeInteger(payment.amount) || payment.amount < 1 || (payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 || (currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null; - if (travel && (targets.length !== 1 || !proposer || targets[0] === proposer || !!payment || + if (travel && (targets.length !== 1 || !proposer || targets[0] === proposer || + !!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0]) || Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' || !['ship', 'move'].includes(travel.kind) || !TERRITORIES.some(territory => territory.id === travel.territory) || !Number.isSafeInteger(travel.minimum) || travel.minimum < 1 || travel.minimum > 20 || @@ -111,6 +112,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 === 'immediateBribe' && travel &&

The recipient cannot use this payment to fund the current travel: their share is collected at the next Mentat opening.

} {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.'}

} {travel &&

Current-turn travel term: {name(deal.to)} {deal.status === 'offered' || deal.status === 'declined' || deal.status === 'withdrawn' ? 'would' : 'agrees to'} {travel.answer ? 'make' : 'avoid'} {travel.kind === 'ship' ? 'a reserve shipment' : 'a ground movement'} of at least {travel.minimum} physical forces in one action to {travelDestination ?? travel.territory} during turn {deal.turn}. {travelStatus ? `Travel status: ${travelStatus}.` : deal.status === 'offered' ? 'The term becomes binding on final acceptance.' : deal.status === 'declined' || deal.status === 'withdrawn' ? 'The term did not become binding.' : 'Travel status is unavailable.'}

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

@@ -194,6 +196,8 @@ export function PublicDeals({ game, act, busy }: { const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid); const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory, minimum:Number(travelMinimum), answer:travelAnswer} : undefined; + const combinedPaymentValid = !includeTravel || !payment || payment.kind === 'immediateBribe' && + payment.payer === me?.id && selected.length === 1 && payment.recipient === selected[0]; const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn, travel) : null; const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot; const mayOffer = game.status === 'playing' && me && !me.bot; @@ -222,7 +226,7 @@ export function PublicDeals({ game, act, busy }: { })} @@ -264,14 +268,16 @@ export function PublicDeals({ game, act, busy }: { {paymentKind === 'deferredBribe' && !dueValid &&

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

} } - {!includeTravel && !includePayment && selected.length !== 1 &&

Select exactly one human recipient to add a travel term.

} + {!includeTravel && selected.length !== 1 &&

Select exactly one human recipient to add a travel term.

} {includeTravel &&
Current-turn travel term -

The selected recipient is the committed player. This term concerns one physical action during turn {game.turn}; it cannot be combined with a payment or additional recipients. Written terms remain player-managed.

+

The selected recipient is the committed player. This term concerns one physical action during turn {game.turn} and cannot include additional recipients. Written terms remain player-managed.

+

A travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening, so it cannot fund this turn’s shipment or movement.

+ {!combinedPaymentValid &&

For a travel term, choose an immediate payment from the proposer to the selected recipient, or remove the payment.

}

This initial preview supports Basic games with classic factions and no expansions or optional modules. Final acceptance needs a quiet native window before or during an eligible Shipment and Movement opportunity. The server checks whether the term can still be honored; an unavailable term cannot be accepted.