Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 07:45:17 +11:00
1 parent ac1f32c859
commit 82c639c977
13 files changed
+777 -28

No files matched your search

+69 -7
View File
@@ -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<string, DealTravelStatus> };
type DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
type DeferredPaymentProjection = GameView & {
deferredDealPaymentOptions?: Record<string, { blocked: string | null }>;
@@ -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 }: {
<p>{deal.terms}</p>
{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 === '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>}
{deal.status === 'offered' && <p>
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
</p>}
{deal.status === 'offered' && !eligible && <p>Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && paymentBlocked && <p>Final acceptance must wait: {paymentBlocked} The offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && paymentExpired && <p>The payment’s due turn has passed. The offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && travelExpired && <p>The travel term’s turn has passed. The offer may still be declined or withdrawn.</p>}
{deal.status === 'active' && deal.releaseVotes.length > 0 && <p>
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} 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} 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}
@@ -130,6 +151,11 @@ export function PublicDeals({ game, act, busy }: {
const [selected, setSelected] = useState<string[]>([]);
const [terms, setTerms] = useState('');
const [includePayment, setIncludePayment] = useState(false);
const [includeTravel, setIncludeTravel] = useState(false);
const [travelKind, setTravelKind] = useState<'ship' | 'move'>('ship');
const [travelTerritory, setTravelTerritory] = useState('');
const [travelMinimum, setTravelMinimum] = useState('1');
const [travelAnswer, setTravelAnswer] = useState(true);
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
const [payer, setPayer] = useState('');
const [paymentRecipient, setPaymentRecipient] = useState('');
@@ -142,6 +168,7 @@ export function PublicDeals({ game, act, busy }: {
const requests = publicDealRequests(game);
const needsResponse = requests.length > 0;
const paymentDue = requests.some(request => request[0] === 'payment');
const travelDue = requests.some(request => request[0] === 'travel');
const requestKey = JSON.stringify(requests);
const panel = useRef<HTMLDetailsElement>(null);
useEffect(() => { if (requestKey !== '[]' && panel.current) panel.current.open = true; }, [requestKey]);
@@ -165,11 +192,13 @@ 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 offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn) : null;
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
minimum:Number(travelMinimum), answer:travelAnswer} : undefined;
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;
return <details ref={panel} className="notice public-deals-panel">
<summary>Public deals{paymentDue ? ' · payment due' : needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
<summary>Public deals{paymentDue ? ' · payment due' : travelDue ? ' · travel term pending' : needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
<p>Deals are public. Every recipient must accept an offer. Players must honor valid terms; an impossible part is void, and all parties can agree to release a deal. The game records the terms but cannot automatically enforce free-text promises or determine when they become impossible.</p>
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. An optional payment uses the ordinary bribe rules; free-text terms do not automatically transfer spice. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
{mayOffer && <form onSubmit={event => {
@@ -182,7 +211,7 @@ 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)}
<input type="checkbox" checked={checked} disabled={disabled || (!checked && (conflicts || includeTravel && selected.length >= 1))}
onChange={event => {
const nextChecked = event.target.checked;
setSelected(current => nextChecked
@@ -193,7 +222,7 @@ export function PublicDeals({ game, act, busy }: {
})}
</fieldset>
<label className="checkbox-row">
<input type="checkbox" checked={includePayment} disabled={disabled}
<input type="checkbox" checked={includePayment} disabled={disabled || includeTravel}
onChange={event => setIncludePayment(event.target.checked)} />
Include a spice payment
</label>
@@ -234,6 +263,39 @@ export function PublicDeals({ game, act, busy }: {
{legalSelection && !paymentPeopleAndAmountValid && <p>Choose two different participants and a positive whole spice amount.</p>}
{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 || includePayment || !includeTravel && selected.length !== 1}
onChange={event => setIncludeTravel(event.target.checked)} />
Include a current-turn travel term
</label>
{!includeTravel && !includePayment && selected.length !== 1 && <p className="fine">Select exactly 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}; it cannot be combined with a payment or additional recipients. Written terms remain player-managed.</p>
<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')}>
<option value="ship">Reserve shipment</option>
<option value="move">Ground movement</option>
</select>
</label>
<label>Commitment
<select value={travelAnswer ? 'require' : 'avoid'} disabled={disabled} onChange={event => setTravelAnswer(event.target.value === 'require')}>
<option value="require">Make this action</option>
<option value="avoid">Avoid this action</option>
</select>
</label>
<label>Destination territory
<select value={travelTerritory} disabled={disabled} onChange={event => setTravelTerritory(event.target.value)}>
<option value="">Choose a territory</option>
{TERRITORIES.map(territory => <option key={territory.id} value={territory.id}>{territory.name}</option>)}
</select>
</label>
<label>Minimum physical forces in one action
<input type="number" inputMode="numeric" min={1} max={20} step={1} value={travelMinimum} disabled={disabled}
onChange={event => setTravelMinimum(event.target.value)} />
</label>
</fieldset>}
<label>Public deal terms
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
placeholder="Describe the agreement for the table" />
+13 -7
View File
@@ -105,22 +105,28 @@ export function TravelPromises(props: TravelProps) {
const game = props.game, own = game.travelPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance shipment and movement promises" id="travel-promise-guidance">
return <section className="notice space-y-3" aria-label={own.some(promise => promise.sourceDeal)
? 'Your shipment and movement commitments' : 'Your Truthtrance shipment and movement promises'} id="travel-promise-guidance">
<h3>Your shipment and movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'travel', target: promise.player,
Turn {promise.turn}: {promise.sourceDeal ? `Public deal: agreed ${promise.answer ? 'Yes' : 'No'} to` : `You answered ${promise.answer ? 'Yes' : 'No'} to`} {truthQuestionText({ kind: 'travel', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}.
{promise.released ? ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding until your Movement turn ends this round.'}
{promise.released ? promise.sourceDeal
? game.dealTravelStatus[promise.sourceDeal] === 'void'
? ' Void: this agreement is no longer possible.' : ' Released: this whole commitment is no longer binding.'
: ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.'
: promise.sourceDeal ? ' This commitment is binding until your Movement turn ends this round.'
: ' This whole answer is binding until your Movement turn ends this round.'}
</li>)}</ul>
<p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
{own.some(promise => !promise.sourceDeal) && <p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Legal alternatives remain available; no fixed branch is selected for you.
Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>
<p className="fine">Shipment conditions concern one future original reserve shipment after the answer.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>}
<p className="fine">Shipment conditions concern one future original reserve shipment after the answer or deal acceptance.
Ground-movement conditions count the maximum physical forces in one actual future move, never the sum of smaller moves.
Earlier actions do not count. A shipment can prepare a move; each action counts only for its own conditions.
Original shipment rights, movement opportunities, cards, preparation and costs still apply.