diff --git a/components/public-deals.tsx b/components/public-deals.tsx index 6e7d276c..dc27ff24 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { faction } from '@/game/catalog'; +import { TERRITORIES } from '@/game/board'; import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals'; import { areCoalitionAllies } from '@/game/coalition-alliance'; import type { Action, GameView } from '@/game/engine'; @@ -11,6 +12,10 @@ 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 }; +type DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released'; +type TravelDeal = Deal & { travel?: DealTravel }; +type TravelProjection = GameView & { dealTravelStatus?: Record }; type DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released'; type DeferredPaymentProjection = GameView & { deferredDealPaymentOptions?: Record; @@ -38,12 +43,16 @@ export function publicDealRequests(game: GameView): string[][] { if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' && pending.payment.payer === game.me && pending.paymentStatus === 'pending' && pending.payment.dueTurn <= game.turn) requests.push(['payment', deal.id]); + const travel = (deal as TravelDeal).travel; + if (deal.status === 'active' && travel && deal.to === game.me && + (game as TravelProjection).dealTravelStatus?.[deal.id] === 'pending') + requests.push(['travel', 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, currentTurn?: number): Action | null { +export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string, currentTurn?: number, travel?: DealTravel): 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 || @@ -51,9 +60,15 @@ 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 || + 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 || + typeof travel.answer !== 'boolean')) return null; const offeredPayment = payment ? { payment } : {}; + const offeredTravel = travel ? { travel } : {}; return targets.length === 1 - ? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment } + ? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel } : { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment }; } @@ -82,6 +97,10 @@ export function PublicDealList({ game, act, disabled }: { other && !allied(game, player, other))); const payment = (deal as unknown as { payment?: DealPayment }).payment; const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus; + const travel = (deal as TravelDeal).travel; + const travelStatus = (game as TravelProjection).dealTravelStatus?.[deal.id]; + const travelExpired = !!travel && deal.turn !== game.turn; + const travelDestination = travel && TERRITORIES.find(territory => territory.id === travel.territory)?.name; 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; @@ -93,17 +112,19 @@ export function PublicDealList({ game, act, disabled }: {

{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)} 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' &&

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 === 'offered' && paymentExpired &&

The payment’s due turn has passed. The offer may still be declined or withdrawn.

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

The travel term’s turn has passed. 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 &&
- {!accepted.includes(game.me) && } + {!accepted.includes(game.me) && }
} {canAct && deal.status === 'offered' && deal.from === game.me &&