diff --git a/components/public-deals.tsx b/components/public-deals.tsx index b1e3a8a4..5b4baf5d 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 } from '@/game/public-deals'; +import { publicDealAcceptVotes, publicDealParticipants, publicDealTravelActor } from '@/game/public-deals'; import { areCoalitionAllies } from '@/game/coalition-alliance'; import type { Action, GameView } from '@/game/engine'; import { Button } from '@/components/ui/button'; @@ -12,7 +12,7 @@ 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 DealTravel = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean; actor?: string }; type DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released'; type TravelDeal = Deal & { travel?: DealTravel }; type TravelProjection = GameView & { dealTravelStatus?: Record }; @@ -34,17 +34,22 @@ export function publicDealRequests(game: GameView): string[][] { if (game.status !== 'playing') return []; return game.deals.flatMap(deal => { const requests: string[][] = []; - const participant = publicDealParticipants(deal).includes(game.me); - if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me)) - requests.push(['offer', deal.id]); + const parties = publicDealParticipants(deal), accepted = publicDealAcceptVotes(deal); + const participant = parties.includes(game.me); + const travel = (deal as TravelDeal).travel; + const waitingForOtherVotes = !!travel && parties.length > 2 && publicDealTravelActor(deal) === game.me && + parties.some(id => id !== game.me && !accepted.includes(id)); + if (deal.status === 'offered' && participant && deal.from !== game.me && !accepted.includes(game.me)) { + const groupTraveler = !!travel && parties.length > 2 && publicDealTravelActor(deal) === game.me; + requests.push(groupTraveler ? ['offer', deal.id, waitingForOtherVotes ? 'waiting' : 'ready'] : ['offer', deal.id]); + } if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0) 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.dueTurn <= game.turn) requests.push(['payment', deal.id]); - const travel = (deal as TravelDeal).travel; - if (deal.status === 'active' && travel && deal.to === game.me && + if (deal.status === 'active' && travel && publicDealTravelActor(deal) === game.me && (game as TravelProjection).dealTravelStatus?.[deal.id] === 'pending') requests.push(['travel', deal.id]); return requests; @@ -60,9 +65,12 @@ 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 && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0]) || - Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' || + const groupTravel = !!travel && targets.length > 1; + if (travel && (!proposer || targets.includes(proposer) || + (groupTravel ? !!payment || Object.keys(travel).sort().join(',') !== 'actor,answer,kind,minimum,territory' || + typeof travel.actor !== 'string' || !targets.includes(travel.actor) : + Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' || + !!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0])) || !['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; @@ -70,7 +78,7 @@ export function publicDealOfferAction(targets: readonly string[], terms: string, const offeredTravel = travel ? { travel } : {}; return targets.length === 1 ? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel } - : { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment }; + : { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment, ...offeredTravel }; } /** The whole table can read an offer and its history; only its parties act. */ @@ -99,10 +107,13 @@ export function PublicDealList({ game, act, disabled }: { 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 travelActor = travel ? publicDealTravelActor(deal) : undefined; 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 travelActorWaiting = !!travel && participants.length > 2 && game.me === travelActor && + participants.some(participant => participant !== travelActor && !accepted.includes(participant)); const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null; const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn; const deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id]; @@ -114,7 +125,8 @@ export function PublicDealList({ game, act, disabled }: { {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.'}

} + {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 && 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(', ')}.

} @@ -126,7 +138,7 @@ export function PublicDealList({ game, act, disabled }: { 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 &&