From 1e81ce8ad8410e106b3deb33afd2a28e7da0a7a1 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:19:52 +1100 Subject: [PATCH] Support scheduled travel agreements --- components/public-deals.tsx | 54 +++-- docs/CURRENT_STATUS.md | 15 ++ docs/PUBLIC_DEALS.md | 26 ++- docs/RULE_DECISIONS.md | 10 + game/engine.ts | 189 +++++++++++++--- game/public-deals.ts | 18 +- game/reference.ts | 2 +- ...blic-deals-future-deferred-runtime.test.ts | 113 ++++++++++ ...public-deals-future-travel-runtime.test.ts | 204 ++++++++++++++++++ ...c-deals-future-turn-action-runtime.test.ts | 56 +++++ .../public-deals-group-travel-runtime.test.ts | 30 ++- tests/public-deals-travel-controls.test.tsx | 57 ++++- tests/public-deals-travel-schema.test.ts | 49 ++++- 13 files changed, 767 insertions(+), 56 deletions(-) create mode 100644 tests/public-deals-future-deferred-runtime.test.ts create mode 100644 tests/public-deals-future-travel-runtime.test.ts create mode 100644 tests/public-deals-future-turn-action-runtime.test.ts diff --git a/components/public-deals.tsx b/components/public-deals.tsx index 5b4baf5d..b3d81b6c 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { faction } from '@/game/catalog'; import { TERRITORIES } from '@/game/board'; -import { publicDealAcceptVotes, publicDealParticipants, publicDealTravelActor } from '@/game/public-deals'; +import { publicDealAcceptVotes, publicDealParticipants, publicDealTravelActor, publicDealTravelDueTurn } from '@/game/public-deals'; import { areCoalitionAllies } from '@/game/coalition-alliance'; import type { Action, GameView } from '@/game/engine'; import { Button } from '@/components/ui/button'; @@ -12,8 +12,8 @@ type Deal = GameView['deals'][number]; type ImmediateBribe = { kind: 'immediateBribe'; payer: string; recipient: string; amount: number }; type DeferredBribe = { kind: 'deferredBribe'; payer: string; recipient: string; amount: number; dueTurn: number }; type DealPayment = ImmediateBribe | DeferredBribe; -type DealTravel = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean; actor?: string }; -type DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released'; +type DealTravel = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean; actor?: string; dueTurn?: number }; +type DealTravelStatus = 'scheduled' | 'pending' | 'fulfilled' | 'void' | 'released'; type TravelDeal = Deal & { travel?: DealTravel }; type TravelProjection = GameView & { dealTravelStatus?: Record }; type DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released'; @@ -66,16 +66,22 @@ export function publicDealOfferAction(targets: readonly string[], terms: string, (payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 || (currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null; const groupTravel = !!travel && targets.length > 1; + const travelKeys = travel ? Object.keys(travel).filter(key => key !== 'dueTurn').sort().join(',') : ''; + const travelDeadline = travel && Object.hasOwn(travel, 'dueTurn') ? travel.dueTurn : undefined; if (travel && (!proposer || targets.includes(proposer) || - (groupTravel ? !!payment || Object.keys(travel).sort().join(',') !== 'actor,answer,kind,minimum,territory' || + (groupTravel ? !!payment || travelKeys !== 'actor,answer,kind,minimum,territory' || typeof travel.actor !== 'string' || !targets.includes(travel.actor) : - Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' || + travelKeys !== 'answer,kind,minimum,territory' || !!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0])) || + (Object.hasOwn(travel, 'dueTurn') && (!Number.isSafeInteger(travelDeadline) || (travelDeadline as number) < 1 || + (currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || (travelDeadline as number) < currentTurn)))) || !['ship', 'move'].includes(travel.kind) || !TERRITORIES.some(territory => territory.id === travel.territory) || !Number.isSafeInteger(travel.minimum) || travel.minimum < 1 || travel.minimum > 20 || typeof travel.answer !== 'boolean')) return null; const offeredPayment = payment ? { payment } : {}; - const offeredTravel = travel ? { travel } : {}; + const normalizedTravel = travel ? {...travel} : undefined; + if (normalizedTravel && travelDeadline === currentTurn) delete normalizedTravel.dueTurn; + const offeredTravel = normalizedTravel ? { travel: normalizedTravel } : {}; return targets.length === 1 ? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel } : { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment, ...offeredTravel }; @@ -109,7 +115,8 @@ export function PublicDealList({ game, act, disabled }: { const travel = (deal as TravelDeal).travel; const travelActor = travel ? publicDealTravelActor(deal) : undefined; const travelStatus = (game as TravelProjection).dealTravelStatus?.[deal.id]; - const travelExpired = !!travel && deal.turn !== game.turn; + const travelTurn = travel ? publicDealTravelDueTurn(deal) : undefined; + const travelExpired = travelTurn !== undefined && game.turn > travelTurn; const travelDestination = travel && TERRITORIES.find(territory => territory.id === travel.territory)?.name; const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1; const travelActorWaiting = !!travel && participants.length > 2 && game.me === travelActor && @@ -123,9 +130,10 @@ 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 === 'immediateBribe' && travel &&

The recipient cannot spend this payment before its next Mentat collection; after collection it can fund travel in a later turn.

} {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(travelActor!)} {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.'}

} + {travel &&

{travelTurn === deal.turn ? 'Current-turn travel term:' : 'Travel term:'} {name(travelActor!)} {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 {travelTurn}. {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.'}

} + {travel && travelTurn !== undefined && travelTurn > deal.turn &&

Before turn {travelTurn}, this term reserves no forces or spice. Its status and legal options update during that turn.

} {travel && participants.length > 2 && deal.status === 'offered' &&

{name(travelActor!)} must accept last, after the other recipients. Anyone invited may decline at any time.

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

Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}. @@ -176,10 +184,15 @@ export function PublicDeals({ game, act, busy }: { const [paymentRecipient, setPaymentRecipient] = useState(''); const [paymentAmount, setPaymentAmount] = useState('1'); const [deadlineDraft, setDeadlineDraft] = useState(() => ({ value: String(game.turn), turn: game.turn })); + const [travelDeadlineDraft, setTravelDeadlineDraft] = useState(() => ({ value: String(game.turn), turn: game.turn })); const previousDeadline = Number(deadlineDraft.value); const paymentDueTurn = deadlineDraft.turn !== game.turn && deadlineDraft.value.trim() !== '' && Number.isSafeInteger(previousDeadline) && previousDeadline < game.turn ? String(game.turn) : deadlineDraft.value; + const previousTravelDeadline = Number(travelDeadlineDraft.value); + const travelDueTurn = travelDeadlineDraft.turn !== game.turn && travelDeadlineDraft.value.trim() !== '' && + Number.isSafeInteger(previousTravelDeadline) && previousTravelDeadline < game.turn + ? String(game.turn) : travelDeadlineDraft.value; const requests = publicDealRequests(game); const needsResponse = requests.length > 0; const paymentDue = requests.some(request => request[0] === 'payment'); @@ -207,12 +220,16 @@ export function PublicDeals({ game, act, busy }: { parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) && Number.isSafeInteger(amount) && amount > 0; const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid); + const travelTurn = Number(travelDueTurn); + const travelDueValid = travelDueTurn.trim() !== '' && Number.isSafeInteger(travelTurn) && travelTurn >= game.turn; const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory, minimum:Number(travelMinimum), answer:travelAnswer, - ...(selected.length > 1 ? {actor:travelActor} : {})} : undefined; + ...(selected.length > 1 ? {actor:travelActor} : {}), + ...(travelTurn > game.turn ? {dueTurn:travelTurn} : {})} : 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 offer = legalSelection && paymentValid && (!includeTravel || travelDueValid) + ? 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; return

@@ -285,13 +302,13 @@ export function PublicDeals({ game, act, busy }: { {!includeTravel && selected.length === 0 &&

Select at least one human recipient to add a travel term.

} {includeTravel &&
- Current-turn travel term -

This term concerns one physical action during turn {game.turn}. The sole recipient commits to travel in a two-party deal. In a group deal, choose one recipient to commit; they accept last after all other recipients. Written terms remain player-managed.

-

A two-party 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. Group travel terms cannot include a payment.

+ Travel term +

This term concerns one physical action during the selected turn. The sole recipient commits to travel in a two-party deal. In a group deal, choose one recipient to commit; they accept last after all other recipients. Written terms remain player-managed.

+

A two-party travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening and can use it after collection. Group travel terms cannot include a payment.

{!combinedPaymentValid &&

For two-party travel, choose an immediate payment from the proposer to the selected recipient. Group travel cannot include payment.

} {selected.length > 1 && } -

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.

+

This initial preview supports Basic games with classic factions and no expansions or optional modules. A current-turn term needs a quiet native window and a legal completion on final acceptance. A later-turn term reserves no forces or spice now. If the game finds no legal completion when the travel turn closes, it provisionally marks that term void.

+ + {!travelDueValid &&

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

}
}