Support group travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m13s
Verify and publish checkpoint container / container (push) Successful in 11m13s
This commit is contained in:
1 parent
82d857b667
commit
e1e02fc1a1
10 files changed
+432
-56
No files matched your search
+43
-21
@@ -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<string, DealTravelStatus> };
|
||||
@@ -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' && <p><strong>Immediate payment term:</strong> {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.</p>}
|
||||
{payment?.kind === 'immediateBribe' && travel && <p>The recipient cannot use this payment to fund the current travel: their share is collected at the next Mentat opening.</p>}
|
||||
{payment?.kind === 'deferredBribe' && <p><strong>Deferred payment term:</strong> {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.'}</p>}
|
||||
{travel && <p><strong>Current-turn travel term:</strong> {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.'}</p>}
|
||||
{travel && <p><strong>Current-turn travel term:</strong> {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.'}</p>}
|
||||
{travel && participants.length > 2 && deal.status === 'offered' && <p>{name(travelActor!)} must accept last, after the other recipients. Anyone invited may decline at any time.</p>}
|
||||
{deal.status === 'offered' && <p>
|
||||
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
||||
</p>}
|
||||
@@ -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.
|
||||
</p>}
|
||||
{canAct && deal.status === 'offered' && recipient && <div className="flex flex-wrap gap-2">
|
||||
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked || !!paymentExpired || travelExpired} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
|
||||
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked || !!paymentExpired || travelExpired || travelActorWaiting} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
|
||||
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
|
||||
</div>}
|
||||
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
|
||||
@@ -158,6 +170,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const [travelTerritory, setTravelTerritory] = useState('');
|
||||
const [travelMinimum, setTravelMinimum] = useState('1');
|
||||
const [travelAnswer, setTravelAnswer] = useState(true);
|
||||
const [travelActor, setTravelActor] = useState('');
|
||||
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
|
||||
const [payer, setPayer] = useState('');
|
||||
const [paymentRecipient, setPaymentRecipient] = useState('');
|
||||
@@ -195,7 +208,8 @@ export function PublicDeals({ game, act, busy }: {
|
||||
Number.isSafeInteger(amount) && amount > 0;
|
||||
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
|
||||
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
|
||||
minimum:Number(travelMinimum), answer:travelAnswer} : undefined;
|
||||
minimum:Number(travelMinimum), answer:travelAnswer,
|
||||
...(selected.length > 1 ? {actor:travelActor} : {})} : 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;
|
||||
@@ -215,9 +229,10 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const checked = selected.includes(player.id);
|
||||
const conflicts = !!me && (allied(game, me, player) || selectedPlayers.some(other => other.id !== player.id && allied(game, other, player)));
|
||||
return <label key={player.id} className="checkbox-row">
|
||||
<input type="checkbox" checked={checked} disabled={disabled || (!checked && (conflicts || includeTravel && selected.length >= 1))}
|
||||
<input type="checkbox" checked={checked} disabled={disabled || (!checked && conflicts)}
|
||||
onChange={event => {
|
||||
const nextChecked = event.target.checked;
|
||||
if (!nextChecked && travelActor === player.id) setTravelActor('');
|
||||
setSelected(current => nextChecked
|
||||
? current.includes(player.id) ? current : [...current, player.id] : current.filter(id => id !== player.id));
|
||||
}} />
|
||||
@@ -268,16 +283,23 @@ export function PublicDeals({ game, act, busy }: {
|
||||
{paymentKind === 'deferredBribe' && !dueValid && <p role="alert">Choose a whole due turn no earlier than turn {game.turn}.</p>}
|
||||
</fieldset>}
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={includeTravel} disabled={disabled || !includeTravel && selected.length !== 1}
|
||||
<input type="checkbox" checked={includeTravel} disabled={disabled || !includeTravel && selected.length === 0}
|
||||
onChange={event => setIncludeTravel(event.target.checked)} />
|
||||
Include a current-turn travel term
|
||||
</label>
|
||||
{!includeTravel && selected.length !== 1 && <p className="fine">Select exactly one human recipient to add a travel term.</p>}
|
||||
{!includeTravel && selected.length === 0 && <p className="fine">Select at least one human recipient to add a travel term.</p>}
|
||||
{includeTravel && <fieldset>
|
||||
<legend>Current-turn travel term</legend>
|
||||
<p className="fine">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.</p>
|
||||
<p className="fine">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.</p>
|
||||
{!combinedPaymentValid && <p role="alert">For a travel term, choose an immediate payment from the proposer to the selected recipient, or remove the payment.</p>}
|
||||
<p className="fine">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.</p>
|
||||
<p className="fine">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.</p>
|
||||
{!combinedPaymentValid && <p role="alert">For two-party travel, choose an immediate payment from the proposer to the selected recipient. Group travel cannot include payment.</p>}
|
||||
{selected.length > 1 && <label>Committed traveler
|
||||
<select value={selected.includes(travelActor) ? travelActor : ''} disabled={disabled}
|
||||
onChange={event => setTravelActor(event.target.value)}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{selectedPlayers.map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<p className="fine">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.</p>
|
||||
<label>Travel action
|
||||
<select value={travelKind} disabled={disabled} onChange={event => setTravelKind(event.target.value === 'move' ? 'move' : 'ship')}>
|
||||
@@ -310,7 +332,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<Button type="submit" disabled={disabled || !offer}>Offer public deal</Button>
|
||||
{targets.length === 0 && <p>No human recipient is available.</p>}
|
||||
{selected.length > 0 && !legalSelection && <p>Clear or change any recipient whose seat or alliance is no longer eligible.</p>}
|
||||
{selected.length > 0 && <Button type="button" variant="outline" disabled={disabled} onClick={() => setSelected([])}>Clear recipients</Button>}
|
||||
{selected.length > 0 && <Button type="button" variant="outline" disabled={disabled} onClick={() => { setSelected([]); setTravelActor(''); }}>Clear recipients</Button>}
|
||||
</form>}
|
||||
<h3>Deal history</h3>
|
||||
<PublicDealList game={game} act={act} disabled={disabled} />
|
||||
|
||||
Reference in new issue
Block a user