Support staged group shipment funding
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:14:44 +11:00
1 parent 624f95dde4
commit 34aa64cdef
13 files changed
+828 -16

No files matched your search

+100
View File
@@ -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>;
}
+18 -3
View File
@@ -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 } : {}),
};
+14 -4
View File
@@ -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
+20
View File
@@ -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
+7
View File
@@ -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
+154
View File
@@ -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<string, readonly [string, string]> {
const out = new Map<string, readonly [string, string]>();
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<string, CoalitionFundingAuthorization>();
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<string>();
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<string, number>();
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 };
}
+135 -8
View File
@@ -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<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['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<T>(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<string, number>();
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) }
+8 -1
View File
@@ -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'],
},
{
+101
View File
@@ -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);
});
+64
View File
@@ -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/);
});
+171
View File
@@ -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);
});
+10
View File
@@ -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);
+26
View File
@@ -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);