Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s
Verify and publish checkpoint container / container (push) Successful in 10m49s
This commit is contained in:
1 parent
ac1f32c859
commit
82c639c977
13 files changed
+777
-28
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user