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

+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'],
},
{