'use client';
import { useEffect, useRef, useState } from 'react';
import { faction } from '@/game/catalog';
import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals';
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, 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, ...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
No public deals have been proposed.
;
return
{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 && 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
{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')} · Turn {deal.turn} · {deal.status}
{deal.terms}
{payment && Immediate payment term: {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.
}
{deal.status === 'offered' &&
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
}
{deal.status === 'offered' && !eligible && Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.
}
{deal.status === 'offered' && paymentBlocked && Final acceptance must wait: {paymentBlocked} The offer may still be declined or withdrawn.
}
{deal.status === 'active' && deal.releaseVotes.length > 0 &&
Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
}
{canAct && deal.status === 'offered' && recipient &&
{!accepted.includes(game.me) && act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal }
act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal
}
{canAct && deal.status === 'offered' && deal.from === game.me && act({ type: 'dealWithdraw', deal: deal.id })}>Withdraw offer }
{canAct && deal.status === 'active' && party && act({ type: 'dealRelease', deal: deal.id, agree: !voted })}>
{voted ? 'Withdraw release request' : deal.releaseVotes.length > 0 ? 'Agree to release deal' : 'Request release of deal'}
}
;
})}
;
}
export function PublicDeals({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const [selected, setSelected] = useState([]);
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))
return [['offer', deal.id]];
if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0)
return [['release', deal.id, ...deal.releaseVotes]];
return [];
}) : [];
const needsResponse = requests.length > 0;
const requestKey = JSON.stringify(requests);
const panel = useRef(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 allied = (a: typeof game.players[number], b: typeof game.players[number]) =>
a.ally === b.id || b.ally === a.id;
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 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
Public deals{needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}
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.
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. Bribe rules
{mayOffer && }
Deal history
;
}