Files
Quintine 6e26ceb216
Verify and publish checkpoint container / container (push) Canceled after 0s
Recognize group allies in bribes and deals
2026-10-28 23:57:55 +11:00

251 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
import { faction } from '@/game/catalog';
import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals';
import { areCoalitionAllies } from '@/game/coalition-alliance';
import type { Action, GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
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 DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
type DeferredPaymentProjection = GameView & {
deferredDealPaymentOptions?: Record<string, { blocked: string | null }>;
};
/** Alliance membership is public. Scalar links retain legacy pair behavior;
* a staged group uses the same coalition quote as authoritative rules. */
function allied(game: GameView, a: GameView['players'][number], b: GameView['players'][number]): boolean {
return a.ally === b.id || b.ally === a.id ||
!!game.coalitionState && areCoalitionAllies(game.players, game.coalitionState, a.id, b.id);
}
/** Requests are local to the active seat; an owed payment is as important to
* surface as a pending invitation or a request to release an agreement. */
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]);
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]);
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 {
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 ||
![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) ||
!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;
const offeredPayment = payment ? { payment } : {};
return targets.length === 1
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment }
: { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment };
}
/** The whole table can read an offer and its history; only its parties act. */
export function PublicDealList({ game, act, disabled }: {
game: GameView;
act: (action: Action) => void;
disabled: boolean;
}) {
const name = (id: string) => {
const player = game.players.find(candidate => candidate.id === id);
return player ? `${player.name} (${faction(player.faction).name})` : 'Unknown player';
};
const canAct = game.status === 'playing' && !game.players.find(player => player.id === game.me)?.bot;
const deals: Deal[] = game.deals ?? [];
if (!deals.length) return <p>No public deals have been proposed.</p>;
return <ol>
{deals.map(deal => {
const participants = publicDealParticipants(deal);
const accepted = publicDealAcceptVotes(deal);
const party = participants.includes(game.me);
const voted = deal.releaseVotes.includes(game.me);
const recipient = party && game.me !== deal.from;
const seats = participants.map(id => game.players.find(player => player.id === id));
const eligible = seats.every((player, index) => player && seats.slice(index + 1).every(other =>
other && !allied(game, player, other)));
const payment = (deal as unknown as { payment?: DealPayment }).payment;
const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus;
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;
const deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id];
const mayPay = payment?.kind === 'deferredBribe' && deal.status === 'active' && paymentStatus === 'pending' &&
payment.payer === game.me && canAct;
return <li key={deal.id}>
<p><strong>{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')}</strong> · Turn {deal.turn} · {deal.status}</p>
<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>}
{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 === '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>}
<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}
onClick={() => act({ type: 'dealWithdraw', deal: deal.id })}>Withdraw offer</Button>}
{canAct && deal.status === 'active' && party && <Button type="button" variant="outline" disabled={disabled}
onClick={() => act({ type: 'dealRelease', deal: deal.id, agree: !voted })}>
{voted ? 'Withdraw release request' : deal.releaseVotes.length > 0 ? 'Agree to release deal' : 'Request release of deal'}
</Button>}
{mayPay && <div>
{deferredOption?.blocked && <p>Payment is unavailable: {deferredOption.blocked}</p>}
<Button type="button" disabled={disabled || !deferredOption || !!deferredOption.blocked}
onClick={() => act({ type: 'dealPay', deal: deal.id })}>Pay agreed amount</Button>
</div>}
</li>;
})}
</ol>;
}
export function PublicDeals({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const [selected, setSelected] = useState<string[]>([]);
const [terms, setTerms] = useState('');
const [includePayment, setIncludePayment] = useState(false);
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
const [payer, setPayer] = useState('');
const [paymentRecipient, setPaymentRecipient] = useState('');
const [paymentAmount, setPaymentAmount] = useState('1');
const [deadlineDraft, setDeadlineDraft] = useState(() => ({ value: String(game.turn), turn: game.turn }));
const previousDeadline = Number(deadlineDraft.value);
const paymentDueTurn = deadlineDraft.turn !== game.turn && deadlineDraft.value.trim() !== '' &&
Number.isSafeInteger(previousDeadline) && previousDeadline < game.turn
? String(game.turn) : deadlineDraft.value;
const requests = publicDealRequests(game);
const needsResponse = requests.length > 0;
const paymentDue = requests.some(request => request[0] === 'payment');
const requestKey = JSON.stringify(requests);
const panel = useRef<HTMLDetailsElement>(null);
useEffect(() => { if (requestKey !== '[]' && panel.current) panel.current.open = true; }, [requestKey]);
if (game.status !== 'playing' && game.status !== 'finished') return null;
const me = game.players.find(player => player.id === game.me);
const targets = game.players.filter(player => me && player.id !== me.id && !player.bot);
const selectedPlayers = targets.filter(player => selected.includes(player.id));
const legalSelection = !!me && selected.length > 0 && selected.length === selectedPlayers.length &&
selectedPlayers.every((player, index) => !allied(game, me, player) &&
selectedPlayers.slice(index + 1).every(other => !allied(game, player, other)));
const parties = me ? [me, ...selectedPlayers] : [];
const amount = Number(paymentAmount);
const dueTurn = Number(paymentDueTurn);
const dueValid = Number.isSafeInteger(dueTurn) && dueTurn >= game.turn;
const payment: DealPayment | undefined = includePayment
? paymentKind === 'immediateBribe'
? { kind: paymentKind, payer, recipient: paymentRecipient, amount }
: { kind: paymentKind, payer, recipient: paymentRecipient, amount, dueTurn }
: undefined;
const paymentPeopleAndAmountValid = !!me && payer !== paymentRecipient &&
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 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>
<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 => {
event.preventDefault();
if (!disabled && offer) act(offer);
}}>
<fieldset>
<legend>Deal recipients</legend>
{targets.map(player => {
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)}
onChange={event => {
const nextChecked = event.target.checked;
setSelected(current => nextChecked
? current.includes(player.id) ? current : [...current, player.id] : current.filter(id => id !== player.id));
}} />
{player.name} · {faction(player.faction).name}{conflicts ? ' (allied with a selected party)' : ''}
</label>;
})}
</fieldset>
<label className="checkbox-row">
<input type="checkbox" checked={includePayment} disabled={disabled}
onChange={event => setIncludePayment(event.target.checked)} />
Include a spice payment
</label>
{includePayment && <fieldset>
<legend>Payment term</legend>
<label>Payment timing
<select value={paymentKind} disabled={disabled} onChange={event =>
setPaymentKind(event.target.value === 'deferredBribe' ? 'deferredBribe' : 'immediateBribe')}>
<option value="immediateBribe">Immediately on final acceptance</option>
<option value="deferredBribe">By the end of a current or future turn</option>
</select>
</label>
<label>Paying participant
<select value={parties.some(player => player.id === payer) ? payer : ''} disabled={disabled}
onChange={event => setPayer(event.target.value)}>
<option value="">Choose a payer</option>
{parties.map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
</select>
</label>
<label>Receiving participant
<select value={parties.some(player => player.id === paymentRecipient) ? paymentRecipient : ''} disabled={disabled}
onChange={event => setPaymentRecipient(event.target.value)}>
<option value="">Choose a recipient</option>
{parties.filter(player => player.id !== payer).map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
</select>
</label>
<label>Gross spice amount
<input type="number" inputMode="numeric" min={1} step={1} value={paymentAmount} disabled={disabled}
onChange={event => setPaymentAmount(event.target.value)} />
</label>
{paymentKind === 'deferredBribe' && <label>Payment due by the end of turn
<input type="number" inputMode="numeric" min={game.turn} step={1} value={paymentDueTurn} disabled={disabled}
required aria-invalid={!dueValid} onChange={event => setDeadlineDraft({ value: event.target.value, turn: game.turn })} />
</label>}
{paymentKind === 'immediateBribe'
? <p className="fine">No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.</p>
: <p className="fine">After all participants accept, the payer can pay on or before the end of the selected turn while payment remains possible. A separate pay control appears on the active deal; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank.</p>}
{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>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" />
</label>
<p className="fine">Up to 500 characters. Select human recipients who are not allied with each other or with you; AI negotiation is unfinished.</p>
<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>}
</form>}
<h3>Deal history</h3>
<PublicDealList game={game} act={act} disabled={disabled} />
</details>;
}