'use client'; import { useId, useState } from 'react'; /** Only the viewer's own pledges and contributions offered to that viewer. */ export type CoalitionFundingView = { targets: readonly { id: string; name: string; pledged: number; maximum: number; minimum?: number }[]; incoming: readonly { donor: string; name: string; amount: number }[]; blocked: string | null; }; export type CoalitionFundingContribution = { donor: string; amount: number }; export type CoalitionPledgeAction = { type: 'coalitionPledge'; recipient: string; amount: number }; function safeMaximum(value: number): number { return Number.isSafeInteger(value) && value >= 0 ? value : 0; } /** Keep only positive donor-specific allocations within the current pledges. */ export function coalitionFundingContributions( incoming: CoalitionFundingView['incoming'], selections: Readonly>, ): CoalitionFundingContribution[] { return incoming.flatMap(entry => { const selected = selections[entry.donor]; const amount = Number.isFinite(selected) ? Math.max(0, Math.min(safeMaximum(entry.amount), Math.trunc(selected))) : 0; return amount ? [{ donor: entry.donor, amount }] : []; }); } /** Advisory validation only; the engine verifies current membership, escrow, * commitments and wallet again when it receives the action. */ export function coalitionPledgeAction( target: CoalitionFundingView['targets'][number], raw: string, blocked: string | null, busy = false, ): CoalitionPledgeAction | null { if (busy || blocked || !/^(0|[1-9]\d*)$/.test(raw)) return null; const amount = Number(raw); if (!Number.isSafeInteger(amount) || amount < safeMaximum(target.minimum ?? 0) || amount > safeMaximum(target.maximum)) return null; return { type: 'coalitionPledge', recipient: target.id, amount }; } export function CoalitionFunding({ view, busy = false, act, onContributionsChange, purpose = 'shipment' }: { view: CoalitionFundingView; busy?: boolean; purpose?: 'bid' | 'shipment'; act: (action: CoalitionPledgeAction) => void; onContributionsChange?: (contributions: CoalitionFundingContribution[]) => void; }) { const id = useId(); const [pledges, setPledges] = useState>({}); const incomingKey = JSON.stringify(view.incoming.map(({donor, amount}) => [donor, amount])); const [selection, setSelection] = useState<{key:string; shares:Record}>({key:'', shares:{}}); const shares = selection.key === incomingKey ? selection.shares : {}; function changeShare(donor: string, amount: number) { const next = { ...shares, [donor]: amount }; const contributions = coalitionFundingContributions(view.incoming, next); setSelection({key:incomingKey, shares:Object.fromEntries(contributions.map(item => [item.donor, item.amount]))}); onContributionsChange?.(contributions); } return

Group alliance funding

Set aside spice separately for each ally. Unspent pledges return at the end of the phase.

{view.blocked &&

{view.blocked}

} {view.targets.map((target, index) => { const raw = pledges[target.id] ?? String(target.pledged); const action = coalitionPledgeAction(target, raw, view.blocked, busy); const inputId = `${id}-pledge-${index}`; const minimum = safeMaximum(target.minimum ?? 0); return
{minimum > 0 &&

The current high bid commits {minimum} spice of this pledge.

} setPledges(previous => ({ ...previous, [target.id]: event.target.value }))} />
; })} {view.incoming.length ?

Spice pledged to you:

{view.incoming.map((entry, index) => { const maximum = safeMaximum(entry.amount); const value = Math.max(0, Math.min(maximum, shares[entry.donor] ?? 0)); const inputId = `${id}-share-${index}`; return

{entry.name}: {entry.amount} spice

{onContributionsChange && <> { const entered = Number(event.target.value); changeShare(entry.donor, Number.isFinite(entered) ? Math.max(0, Math.min(maximum, Math.trunc(entered))) : 0); }} /> }
; })}
:

No allies have pledged spice to you.

}
; }