Support immediate payments in public deals

This commit is contained in:
Quintine committed 2026-10-28 00:54:20 +11:00
1 parent e133a5fe2b
commit 6da6ff7f83
14 files changed
+545 -32

No files matched your search

+55 -6
View File
@@ -7,14 +7,19 @@ 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 };
/** Keep the legacy single-recipient wire packet when only one seat is chosen. */
export function publicDealOfferAction(targets: readonly string[], terms: string): Action | null {
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: ImmediateBribe, proposer?: string): 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.payer === payment.recipient ||
![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) ||
!Number.isSafeInteger(payment.amount) || payment.amount < 1)) return null;
const offeredPayment = payment ? { payment } : {};
return targets.length === 1
? { type: 'dealOffer', target: targets[0], terms: text }
: { type: 'dealOffer', targets: [...targets], terms: text };
? { 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. */
@@ -40,18 +45,23 @@ export function PublicDealList({ game, act, disabled }: {
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 && player.ally !== other.id && other.ally !== player.id));
const payment = (deal as Deal & { payment?: ImmediateBribe }).payment;
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
const paymentBlocked = payment && finalAcceptance ? game.bribeOptions?.blocked : null;
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 && <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>}
{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 === '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} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked} 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}
@@ -72,6 +82,10 @@ export function PublicDeals({ game, act, busy }: {
}) {
const [selected, setSelected] = useState<string[]>([]);
const [terms, setTerms] = useState('');
const [includePayment, setIncludePayment] = useState(false);
const [payer, setPayer] = useState('');
const [paymentRecipient, setPaymentRecipient] = useState('');
const [paymentAmount, setPaymentAmount] = useState('1');
const requests = game.status === 'playing' ? game.deals.flatMap(deal => {
const participant = publicDealParticipants(deal).includes(game.me);
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
@@ -93,13 +107,20 @@ export function PublicDeals({ game, act, busy }: {
const legalSelection = !!me && selected.length > 0 && selected.length === selectedPlayers.length &&
selectedPlayers.every((player, index) => !allied(me, player) &&
selectedPlayers.slice(index + 1).every(other => !allied(player, other)));
const offer = legalSelection ? publicDealOfferAction(selected, terms) : null;
const parties = me ? [me, ...selectedPlayers] : [];
const amount = Number(paymentAmount);
const payment: ImmediateBribe | undefined = includePayment
? { kind: 'immediateBribe', payer, recipient: paymentRecipient, amount } : undefined;
const paymentValid = !includePayment || !!me && payer !== paymentRecipient &&
parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) &&
Number.isSafeInteger(amount) && amount > 0;
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id) : 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{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. A deal does not automatically transfer spice; use the Bribes control to make an eligible payment. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. An optional immediate 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);
@@ -120,6 +141,34 @@ export function PublicDeals({ game, act, busy }: {
</label>;
})}
</fieldset>
<label className="checkbox-row">
<input type="checkbox" checked={includePayment} disabled={disabled}
onChange={event => setIncludePayment(event.target.checked)} />
Include an immediate spice payment
</label>
{includePayment && <fieldset>
<legend>Payment on final acceptance</legend>
<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>
<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>
{legalSelection && !paymentValid && <p>Choose two different participants and a positive whole spice amount.</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" />