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
+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) }
|
||||
|
||||
Reference in new issue
Block a user