'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; }; /** 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

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 && !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
  1. {name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')} · Turn {deal.turn} · {deal.status}

    {deal.terms}

    {payment?.kind === 'immediateBribe' &&

    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.

    } {payment?.kind === 'deferredBribe' &&

    Deferred payment term: {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.'}

    } {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 === 'offered' && paymentExpired &&

    The payment’s due turn has passed. 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) && }
    } {canAct && deal.status === 'offered' && deal.from === game.me && } {canAct && deal.status === 'active' && party && } {mayPay &&
    {deferredOption?.blocked &&

    Payment is unavailable: {deferredOption.blocked}

    }
    }
  2. ; })}
; } 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 [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(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
Public deals{paymentDue ? ' · payment due' : 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 payment uses the ordinary bribe rules; free-text terms do not automatically transfer spice. Bribe rules

{mayOffer &&
{ event.preventDefault(); if (!disabled && offer) act(offer); }}>
Deal recipients {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 ; })}
{includePayment &&
Payment term {paymentKind === 'deferredBribe' && } {paymentKind === 'immediateBribe' ?

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.

:

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.

} {legalSelection && !paymentPeopleAndAmountValid &&

Choose two different participants and a positive whole spice amount.

} {paymentKind === 'deferredBribe' && !dueValid &&

Choose a whole due turn no earlier than turn {game.turn}.

}
}