From 34aa64cdefbaf4f80cda037e9e0da41f4c8d2e05 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:14:44 +1100 Subject: [PATCH] Support staged group shipment funding --- components/coalition-funding.tsx | 100 +++++++++++++ components/game-table.tsx | 21 ++- docs/CURRENT_STATUS.md | 18 ++- docs/OPTIONAL_ALLIANCE_VICTORY.md | 20 +++ docs/RULE_DECISIONS.md | 7 + game/coalition-escrow.ts | 154 +++++++++++++++++++ game/engine.ts | 143 +++++++++++++++++- game/reference.ts | 9 +- tests/coalition-escrow.test.ts | 101 +++++++++++++ tests/coalition-funding-controls.test.tsx | 64 ++++++++ tests/coalition-shipment-funding.test.ts | 171 ++++++++++++++++++++++ tests/coalition-transit-engine.test.ts | 10 ++ tests/nexus-offer-controls.test.tsx | 26 ++++ 13 files changed, 828 insertions(+), 16 deletions(-) create mode 100644 components/coalition-funding.tsx create mode 100644 game/coalition-escrow.ts create mode 100644 tests/coalition-escrow.test.ts create mode 100644 tests/coalition-funding-controls.test.tsx create mode 100644 tests/coalition-shipment-funding.test.ts diff --git a/components/coalition-funding.tsx b/components/coalition-funding.tsx new file mode 100644 index 00000000..846ba977 --- /dev/null +++ b/components/coalition-funding.tsx @@ -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>, +): 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>({}); + 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}`; + return
+ + 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.

} +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 046b956f..0d6f0004 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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({ + {g.coalitionFunding && + setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />} {me.ally && [3, 5].includes(g.phase) && (
@@ -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 } : {}), }; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1ebc5d7c..5e43fd38 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -40,6 +40,16 @@ cases. The three-member option remains unavailable: faction benefits, funding, and other group interactions still need integration. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-nexus-negotiation--28-october-2026). +**Staged group shipment funding — 28 October. Prototyped / Partial.** Two +allies can pledge separately to a group member, who may use named shares for +one ordinary Basic shipment; an outside Guild receives the payment and unspent +pledges return to their donors. The staged table has private pledge and share +controls. The related eight-file batch passes 48 cases, with real Nexus and +shipment actions following a staged starting position. Guild-member groups, +group bidding and special transports remain +unsupported; group play is still not exposed in the lobby. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026). + **Basic allied No-Field transit — 28 October. Prototyped / Partial.** Richese may offer its ally an immediately revealed shipment into a Richese-occupied territory. The recipient may depart or accept the existing provisional loss @@ -132,10 +142,10 @@ enabled or claimed complete by the standalone foundation. Shipment entry and visitor consequences now use the group-aware receipt and physical baseline. The staged Nexus negotiation is connected; group funding, faction benefits and other engine paths still need integration before the option can be offered. -Connected staged Nexus controls and a funding quote model unanimous consent, -explicit donor allocations and edge-bound pledge authorizations. The engine -still must issue and revoke unique edge authorizations, settle and refund -escrow atomically, and connect group funding controls to native actions and AI. +Connected staged Nexus and shipment controls model unanimous consent, explicit +donor allocations and edge-bound pledge authorizations. The engine issues and +revokes those authorizations and refunds shipment escrow; group-funded bidding, +special transport and Guild-allied rates still require integration. The separate printed unlimited-size / three-stronghold variant remains missing; the current group model deliberately caps membership at three. The combined earlier batch passed 189 cases; the shipment batch passed 58 cases. The latest diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index a8d954a3..46850fc5 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,25 @@ # Optional three-stronghold alliance victory +## Group ordinary-shipment funding — 28 October 2026 + +**Staged Basic prototype, not a selectable game mode.** Group allies can set +aside separate amounts for each other during Shipment and Movement. A shipper +may explicitly allocate amounts from each pledged donor to an ordinary physical +reserve shipment; the authoritative payment rechecks those exact pledges, +charges the shipper's remainder once, and retains each donor's identity. An +outside Guild receives the eligible payment. Unspent pledges return to their +respective donors at phase end. Alliance edge authorizations are renewed on +formation, preserved for surviving edges and revoked on departure, with +aggregated refunds for removed edges. Controls show only the viewer's own +outgoing and incoming pledges; bots may continue using their own spice. + +This first path is limited to Basic classic-faction staged groups without a +Guild member, optional modules, Karama shipment pricing or special transport. +Guild-allied rates, other shipment types, group-funded bidding, and the +remaining faction benefits and interactions are not connected. No lobby can +start a group game yet. The recipient-specific pledge and allocation procedure +is a provisional online convention rather than a printed worked example. + ## Group Nexus negotiation — 28 October 2026 **Integrated groundwork, not a selectable game mode.** In a staged Basic diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1654cf6f..82d9bc46 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Group shipment funding — 28 October 2026 + +The unexposed Basic group prototype uses recipient-specific escrow and +explicit donor allocations for an ordinary physical shipment. Those online +controls are provisional; Guild-allied pricing, bidding and other transports +remain separate. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026). + ## Group Nexus negotiations — 28 October 2026 For the unexposed Basic group foundation, unanimous consent, a held Nexus diff --git a/game/coalition-escrow.ts b/game/coalition-escrow.ts new file mode 100644 index 00000000..9768a35b --- /dev/null +++ b/game/coalition-escrow.ts @@ -0,0 +1,154 @@ +import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance'; +import { + CoalitionFundingError, + reconcileCoalitionPledges, + type CoalitionFundingAuthorization, + type CoalitionPledge, +} from './coalition-funding'; + +/** Epochs are never reused, including after an edge leaves and reforms. */ +export type CoalitionEscrowState = Readonly<{ + authorizations: readonly CoalitionFundingAuthorization[]; + pledges: readonly CoalitionPledge[]; + usedEpochs: readonly string[]; +}>; +export type CoalitionEscrowMembership = Readonly<{ + seats: readonly CoalitionSeat[]; + coalitionState?: CoalitionState; +}>; +export type CoalitionEscrowRefund = Readonly<{ donor: string; amount: number }>; + +const whole = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) >= 0; +const validEpoch = (s: unknown): s is string => typeof s === 'string' && !!s && + s.length <= 256 && s.trim() === s; +function check(condition: unknown, message: string): asserts condition { + if (!condition) throw new CoalitionFundingError(message); +} +const key = (members: readonly string[]) => JSON.stringify(members); +const edge = (a: string, b: string) => key([a, b].sort()); +const directed = (donor: string, recipient: string) => key([donor, recipient]); +function checkedSum(a: number, b: number): number { + const result = a + b; + check(whole(result), 'Escrow totals must be safe integers.'); + return result; +} +function edges(membership: CoalitionEscrowMembership): Map { + const out = new Map(); + for (const group of coalitionGroups(membership.seats, membership.coalitionState)) + for (const item of group.edges) out.set(key(item.members), item.members); + return out; +} +function validateHistory(escrow: CoalitionEscrowState) { + check(!!escrow && Array.isArray(escrow.authorizations) && Array.isArray(escrow.pledges) && + Array.isArray(escrow.usedEpochs) && escrow.usedEpochs.every(validEpoch) && + new Set(escrow.usedEpochs).size === escrow.usedEpochs.length, + 'The escrow must retain unique valid epoch history.'); + const used = new Set(escrow.usedEpochs); + check(escrow.authorizations.every(auth => auth && validEpoch(auth.epoch) && used.has(auth.epoch)) && + escrow.pledges.every(pledge => pledge && validEpoch(pledge.epoch) && used.has(pledge.epoch)), + 'Every escrow epoch must appear in its retained history.'); +} +function validateCurrent(membership: CoalitionEscrowMembership, escrow: CoalitionEscrowState) { + validateHistory(escrow); + const result = reconcileCoalitionPledges({ ...membership, + authorizations: escrow.authorizations, pledges: escrow.pledges }); + check(result.refunds.length === 0, 'Reconcile stale pledges before changing escrow.'); +} + +/** + * Apply one authoritative alliance transition. Call once for each formation or + * break, not on a collapsed before/after history: an edge broken and reformed + * must receive a new epoch. The caller supplies freshly generated IDs for only + * the new edges, atomically installs the result and credits each donor refund. + * A pledge with a live commitment cannot be refunded until that commitment + * has been settled or canceled. The caller supplies every such locked pledge. + */ +export function reconcileCoalitionEscrow(input: Readonly<{ + before: CoalitionEscrowMembership; + after: CoalitionEscrowMembership; + escrow: CoalitionEscrowState; + newAuthorizations: readonly CoalitionFundingAuthorization[]; + locked?: readonly CoalitionPledge[]; +}>): { escrow: CoalitionEscrowState; refunds: CoalitionEscrowRefund[] } { + const { before, after, escrow } = input; + validateCurrent(before, escrow); + const oldEdges = edges(before), newEdges = edges(after); + const beforeIds = before.seats.map(seat => seat.id).sort(); + const afterIds = after.seats.map(seat => seat.id).sort(); + check(key(beforeIds) === key(afterIds), 'Escrow transitions require the same seated players.'); + check(Array.isArray(input.newAuthorizations) && Array.isArray(input.locked ?? []), + 'Supply new edge authorizations and any locked pledges.'); + const previous = new Map(escrow.authorizations.map(auth => [key(auth.members), auth])); + const history = new Set(escrow.usedEpochs); + const fresh = new Map(); + for (const auth of input.newAuthorizations) { + check(!!auth && Array.isArray(auth.members) && auth.members.length === 2 && + typeof auth.members[0] === 'string' && typeof auth.members[1] === 'string' && + auth.members[0] < auth.members[1] && validEpoch(auth.epoch), + 'Each fresh epoch needs an ordered pair of allies.'); + const id = key(auth.members); + check(newEdges.has(id) && !oldEdges.has(id) && !fresh.has(id) && !history.has(auth.epoch), + 'Fresh epochs must uniquely authorize new edges and cannot be reused.'); + history.add(auth.epoch); + fresh.set(id, { members: [auth.members[0], auth.members[1]], epoch: auth.epoch }); + } + const authorizations: CoalitionFundingAuthorization[] = []; + for (const [id, members] of newEdges) { + const auth = oldEdges.has(id) ? previous.get(id) : fresh.get(id); + check(auth, 'Every new alliance edge needs a fresh epoch.'); + authorizations.push({ members: [members[0], members[1]], epoch: auth.epoch }); + } + const pledged = new Map(escrow.pledges.map(item => [directed(item.donor, item.recipient), item])); + const locked = new Set(); + for (const item of input.locked ?? []) { + check(!!item && typeof item.donor === 'string' && typeof item.recipient === 'string' && + whole(item.amount) && item.amount > 0 && validEpoch(item.epoch), 'A locked pledge needs a valid committed amount.'); + const id = directed(item.donor, item.recipient), pledge = pledged.get(id); + check(!locked.has(id) && pledge && pledge.epoch === item.epoch && item.amount <= pledge.amount, + 'Each committed pledge must match its available escrow once.'); + locked.add(id); + check(newEdges.has(edge(item.donor, item.recipient)), + 'Resolve committed payments before breaking their alliance edge.'); + } + const reconciled = reconcileCoalitionPledges({ ...after, authorizations, pledges: escrow.pledges }); + const totals = new Map(); + for (const item of reconciled.refunds) + totals.set(item.donor, checkedSum(totals.get(item.donor) ?? 0, item.amount)); + return { + escrow: { authorizations, pledges: reconciled.pledges, usedEpochs: [...history] }, + refunds: [...totals].map(([donor, amount]) => ({ donor, amount })), + }; +} + +/** Return a signed wallet change and replacement ledger without mutating the + * donor. `available` is uncommitted wallet spice; `locked` is the already + * committed amount of this recipient's existing pledge. The engine must + * verify the actual wallet and atomically apply both outputs. */ +export function setCoalitionPledge(input: CoalitionEscrowMembership & Readonly<{ + escrow: CoalitionEscrowState; + donor: string; + recipient: string; + amount: number; + available: number; + locked?: number; +}>): { escrow: CoalitionEscrowState; walletDelta: number } { + const { escrow, donor, recipient, amount, available } = input; + const locked = input.locked ?? 0; + validateCurrent(input, escrow); + check(typeof donor === 'string' && typeof recipient === 'string' && donor !== recipient && + whole(amount) && whole(available) && whole(locked), 'Choose a valid donor, recipient and safe spice amounts.'); + const auth = escrow.authorizations.find(item => key(item.members) === edge(donor, recipient)); + check(auth, 'A pledge requires a current authorized alliance edge.'); + const old = escrow.pledges.find(item => item.donor === donor && item.recipient === recipient); + const prior = old?.amount ?? 0; + check(locked <= prior && amount >= locked, 'Committed pledge spice cannot be withdrawn.'); + const delta = prior - amount; + check(delta >= 0 || -delta <= available, 'The donor lacks uncommitted spice for this pledge.'); + if (delta > 0) checkedSum(available, delta); + const pledges = escrow.pledges.filter(item => item.donor !== donor || item.recipient !== recipient) + .map(item => ({ ...item })); + if (amount) pledges.push({ donor, recipient, amount, epoch: auth.epoch }); + return { escrow: { authorizations: escrow.authorizations.map(item => ({ + members: [item.members[0], item.members[1]], epoch: item.epoch, + })), pledges, usedEpochs: [...escrow.usedEpochs] }, walletDelta: delta }; +} diff --git a/game/engine.ts b/game/engine.ts index c7f4a35a..a24011d9 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -752,6 +752,10 @@ import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipmen type BasicAlliedShipment } from './allied-separation'; import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionGroups, coalitionOf, leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance'; +import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowMembership, + type CoalitionEscrowState } from './coalition-escrow'; +import { CoalitionFundingError, quoteCoalitionFunding, settleCoalitionFunding, + type CoalitionContribution } from './coalition-funding'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; @@ -1352,6 +1356,8 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & { pools: ReturnType['sources'] | ReturnType['boardSources']; }; type PendingShipment = { + /** Donor-specific paid shares for the staged group shipment path. */ + coalitionContributions?: CoalitionContribution[]; /** Only new declarations use the bonus; legacy quoted shipments retain their original price. */ smuggler?: SmugglerShipment; smugglerCompanion?: SmugglerNoFieldCompanion; @@ -1434,6 +1440,7 @@ type RicheseAllyOffer = { export type Game = { /** Unexposed group-alliance integration state; absent in existing games. */ coalitionState?: CoalitionState; + coalitionEscrow?: CoalitionEscrowState; /** Public written agreements. Their prose is honored by players, not parsed. */ deals?: PublicDeal[]; /** A human-to-human lobby offer, pending the recipient's consent. */ @@ -16001,9 +16008,34 @@ function coalitionNexusAvailable(g: Game): boolean { } function coalitionRule(work: () => T): T { try { return work(); } - catch (error) { if (error instanceof CoalitionAllianceError) throw new RuleError(error.message); throw error; } + catch (error) { if (error instanceof CoalitionAllianceError || error instanceof CoalitionFundingError) throw new RuleError(error.message); throw error; } +} +function coalitionMembership(g: Game): CoalitionEscrowMembership { + return { seats: g.players.map(({id, ally, allySinceTurn}) => ({id, ally, allySinceTurn})), + ...(g.coalitionState ? {coalitionState: structuredClone(g.coalitionState)} : {}) }; +} +function coalitionEdges(g: Game) { + return coalitionRule(() => coalitionGroups(g.players, g.coalitionState).flatMap(group => group.edges.map(edge => edge.members))); +} +function initializeCoalitionEscrow(g: Game) { + if (g.coalitionEscrow) return; + const authorizations = coalitionEdges(g).map(members => ({members, epoch: crypto.randomUUID()})); + g.coalitionEscrow = {authorizations, pledges: [], usedEpochs: authorizations.map(entry => entry.epoch)}; +} +function syncCoalitionEscrow(g: Game, before: CoalitionEscrowMembership) { + if (!g.coalitionEscrow) return; + const prior = new Set(g.coalitionEscrow.authorizations.map(auth => JSON.stringify(auth.members))); + const newAuthorizations = coalitionEdges(g).filter(members => !prior.has(JSON.stringify(members))) + .map(members => ({members, epoch: crypto.randomUUID()})); + const result = coalitionRule(() => reconcileCoalitionEscrow({before, after: coalitionMembership(g), + escrow: g.coalitionEscrow!, newAuthorizations})); + for (const refund of result.refunds) + requireRule(Number.isSafeInteger(getPlayer(g, refund.donor).spice + refund.amount), 'Group refund exceeds the donor wallet.'); + for (const refund of result.refunds) getPlayer(g, refund.donor).spice += refund.amount; + g.coalitionEscrow = result.escrow; } function applyCoalitionTransition(g: Game, result: CoalitionTransition) { + const before = coalitionMembership(g); g.coalitionState = result.state; for (const [id, ally] of Object.entries(result.allyUpdates)) getPlayer(g, id).ally = ally; for (const [id, turn] of Object.entries(result.allySinceUpdates)) { @@ -16011,6 +16043,24 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) { if (turn === null) delete player.allySinceTurn; else player.allySinceTurn = turn; } + syncCoalitionEscrow(g, before); +} +/** Initial staged group-funding scope: ordinary Basic planet shipments, with + * Guild outside the group. Guild-allied rate and other purchases remain separate. */ +function coalitionShipmentFundingAvailable(g: Game, p: Player): boolean { + if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || g.phase !== 5 || + g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || + g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || g.karamaShipping || + g.spiceBankerIncomePreview) return false; + const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id)); + const guild = byFaction(g, 'guild'); + return group?.members.length === 3 && (!guild || !group.members.includes(guild.id)); +} +function coalitionShipmentQuote(g: Game, p: Player, cost: number, contributions: CoalitionContribution[]) { + requireRule(coalitionShipmentFundingAvailable(g, p), 'Group funding is unavailable for this shipment.'); + return coalitionRule(() => quoteCoalitionFunding({seats:g.players, coalitionState:g.coalitionState, + authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges, + purpose:'shipment', payer:p.id, cost, ownAvailable:uncommittedSpice(g, p), contributions})); } /** The original native reciprocal quote owns identity and pairing legality. */ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) { @@ -28331,6 +28381,18 @@ function currentPhaseResources(g: Game) { } } function commitPhaseResources(g: Game, quote: PhaseResourceQuote) { + const groupRefunds = new Map(); + if (quote.clearAid && g.coalitionEscrow) { + for (const pledge of g.coalitionEscrow.pledges) { + requireRule(Number.isSafeInteger(pledge.amount) && pledge.amount >= 0 && + g.players.some(player => player.id === pledge.donor), 'Group refund needs a seated donor and valid escrow.'); + const total = (groupRefunds.get(pledge.donor) ?? 0) + pledge.amount; + requireRule(Number.isSafeInteger(total), 'Group funding refund exceeds a safe total.'); + groupRefunds.set(pledge.donor, total); + } + for (const [donor, amount] of groupRefunds) + requireRule(Number.isSafeInteger(quote.balances[donor] + amount), 'Group refund exceeds the donor wallet.'); + } for (const credit of quote.credits) { const owner = getPlayer(g, credit.player); owner.spice = credit.balance; @@ -28342,6 +28404,10 @@ function commitPhaseResources(g: Game, quote: PhaseResourceQuote) { } for (const id of quote.tokenResets) g.techTokens![id].spice = 0; if (quote.clearAid) g.aid = {}; + if (quote.clearAid && g.coalitionEscrow) { + for (const [donor, amount] of groupRefunds) getPlayer(g, donor).spice += amount; + g.coalitionEscrow = {...g.coalitionEscrow, pledges: []}; + } } function transferTech(g: Game, id: TechId, winner: string) { g.techTokens![id].owner = winner; @@ -37874,6 +37940,14 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) { ); integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current shipment spice'); integer(shipment.allyPayment, 0, cost, 'Shipment ally payment'); + if (shipment.coalitionContributions !== undefined) { + requireRule(shipment.allyPayment === 0 && !shipment.noField && !shipment.alliedNoField && + !shipment.source && !shipment.nexusEvent && !shipment.guildSecretEvent && !shipment.guildNexusEvent && + !shipment.smuggler && !shipment.homeworldSources && Array.isArray(shipment.coalitionContributions), + 'Group funding currently supports an ordinary physical reserve shipment.'); + coalitionShipmentQuote(g, p, cost, shipment.coalitionContributions); + return; + } const credit = aidFor(g, p); if (credit) integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge'); @@ -38521,6 +38595,13 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi owner.noFieldEvent = crypto.randomUUID(); owner.spice -= quote.ownerPayment; p.spice -= quote.recipientPayment; + } else if (shipment.coalitionContributions !== undefined) { + const result = coalitionRule(() => settleCoalitionFunding({seats:g.players, coalitionState:g.coalitionState, + authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges, + purpose:'shipment', payer:p.id, cost, ownAvailable:uncommittedSpice(g, p), + contributions:shipment.coalitionContributions!})); + p.spice -= result.quote.own; + g.coalitionEscrow = {...g.coalitionEscrow!, pledges:result.pledges}; } else payWithAlly(g, p, cost, allyPayment); if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost; if (pairRevealed) { @@ -38561,7 +38642,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi if (movementPreparation) { g.karamaShipping = null; return; } if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); const guild = byFaction(g, 'guild'); - const guildPayment = guildShipmentIncome({ + const groupShares = shipment.coalitionContributions; + const groupTotal = groupShares?.reduce((sum, share) => sum + share.amount, 0) ?? 0; + const guildPayment = groupShares ? (guild ? cost : 0) : guildShipmentIncome({ guild: guild?.id, shipper: p.id, ally: p.ally, @@ -38570,10 +38653,15 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi bankOnly: g.karamaShipping?.player === p.id || !!(shipment.alliedNoField && alliedNoFieldPricing(g, p).bankOnly), }); - if (!settlement?.recipient && guild && guildPayment > 0) - g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), - allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, - ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); + if (!settlement?.recipient && guild && guildPayment > 0) { + const named = groupShares ? [{payer:p.id, amount:cost - groupTotal}, + ...groupShares.map(share => ({payer:share.donor, amount:share.amount}))] + : [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, + ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]; + g.response = guildPaymentResponse(g, guild.id, + groupShares ? named.map(share => share.amount) : shipmentIncomeContributions(g, p, cost, allyPayment), + groupShares || allyPayment ? undefined : p.id, named); + } settleBankerShipmentIncome(g, p, cost, allyPayment); g.karamaShipping = null; log( @@ -44257,6 +44345,7 @@ function applyActionInner( requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.'); const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision'); if (t === 'coalitionPropose') { + initializeCoalitionEscrow(g); const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, { event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn, groupId: stringField(action.groupId), entrant: stringField(action.entrant), @@ -44317,6 +44406,7 @@ function applyActionInner( const formerOffer = g.allianceOffers[id]; const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name; const other = target === null ? null : getPlayer(g, target); + const escrowBefore = g.coalitionEscrow ? coalitionMembership(g) : null; const quote = normalNexusAllianceQuote(g, p, target); for (const player of g.players) player.ally = quote.allies[player.id]; g.allianceOffers = quote.offers; @@ -44334,6 +44424,7 @@ function applyActionInner( } else { log(g, `${p.name} offered an alliance to ${other!.name}; both players must agree before an alliance forms.`); } + if (escrowBefore) syncCoalitionEscrow(g, escrowBefore); g.ready = []; return g; } @@ -44371,6 +44462,19 @@ function applyActionInner( log(g, `${p.name} claimed CHOAM charity.`); return g; } + if (t === 'coalitionPledge') { + requireRule(coalitionShipmentFundingAvailable(g, p), 'Group shipment funding is unavailable now.'); + const recipient = stringField(action.recipient); + const amount = integer(action.amount, 0, Number.MAX_SAFE_INTEGER, 'Group pledge'); + const result = coalitionRule(() => setCoalitionPledge({ ...coalitionMembership(g), + escrow:g.coalitionEscrow!, donor:id, recipient, amount, available:uncommittedSpice(g, p) })); + requireRule(Number.isSafeInteger(p.spice + result.walletDelta) && p.spice + result.walletDelta >= 0, + 'Group pledge exceeds the donor wallet.'); + p.spice += result.walletDelta; + g.coalitionEscrow = result.escrow; + log(g, `${p.name} updated group shipment funding for ${getPlayer(g, recipient).name}.`); + return g; + } if (t === 'pledgeAid') { requireRule( ([3, 5].includes(g.phase) || @@ -44922,11 +45026,19 @@ function applyActionInner( 'Fremen reinforcements must arrive within two territories of the Great Flat.', ); } - requireRule( + let coalitionContributions: CoalitionContribution[] | undefined; + if (action.coalitionContributions !== undefined) { + requireRule(execution === 'live' && Array.isArray(action.coalitionContributions) && !noField && + !nexusEvent && !guildSecretEvent && !smuggler && !smugglerCompanion && + action.homeworldSources === undefined && (action.allyPayment === undefined || action.allyPayment === 0), + 'Group funding currently supports an ordinary physical reserve shipment.'); + coalitionContributions = action.coalitionContributions as CoalitionContribution[]; + coalitionShipmentQuote(g, p, cost, coalitionContributions); + } else requireRule( cost <= p.spice + (aidFor(g, p)?.amount ?? 0), 'Not enough spice to ship those forces.', ); - const allyPayment = contribution(g, p, cost, action.allyPayment); + const allyPayment = coalitionContributions === undefined ? contribution(g, p, cost, action.allyPayment) : 0; const shipment: PendingShipment = { ...(smuggler ? { smuggler } : {}), ...(smugglerCompanion ? { smugglerCompanion } : {}), @@ -44943,6 +45055,7 @@ function applyActionInner( elite, cost, allyPayment, + ...(coalitionContributions === undefined ? {} : {coalitionContributions}), advisors, ...(action.homeworldSources === undefined ? {} @@ -46503,6 +46616,10 @@ export function viewGame(state: Game, id: string, now?: number) { !g.nexus, spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null), allianceOffers: g.allianceOffers, + // Membership and edge ages are public; historical proposal IDs and escrow + // are not needed by board rules in the player view. + coalitionState: g.coalitionState ? {...g.coalitionState, + usedEvents:g.coalitionState.proposal ? [g.coalitionState.proposal.event] : []} : undefined, coalitionNexus: coalitionNexusAvailable(g) ? { me: id, players: g.players.map(({id: seatId, name}) => ({id: seatId, name})), @@ -46839,6 +46956,16 @@ export function viewGame(state: Game, id: string, now?: number) { ? g.aid[me.ally].amount : 0, }, + coalitionFunding: coalitionShipmentFundingAvailable(g, me) ? (() => { + const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id))!; + const pledges = g.coalitionEscrow!.pledges; + return {targets: group.members.filter(member => member !== id).map(member => { + const pledged = pledges.find(entry => entry.donor === id && entry.recipient === member)?.amount ?? 0; + return {id: member, name: getPlayer(g, member).name, pledged, + maximum: Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, uncommittedSpice(g, me)) + pledged)}; + }), incoming: pledges.filter(entry => entry.recipient === id && entry.amount > 0) + .map(entry => ({donor:entry.donor, name:getPlayer(g, entry.donor).name, amount:entry.amount})), blocked:null}; + })() : null, players: g.players.map((p) => ({ ...(p.noField ? { noField: projectRicheseNoField(p.noField, false) } diff --git a/game/reference.ts b/game/reference.ts index 8b401547..f56411d7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2781,7 +2781,8 @@ export const RULE_TOPICS: RuleTopic[] = [ id: 'alliance-funding', title: 'Fund an ally’s bid or shipment', category: 'Getting started', - coverage: 'Implemented', + coverage: 'Partial', + developmentStage: 'Prototyped', summary: 'Use Alliance funding during bidding or shipment and movement to authorize a payment contribution.', steps: [ @@ -2793,6 +2794,12 @@ export const RULE_TOPICS: RuleTopic[] = [ ], example: 'You have two spice and your ally pledges four. You may bid six; winning uses two of yours and four of theirs.', + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Ordinary pair funding is connected. Staged Basic group games support recipient-specific pledges and named contributions for ordinary physical shipments when Guild is outside the group; group bidding, Guild-allied rates and special transports remain open.', evidence: ['game/coalition-escrow.ts', 'game/coalition-funding.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Pair funding has normal table controls. Staged group shipment controls project each player’s own outgoing and incoming pledges; group play is not selectable yet.', evidence: ['components/coalition-funding.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Existing bots can participate using their own spice; group funding strategy is deferred.' }, + { area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise two donors, an outside Guild, rejected splits and individual refunds. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] }, + ], related: ['bidding', 'movement', 'faction-guild', 'card-karama'], }, { diff --git a/tests/coalition-escrow.test.ts b/tests/coalition-escrow.test.ts new file mode 100644 index 00000000..3c9d65f8 --- /dev/null +++ b/tests/coalition-escrow.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowState, + type CoalitionEscrowMembership } from '../game/coalition-escrow'; +import type { CoalitionState } from '../game/coalition-alliance'; + +const empty: CoalitionEscrowState = { authorizations: [], pledges: [], usedEpochs: [] }; +const seats = (values: [string, string | null][]) => values.map(([id, ally]) => ({id, ally})); +const unallied: CoalitionEscrowMembership = { seats: seats([['a', null], ['b', null], ['c', null]]) }; +const pair: CoalitionEscrowMembership = { seats: seats([['a', 'b'], ['b', 'a'], ['c', null]]) }; +const bcPair: CoalitionEscrowMembership = { seats: seats([['a', null], ['b', 'c'], ['c', 'b']]) }; +const tripleState: CoalitionState = {version: 1, revision: 1, proposal: null, usedEvents: [], groups: [{ + id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [ + {members: ['a', 'b'], formedTurn: 2}, {members: ['a', 'c'], formedTurn: 3}, + {members: ['b', 'c'], formedTurn: 3}, + ], +}]}; +const triple: CoalitionEscrowMembership = { + seats: seats([['a', null], ['b', null], ['c', null]]), coalitionState: tripleState, +}; +const ab = {members: ['a', 'b'] as const, epoch: 'ab1'}; +const ac = {members: ['a', 'c'] as const, epoch: 'ac1'}; +const bc = {members: ['b', 'c'] as const, epoch: 'bc1'}; + +function startedPair() { + return reconcileCoalitionEscrow({before: unallied, after: pair, escrow: empty, newAuthorizations: [ab]}).escrow; +} +function startedTriple() { + return reconcileCoalitionEscrow({before: pair, after: triple, escrow: startedPair(), newAuthorizations: [ac, bc]}).escrow; +} + +void test('each new edge receives its own epoch while the existing edge retains its epoch', () => { + const first = startedPair(), original = structuredClone(first); + const result = reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac, bc]}); + assert.deepEqual(result.escrow.authorizations, [ab, ac, bc]); + assert.deepEqual(result.escrow.usedEpochs, ['ab1', 'ac1', 'bc1']); + assert.deepEqual(result.refunds, []); + assert.deepEqual(first, original); + assert.deepEqual(JSON.parse(JSON.stringify(result)), result); + assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac]}), /fresh epoch/); + assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac, ac, bc]}), /uniquely/); + assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, + newAuthorizations: [{...ac, epoch: 'ab1'}, bc]}), /reused/); +}); + +void test('recipient-specific pledges retain independent balances and protect committed spice', () => { + const initial = startedTriple(), before = structuredClone(initial); + const one = setCoalitionPledge({...triple, escrow: initial, donor: 'a', recipient: 'b', amount: 6, available: 10}); + assert.equal(one.walletDelta, -6); + const two = setCoalitionPledge({...triple, escrow: one.escrow, donor: 'a', recipient: 'c', amount: 3, available: 4}); + assert.equal(two.walletDelta, -3); + const reduced = setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 4, available: 1, locked: 4}); + assert.equal(reduced.walletDelta, 2); + assert.deepEqual(reduced.escrow.pledges, [ + {donor: 'a', recipient: 'c', amount: 3, epoch: 'ac1'}, + {donor: 'a', recipient: 'b', amount: 4, epoch: 'ab1'}, + ]); + assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 3, available: 1, locked: 4}), /Committed/); + assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 8, available: 1}), /lacks/); + assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'a', amount: 1, available: 1}), /valid donor/); + assert.deepEqual(initial, before); +}); + +void test('a departing member receives each refund and surviving allies keep their edge and escrow', () => { + let ledger = startedTriple(); + for (const [donor, recipient, amount] of [['a', 'b', 3], ['a', 'c', 4], ['b', 'c', 5], ['c', 'a', 2]] as const) + ledger = setCoalitionPledge({...triple, escrow: ledger, donor, recipient, amount, available: 20}).escrow; + const before = structuredClone(ledger); + const result = reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: ledger, newAuthorizations: []}); + assert.deepEqual(result.escrow.authorizations, [bc]); + assert.deepEqual(result.escrow.pledges, [{donor: 'b', recipient: 'c', amount: 5, epoch: 'bc1'}]); + assert.deepEqual(result.refunds, [{donor: 'a', amount: 7}, {donor: 'c', amount: 2}]); + assert.deepEqual(ledger, before); + assert.throws(() => reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: ledger, newAuthorizations: [], + locked: [{donor: 'a', recipient: 'b', amount: 1, epoch: 'ab1'}]}), /Resolve committed/); +}); + +void test('same-turn reformation requires fresh epochs and cannot reactivate old authorization', () => { + const old = startedTriple(); + const left = reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: old, newAuthorizations: []}).escrow; + assert.throws(() => reconcileCoalitionEscrow({before: bcPair, after: triple, escrow: left, newAuthorizations: [ab, ac]}), /reused/); + const result = reconcileCoalitionEscrow({before: bcPair, after: triple, escrow: left, + newAuthorizations: [{...ab, epoch: 'ab2'}, {...ac, epoch: 'ac2'}]}); + assert.deepEqual(result.escrow.authorizations, [{...ab, epoch: 'ab2'}, {...ac, epoch: 'ac2'}, bc]); + assert.deepEqual(result.escrow.usedEpochs, ['ab1', 'ac1', 'bc1', 'ab2', 'ac2']); +}); + +void test('overflow, duplicate or malformed ledgers and forged locks fail without mutation', () => { + const huge: CoalitionEscrowState = {...startedTriple(), pledges: [ + {donor: 'a', recipient: 'b', amount: Number.MAX_SAFE_INTEGER, epoch: 'ab1'}, + {donor: 'a', recipient: 'c', amount: 1, epoch: 'ac1'}, + ]}; + const before = structuredClone(huge); + assert.throws(() => reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: huge, newAuthorizations: []}), /safe integers/); + assert.throws(() => setCoalitionPledge({...triple, escrow: huge, donor: 'a', recipient: 'c', amount: 0, available: Number.MAX_SAFE_INTEGER}), /safe integers/); + assert.throws(() => reconcileCoalitionEscrow({before: triple, after: triple, escrow: huge, newAuthorizations: [], + locked: [{donor: 'a', recipient: 'c', amount: 2, epoch: 'ac1'}]}), /match its available/); + assert.throws(() => reconcileCoalitionEscrow({before: triple, after: triple, escrow: {...huge, usedEpochs: ['ab1', 'ac1', 'ac1', 'bc1']}, newAuthorizations: []}), /unique valid/); + assert.throws(() => setCoalitionPledge({...triple, escrow: {...huge, pledges: [...huge.pledges, huge.pledges[0]]}, donor: 'a', recipient: 'b', amount: 1, available: 1}), /only one pledge/); + assert.deepEqual(huge, before); +}); diff --git a/tests/coalition-funding-controls.test.tsx b/tests/coalition-funding-controls.test.tsx new file mode 100644 index 00000000..baa45a5c --- /dev/null +++ b/tests/coalition-funding-controls.test.tsx @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CoalitionFunding, coalitionFundingContributions, coalitionPledgeAction, + type CoalitionFundingView } from '../components/coalition-funding'; + +const view: CoalitionFundingView = { + targets: [ + { id: 'b', name: 'Emperor', pledged: 2, maximum: 6 }, + { id: 'c', name: 'Fremen', pledged: 1, maximum: 5 }, + ], + incoming: [ + { donor: 'b', name: 'Emperor', amount: 3 }, + { donor: 'c', name: 'Fremen', amount: 2 }, + ], + blocked: null, +}; + +void test('each outgoing ally has a separate labelled pledge and incoming amount', () => { + const html = renderToStaticMarkup(createElement(CoalitionFunding, { + view, act() {}, onContributionsChange() {}, + })); + assert.match(html, /Pledge to Emperor · 2 spice currently set aside/); + assert.match(html, /Pledge to Fremen · 1 spice currently set aside/); + assert.match(html, /Set funding for Emperor/); + assert.match(html, /Set funding for Fremen/); + assert.match(html, /Emperor: 3 spice/); + assert.match(html, /Fremen: 2 spice/); + assert.match(html, /Use from Emperor for your next shipment/); + assert.match(html, /Use from Fremen for your next shipment/); +}); + +void test('pledge action uses the named recipient and validates whole, safe limits', () => { + const target = view.targets[0]; + assert.deepEqual(coalitionPledgeAction(target, '6', null), + { type: 'coalitionPledge', recipient: 'b', amount: 6 }); + assert.deepEqual(coalitionPledgeAction(target, '0', null), + { type: 'coalitionPledge', recipient: 'b', amount: 0 }); + for (const invalid of ['', '-1', '1.5', '7', '01', '1e2', '9007199254740992']) + assert.equal(coalitionPledgeAction(target, invalid, null), null); + assert.equal(coalitionPledgeAction(target, '2', 'Finish the pending payment.'), null); + assert.equal(coalitionPledgeAction(target, '2', null, true), null); +}); + +void test('shipment selections retain donor identity and clamp to live pledges', () => { + assert.deepEqual(coalitionFundingContributions(view.incoming, {b: 9, c: 1.9, missing: 8}), [ + { donor: 'b', amount: 3 }, { donor: 'c', amount: 1 }, + ]); + assert.deepEqual(coalitionFundingContributions(view.incoming, {b: -2, c: 0}), []); + assert.deepEqual(coalitionFundingContributions([{donor: 'b', name: 'Emperor', amount: 1}], + {b: 3, c: 2}), [{ donor: 'b', amount: 1 }]); +}); + +void test('busy or blocked controls cannot submit and omit shipment inputs when not requested', () => { + const blocked = renderToStaticMarkup(createElement(CoalitionFunding, { + view: {...view, blocked: 'Finish the pending payment.'}, act() {}, + })); + assert.match(blocked, /Finish the pending payment/); + assert.match(blocked, /disabled=""[^>]*>Set funding for Emperor/); + assert.doesNotMatch(blocked, /Use from Emperor for your next shipment/); + const busy = renderToStaticMarkup(createElement(CoalitionFunding, { view, busy: true, act() {} })); + assert.match(busy, /disabled=""[^>]*>Set funding for Fremen/); +}); diff --git a/tests/coalition-shipment-funding.test.ts b/tests/coalition-shipment-funding.test.ts new file mode 100644 index 00000000..c40e3c71 --- /dev/null +++ b/tests/coalition-shipment-funding.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const send = (game: Game, player: string, action: Action) => applyAction(game, player, action); + +/** The empty ledger and Nexus are staged because no lobby enables the group + * mode yet. The pair, invitation and unanimous group formation are actual + * actions. The subsequent quiet movement boundary and starting wallets are + * staged; each faction's force inventory remains conserved. */ +function fixture(): Game { + let game = createGame('GROUPFUND', newPlayer('a', 'Atreides', 'atreides'), false); + game.players.push(newPlayer('b', 'Emperor', 'emperor'), + newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Guild', 'guild')); + Object.assign(game, {status: 'playing', phase: 1, turn: 2, nexus: true, + order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck()}); + game.coalitionState = emptyCoalitionState(); + game = send(game, 'a', {type: 'alliance', target: 'b'}); + game = send(game, 'b', {type: 'alliance', target: 'a'}); + const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); + game = send(game, 'a', {type: 'coalitionPropose', groupId: pair.id, + entrant: 'c', expectedRevision: game.coalitionState!.revision}); + for (const id of ['b', 'c']) { + const offer = game.coalitionState!.proposal!; + game = send(game, id, {type: 'coalitionAnswer', event: offer.event, + nexus: offer.nexus, turn: offer.turn, accept: true, + expectedRevision: game.coalitionState!.revision}); + } + assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a', 'b', 'c']); + assert.ok(game.coalitionEscrow, 'Real group formation initialized the escrow.'); + Object.assign(game, {phase: 5, nexus: false, active: 'a', ready: [], + movementRemaining: ['a', 'b', 'c', 'd'], guildTimingGranted: false, + guildTimingLocked: false}); + for (const player of game.players) { + player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10; + player.hand = []; player.traitors = []; player.traitorChoices = []; + player.shipped = false; player.moved = 0; + } + game.players[0].spice = 2; + return game; +} + +function pledge(game: Game): Game { + game = send(game, 'b', {type: 'coalitionPledge', recipient: 'a', amount: 4}); + return send(game, 'c', {type: 'coalitionPledge', recipient: 'a', amount: 3}); +} +const shipment: Action = {type: 'ship', territory: 'imperial_basin', sector: 9, + amount: 3, elite: 0, allyPayment: 0, + coalitionContributions: [{donor: 'b', amount: 3}, {donor: 'c', amount: 2}]}; + +function finishResponses(game: Game): Game { + for (let step = 0; game.response && step < 12; step++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(actor, 'Every income response must have a remaining voter.'); + game = send(game, actor.id, {type: 'passResponse'}); + } + assert.equal(game.response ?? null, null); + assert.equal(game.pendingShipment ?? null, null); + return game; +} +function remaining(game: Game, donor: string, recipient = 'a'): number { + return game.coalitionEscrow!.pledges.find(pledge => pledge.donor === donor && + pledge.recipient === recipient)?.amount ?? 0; +} +function conservedForces(game: Game) { + for (const player of game.players) + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((n, value) => n + value, 0), 20); +} +function spiceIncludingEscrow(game: Game): number { + return game.players.reduce((n, player) => n + player.spice, 0) + + (game.coalitionEscrow?.pledges.reduce((n, pledge) => n + pledge.amount, 0) ?? 0); +} + +void test('two allies fund one ordinary shipment with donor-specific settlement and Guild income', () => { + let game = fixture(); + const originalSpice = spiceIncludingEscrow(game); + game = pledge(game); + assert.equal(game.players[1].spice, 6); + assert.equal(game.players[2].spice, 7); + assert.equal(remaining(game, 'b'), 4); + assert.equal(remaining(game, 'c'), 3); + assert.deepEqual(viewGame(game, 'a').coalitionFunding?.incoming.map(({donor, amount}) => ({donor, amount})), + [{donor:'b', amount:4}, {donor:'c', amount:3}]); + assert.equal(viewGame(game, 'd').coalitionFunding, null); + assert.deepEqual(viewGame(game, 'b').coalitionFunding?.incoming, []); + game = send(game, 'a', shipment); + if (game.response) assert.equal(game.response.kind, 'guildIncome'); + game = finishResponses(game); + assert.equal(game.players[0].spice, 1, 'The shipper pays the one-spice remainder.'); + assert.equal(game.players[1].spice, 6, 'Already escrowed contributions are not charged twice.'); + assert.equal(game.players[2].spice, 7); + assert.equal(remaining(game, 'b'), 1); + assert.equal(remaining(game, 'c'), 1); + assert.equal(game.players[3].spice, 16, 'The outsider Guild receives the full six-spice invoice.'); + assert.equal(game.players[0].forces['imperial_basin:9'], 3); + assert.equal(game.players[0].reserves, 17); + assert.equal(game.players[0].shipped, true); + conservedForces(game); + assert.equal(spiceIncludingEscrow(game), originalSpice); +}); + +void test('fake or insufficient donor contributions reject without payment or arrival', () => { + const game = pledge(fixture()); + for (const contributions of [ + [{donor: 'd', amount: 3}], + [{donor: 'b', amount: 5}], + [{donor: 'b', amount: 3}, {donor: 'b', amount: 1}], + [{donor: 'b', amount: 3}], // Shipper cannot afford the three-spice remainder. + ]) { + const before = structuredClone(game); + assert.throws(() => send(game, 'a', {...shipment, coalitionContributions: contributions})); + assert.deepEqual(game, before); + } +}); + +void test('unspent group shipment pledges return to their actual donors when movement ends', () => { + let game = finishResponses(send(pledge(fixture()), 'a', shipment)); + for (let step = 0; game.phase === 5 && step < 20; step++) { + if (game.response) { game = finishResponses(game); continue; } + if (game.decision?.kind === 'guildTiming') { + game = send(game, game.decision.player, {type: 'decision', take: false}); + continue; + } + assert.ok(game.active, 'A live movement turn must have an actor.'); + game = send(game, game.active, {type: 'endMovement'}); + } + assert.notEqual(game.phase, 5, 'The native movement queue completes.'); + assert.equal(game.players[1].spice, 7); + assert.equal(game.players[2].spice, 8); + assert.equal(remaining(game, 'b'), 0); + assert.equal(remaining(game, 'c'), 0); + assert.equal(spiceIncludingEscrow(game), 32); + conservedForces(game); +}); + +void test('one donor receives both separate unspent pledges at phase end', () => { + let game = fixture(); + game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3}); + game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2}); + assert.equal(game.players[1].spice, 5); + for (let step = 0; game.phase === 5 && step < 20; step++) { + if (game.response) { game = finishResponses(game); continue; } + if (game.decision?.kind === 'guildTiming') { + game = send(game, game.decision.player, {type:'decision', take:false}); + continue; + } + assert.ok(game.active); + game = send(game, game.active, {type:'endMovement'}); + } + assert.notEqual(game.phase, 5); + assert.equal(game.players[1].spice, 10); + assert.equal(game.coalitionEscrow!.pledges.length, 0); + assert.equal(spiceIncludingEscrow(game), 32); +}); + +void test('leaving refunds only broken group edges and retains the surviving pair pledge', () => { + let game = fixture(); + game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3}); + game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2}); + const oldEpoch = game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch; + // Stage a later quiet Nexus; normal phase ending would already refund these pledges. + Object.assign(game, {phase:1, nexus:true, active:null, ready:[]}); + game = send(game, 'c', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision}); + assert.equal(game.players[1].spice, 7); + assert.equal(remaining(game, 'b', 'a'), 3); + assert.equal(remaining(game, 'b', 'c'), 0); + assert.equal(game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch, oldEpoch); + assert.equal(spiceIncludingEscrow(game), 32); +}); diff --git a/tests/coalition-transit-engine.test.ts b/tests/coalition-transit-engine.test.ts index 50419d5e..e64b5766 100644 --- a/tests/coalition-transit-engine.test.ts +++ b/tests/coalition-transit-engine.test.ts @@ -4,6 +4,7 @@ import { baseDeck } from '../game/cards'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { createRicheseNoField } from '../game/richese-no-field'; import type { CoalitionState } from '../game/coalition-alliance'; +import { isAlliedShipmentVisit } from '../game/allied-separation'; /** Controlled Basic phase and force placement; the three-member group is * staged because Nexus cannot create one yet. No full-game claim is made. */ @@ -68,6 +69,15 @@ void test('ordinary movement cannot enter either group ally territory', () => { assert.deepEqual(game, before); }); +void test('the player view gives the shared transit quote the public group membership', () => { + const game = fixture(); + const view = viewGame(game, 'a'); + const own = view.players.find(player => player.id === 'a')!; + assert.equal(isAlliedShipmentVisit(view, own, 'imperial_basin'), true); + assert.equal(isAlliedShipmentVisit(view, own, 'polar_sink'), false); + assert.equal(Object.hasOwn(view, 'coalitionEscrow'), false); +}); + void test('a three-member group visitor can ship then depart without losing its pre-arrival forces', () => { const original = fixture(); let game = ship(original); assert.equal(game.players[0].spice, 4); diff --git a/tests/nexus-offer-controls.test.tsx b/tests/nexus-offer-controls.test.tsx index b6d9f137..f0f5f45d 100644 --- a/tests/nexus-offer-controls.test.tsx +++ b/tests/nexus-offer-controls.test.tsx @@ -43,6 +43,32 @@ void test('staged group mode renders its real Nexus invitation and hides conflic assert.match(markup(game, 'h', true), /disabled=""[^>]*>Accept invitation/); }); +void test('staged group shipment funding appears on the live table for the donor and recipient', () => { + let game = nexusOfferGame(); + game.coalitionState = emptyCoalitionState(); + game = applyAction(game, 'a', {type:'alliance', target:'e'}); + game = applyAction(game, 'e', {type:'alliance', target:'a'}); + const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); + game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, + entrant:'h', expectedRevision:game.coalitionState!.revision}); + for (const id of ['e', 'h']) { + const offer = game.coalitionState!.proposal!; + game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, + turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision}); + } + // Stage the next movement boundary; the group negotiation above used real actions. + Object.assign(game, {phase:5, nexus:false, active:'a', ready:[], movementRemaining:['a','e','h']}); + const emperor = game.players.find(player => player.id === 'e')!; + assert.ok(emperor.reserves > 0); + emperor.reserves--; + emperor.forces['arrakeen:10'] = (emperor.forces['arrakeen:10'] ?? 0) + 1; + game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1}); + assert.match(markup(game, 'a'), /Emperor recipient: 1 spice/); + assert.match(markup(game, 'a'), /Use from Emperor recipient for your next shipment/); + assert.match(markup(game, 'a'), /Basic allied transit: ship forces/); + assert.match(markup(game, 'e'), /Pledge to Atreides offerer · 1 spice currently set aside/); +}); + void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => { const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' }); const html = markup(g);