Support staged group shipment funding
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
624f95dde4
commit
34aa64cdef
13 files changed
+828
-16
No files matched your search
@@ -0,0 +1,100 @@
|
||||
'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 }[];
|
||||
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.maximum)) return null;
|
||||
return { type: 'coalitionPledge', recipient: target.id, amount };
|
||||
}
|
||||
|
||||
export function CoalitionFunding({ view, busy = false, act, onContributionsChange }: {
|
||||
view: CoalitionFundingView;
|
||||
busy?: boolean;
|
||||
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}`;
|
||||
return <div key={target.id} className="space-y-2">
|
||||
<label htmlFor={inputId}>Pledge to {target.name} · {target.pledged} spice currently set aside</label>
|
||||
<input id={inputId} type="number" min={0} 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 shipment</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>;
|
||||
}
|
||||
@@ -285,6 +285,7 @@ import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assa
|
||||
import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion';
|
||||
import { AdvancedAllySeparation } from './advanced-ally-separation';
|
||||
import { CoalitionNexus } from './coalition-nexus';
|
||||
import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding';
|
||||
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
||||
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
|
||||
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
|
||||
@@ -583,6 +584,7 @@ export function GameTable({
|
||||
});
|
||||
const [aidAmount, setAidAmount] = useState(0);
|
||||
const [allyPayment, setAllyPayment] = useState('');
|
||||
const [coalitionShares, setCoalitionShares] = useState<{key:string; shares:CoalitionFundingContribution[]}>({key:'', shares:[]});
|
||||
const fillBattlePlan = (plan: Plan) => {
|
||||
setDial(plan.dial);
|
||||
setSupport(plan.support);
|
||||
@@ -780,9 +782,17 @@ export function GameTable({
|
||||
if (useGuildSecret && shipmentCost === null && !g.nexusGuildSecretAlly?.blocked) shipmentProblems.push('Choose a current Guild Secret Ally shipment.');
|
||||
if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked)
|
||||
shipmentProblems.push('Choose a current Richese Secret Ally shipment of up to five physical forces.');
|
||||
const coalitionFundingKey = JSON.stringify(g.coalitionFunding?.incoming.map(({donor, amount}) => [donor, amount]) ?? []);
|
||||
const currentCoalitionShares = g.coalitionFunding && coalitionShares.key === coalitionFundingKey
|
||||
? coalitionFundingContributions(g.coalitionFunding.incoming,
|
||||
Object.fromEntries(coalitionShares.shares.map(share => [share.donor, share.amount]))) : [];
|
||||
const groupShipmentShare = currentCoalitionShares.reduce((sum, share) => sum + share.amount, 0);
|
||||
const availableShipmentAid = g.coalitionFunding
|
||||
? g.coalitionFunding.incoming.reduce((sum, share) => sum + share.amount, 0) : g.aid.available;
|
||||
const selectedAllyPayment =
|
||||
shipmentCost === null
|
||||
? 0
|
||||
: g.coalitionFunding ? groupShipmentShare
|
||||
: !me.ally || allyPayment === ''
|
||||
? Math.max(0, shipmentCost - (me.spice ?? 0))
|
||||
: Number(allyPayment);
|
||||
@@ -797,9 +807,9 @@ export function GameTable({
|
||||
`Ally payment must be a whole number from 0 to ${shipmentCost}.`,
|
||||
);
|
||||
else {
|
||||
if (selectedAllyPayment > g.aid.available)
|
||||
if (selectedAllyPayment > availableShipmentAid)
|
||||
shipmentProblems.push(
|
||||
`Only ${g.aid.available} pledged ally spice is available.`,
|
||||
`Only ${availableShipmentAid} pledged ally spice is available.`,
|
||||
);
|
||||
if (shipmentCost - selectedAllyPayment > (me.spice ?? 0))
|
||||
shipmentProblems.push(
|
||||
@@ -4226,6 +4236,9 @@ export function GameTable({
|
||||
<ChoamGamont game={g} act={act} busy={busy} />
|
||||
<ChoamWorthless game={g} act={act} busy={busy} />
|
||||
<ChoamBattleFunding game={g} act={act} busy={busy} />
|
||||
{g.coalitionFunding && <CoalitionFunding view={g.coalitionFunding} busy={busy} act={act}
|
||||
{...(shipmentAvailable ? {onContributionsChange:(shares:CoalitionFundingContribution[]) =>
|
||||
setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />}
|
||||
{me.ally && [3, 5].includes(g.phase) && (
|
||||
<details className="alliance-funding">
|
||||
<summary>
|
||||
@@ -4940,7 +4953,7 @@ export function GameTable({
|
||||
quote={shipmentQuote}
|
||||
funding={{
|
||||
ownSpice: me.spice ?? 0,
|
||||
pledgedSpice: g.aid.available,
|
||||
pledgedSpice: availableShipmentAid,
|
||||
}}
|
||||
unavailableReasons={shipmentProblems}
|
||||
/>
|
||||
@@ -4957,6 +4970,8 @@ export function GameTable({
|
||||
const shipment: Action = {
|
||||
type: 'ship', elite: eliteAmount, ...payment,
|
||||
territory: selected, sector, amount,
|
||||
...(g.coalitionFunding && currentCoalitionShares.length
|
||||
? {allyPayment: 0, coalitionContributions: currentCoalitionShares} : {}),
|
||||
...(smugglerOffer ? { smuggler: useSmuggler } : {}),
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user