Support staged group bid funding
Verify and publish checkpoint container / container (push) Successful in 10m27s

This commit is contained in:
Quintine committed 2026-10-28 23:22:50 +11:00
1 parent 34aa64cdef
commit 1eeeb32566
12 files changed
+426 -37

No files matched your search

+9 -5
View File
@@ -4,7 +4,7 @@ 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 }[];
targets: readonly { id: string; name: string; pledged: number; maximum: number; minimum?: number }[];
incoming: readonly { donor: string; name: string; amount: number }[];
blocked: string | null;
};
@@ -35,13 +35,15 @@ export function coalitionPledgeAction(
): 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;
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 }: {
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;
}) {
@@ -66,9 +68,11 @@ export function CoalitionFunding({ view, busy = false, act, onContributionsChang
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>
<input id={inputId} type="number" min={0} max={safeMaximum(target.maximum)} step={1}
{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}
@@ -84,7 +88,7 @@ export function CoalitionFunding({ view, busy = false, act, onContributionsChang
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>
<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 => {
+12 -9
View File
@@ -453,11 +453,6 @@ export function GameTable({
? bidDraft.value
: String(minimumBid);
const bidAmount = integerInputValue(bidText);
const maximumBid = me.hand?.some((c) =>
canUseAsKaramaRole(g, me, c),
)
? Number.MAX_SAFE_INTEGER
: (me.spice ?? 0) + g.aid.available;
const [eliteAmount, setEliteAmount] = useState(0);
const [homeworldDraft, setHomeworldDraft] = useState<{
key: string;
@@ -782,13 +777,16 @@ 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 coalitionFundingKey = JSON.stringify([g.code, g.turn, g.phase,
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 maximumBid = me.hand?.some((c) => canUseAsKaramaRole(g, me, c))
? Number.MAX_SAFE_INTEGER : (me.spice ?? 0) + (g.phase === 3 && g.coalitionFunding ? groupShipmentShare : g.aid.available);
const selectedAllyPayment =
shipmentCost === null
? 0
@@ -3890,7 +3888,7 @@ export function GameTable({
{actionButton(
'Pay with spice',
{ type: 'decision', karama: false },
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available + (g.auction?.coalitionPayment ?? 0),
)}
{me.hand
?.filter((card) => canSpendKarama(card, 'auction-free-purchase'))
@@ -4237,7 +4235,8 @@ export function GameTable({
<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[]) =>
purpose={g.phase === 3 ? 'bid' : 'shipment'}
{...((shipmentAvailable || g.phase === 3 && g.auction?.active === me.id) ? {onContributionsChange:(shares:CoalitionFundingContribution[]) =>
setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />}
{me.ally && [3, 5].includes(g.phase) && (
<details className="alliance-funding">
@@ -4494,11 +4493,15 @@ export function GameTable({
type: 'bid',
amount: bidAmount,
...payment,
...(g.coalitionFunding && currentCoalitionShares.length
? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}),
},
!!normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) ||
!Number.isSafeInteger(bidAmount) ||
bidAmount < minimumBid ||
bidAmount > maximumBid,
bidAmount > maximumBid ||
!!(g.coalitionFunding && currentCoalitionShares.length &&
(groupShipmentShare > bidAmount || bidAmount - groupShipmentShare > (me.spice ?? 0))),
)}
{actionButton('Pass this bid', { type: 'passBid' })}
</>