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

+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) }