Support staged group shipment funding
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
624f95dde4
commit
34aa64cdef
13 files changed
+828
-16
No files matched your search
@@ -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
@@ -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
@@ -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'],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user