105 lines
5.1 KiB
TypeScript
105 lines
5.1 KiB
TypeScript
'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<Record<string, number>>,
|
|
): 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<Record<string, string>>({});
|
|
const incomingKey = JSON.stringify(view.incoming.map(({donor, amount}) => [donor, amount]));
|
|
const [selection, setSelection] = useState<{key:string; shares:Record<string, number>}>({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 <section aria-label="Group alliance funding" className="alliance-funding space-y-3">
|
|
<h3>Group alliance funding</h3>
|
|
<p>Set aside spice separately for each ally. Unspent pledges return at the end of the phase.</p>
|
|
{view.blocked && <p className="notice">{view.blocked}</p>}
|
|
{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 <div key={target.id} className="space-y-2">
|
|
<label htmlFor={inputId}>Pledge to {target.name} · {target.pledged} spice currently set aside</label>
|
|
{minimum > 0 && <p>The current high bid commits {minimum} spice of this pledge.</p>}
|
|
<input id={inputId} type="number" min={minimum} max={safeMaximum(target.maximum)} step={1}
|
|
inputMode="numeric" value={raw} disabled={busy || !!view.blocked}
|
|
onChange={event => setPledges(previous => ({ ...previous, [target.id]: event.target.value }))} />
|
|
<button type="button" className="game-action" disabled={!action}
|
|
onClick={() => { if (action) act(action); }}>Set funding for {target.name}</button>
|
|
</div>;
|
|
})}
|
|
{view.incoming.length ? <div>
|
|
<p>Spice pledged to you:</p>
|
|
{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 <div key={entry.donor} className="space-y-2">
|
|
<p>{entry.name}: {entry.amount} spice</p>
|
|
{onContributionsChange && <>
|
|
<label htmlFor={inputId}>Use from {entry.name} for your next {purpose}</label>
|
|
<input id={inputId} type="number" min={0} max={maximum} step={1}
|
|
inputMode="numeric" value={value} disabled={busy || !!view.blocked}
|
|
onChange={event => {
|
|
const entered = Number(event.target.value);
|
|
changeShare(entry.donor, Number.isFinite(entered)
|
|
? Math.max(0, Math.min(maximum, Math.trunc(entered))) : 0);
|
|
}} />
|
|
</>}
|
|
</div>;
|
|
})}
|
|
</div> : <p>No allies have pledged spice to you.</p>}
|
|
</section>;
|
|
}
|