Extend Truthtrance bidding and prepare alliance controls
Verify and publish checkpoint container / container (push) Successful in 11m0s
Verify and publish checkpoint container / container (push) Successful in 11m0s
This commit is contained in:
1 parent
77376c39d6
commit
e529cc2d7a
10 files changed
+525
-9
No files matched your search
@@ -0,0 +1,153 @@
|
||||
import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance';
|
||||
|
||||
/** Already-escrowed spice, explicitly pledged by one donor to one ally.
|
||||
* Wallet debits, pledge authorization, commitments and refunds remain the
|
||||
* engine's responsibility; this ledger does not create or hold spice itself. */
|
||||
export type CoalitionPledge = Readonly<{
|
||||
donor: string;
|
||||
recipient: string;
|
||||
amount: number;
|
||||
/** The particular alliance edge under which this escrow was authorized. */
|
||||
epoch: string;
|
||||
}>;
|
||||
export type CoalitionFundingAuthorization = Readonly<{
|
||||
/** Sorted pair of currently allied seats. */
|
||||
members: readonly [string, string];
|
||||
epoch: string;
|
||||
}>;
|
||||
export type CoalitionContribution = Readonly<{ donor: string; amount: number }>;
|
||||
export type CoalitionFundingInput = Readonly<{
|
||||
seats: readonly CoalitionSeat[];
|
||||
coalitionState?: CoalitionState;
|
||||
purpose: 'bid' | 'shipment';
|
||||
payer: string;
|
||||
cost: number;
|
||||
/** Spendable payer spice after other obligations have been reserved. */
|
||||
ownAvailable: number;
|
||||
/** Authoritative current edge authorizations, never supplied by a client. */
|
||||
authorizations: readonly CoalitionFundingAuthorization[];
|
||||
pledges: readonly CoalitionPledge[];
|
||||
contributions: readonly CoalitionContribution[];
|
||||
}>;
|
||||
export type CoalitionFundingQuote = {
|
||||
purpose: 'bid' | 'shipment';
|
||||
payer: string;
|
||||
cost: number;
|
||||
own: number;
|
||||
contributions: { donor: string; amount: number }[];
|
||||
};
|
||||
|
||||
export class CoalitionFundingError extends Error {
|
||||
constructor(message: string) { super(message); this.name = 'CoalitionFundingError'; }
|
||||
}
|
||||
function requireFunding(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new CoalitionFundingError(message);
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const amount = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
const pairKey = (donor: string, recipient: string) => JSON.stringify([donor, recipient]);
|
||||
const edgeKey = (a: string, b: string) => JSON.stringify([a, b].sort());
|
||||
const epoch = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value;
|
||||
|
||||
/** The engine must mint a globally fresh, never-reused epoch on edge formation,
|
||||
* preserve it while that edge remains allied, and revoke it atomically on a
|
||||
* break. A re-formed edge must receive a different epoch even in the same turn.
|
||||
* Legacy pairs need an issued epoch before funding may use this API. */
|
||||
function fundingEdges(input: Pick<CoalitionFundingInput, 'seats' | 'coalitionState' | 'authorizations'>) {
|
||||
const groups = coalitionGroups(input.seats, input.coalitionState);
|
||||
requireFunding(Array.isArray(input.authorizations), 'Supply current authoritative alliance funding epochs.');
|
||||
const expected = new Set<string>();
|
||||
for (const group of groups) for (const edge of group.edges)
|
||||
expected.add(edgeKey(edge.members[0], edge.members[1]));
|
||||
const current = new Map<string, string>(), usedEpochs = new Set<string>();
|
||||
for (const authorization of input.authorizations) {
|
||||
requireFunding(record(authorization) && Array.isArray(authorization.members) &&
|
||||
authorization.members.length === 2 && typeof authorization.members[0] === 'string' &&
|
||||
typeof authorization.members[1] === 'string' && authorization.members[0] < authorization.members[1] &&
|
||||
epoch(authorization.epoch), 'Each funding epoch needs an ordered pair of allies.');
|
||||
const key = edgeKey(authorization.members[0], authorization.members[1]);
|
||||
requireFunding(expected.has(key) && !current.has(key) && !usedEpochs.has(authorization.epoch),
|
||||
'Funding epochs must uniquely match current alliance edges exactly.');
|
||||
current.set(key, authorization.epoch);
|
||||
usedEpochs.add(authorization.epoch);
|
||||
}
|
||||
requireFunding(current.size === expected.size, 'Every current alliance edge needs an authoritative funding epoch.');
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Separate stale escrow for refund after a break or epoch replacement.
|
||||
* The engine must resolve or cancel any affected committed payments and
|
||||
* atomically refund every returned stale record before using the retained
|
||||
* ledger. No wallet or pledge is changed by this pure function. */
|
||||
export function reconcileCoalitionPledges(input: Pick<CoalitionFundingInput,
|
||||
'seats' | 'coalitionState' | 'authorizations' | 'pledges'>): {
|
||||
pledges: CoalitionPledge[]; refunds: CoalitionPledge[];
|
||||
} {
|
||||
requireFunding(record(input) && Array.isArray(input.pledges), 'Supply the complete pledged escrow ledger.');
|
||||
const current = fundingEdges(input);
|
||||
const seated = new Set(input.seats.map(seat => seat.id));
|
||||
const seen = new Set<string>(), pledges: CoalitionPledge[] = [], refunds: CoalitionPledge[] = [];
|
||||
for (const pledge of input.pledges) {
|
||||
requireFunding(record(pledge) && typeof pledge.donor === 'string' && typeof pledge.recipient === 'string' &&
|
||||
pledge.donor !== pledge.recipient && seated.has(pledge.donor) && seated.has(pledge.recipient) &&
|
||||
amount(pledge.amount) && epoch(pledge.epoch), 'Every pledge needs seated parties, safe escrow and an epoch.');
|
||||
const key = pairKey(pledge.donor, pledge.recipient);
|
||||
requireFunding(!seen.has(key), 'Each donor may have only one pledge for a recipient.');
|
||||
seen.add(key);
|
||||
(current.get(edgeKey(pledge.donor, pledge.recipient)) === pledge.epoch ? pledges : refunds).push({
|
||||
donor: pledge.donor, recipient: pledge.recipient, amount: pledge.amount, epoch: pledge.epoch,
|
||||
});
|
||||
}
|
||||
return { pledges, refunds };
|
||||
}
|
||||
|
||||
/** Validate an exact declared split against current coalition membership and
|
||||
* each recipient-specific escrow. The group-funding and allocation procedure
|
||||
* is an online interpretation; the printed rule permits allies to help pay.
|
||||
* This is not an authorization, reservation, or a promise of future funds. */
|
||||
export function quoteCoalitionFunding(input: CoalitionFundingInput): CoalitionFundingQuote {
|
||||
requireFunding(record(input) && (input.purpose === 'bid' || input.purpose === 'shipment') &&
|
||||
amount(input.cost) && amount(input.ownAvailable) && Array.isArray(input.pledges) &&
|
||||
Array.isArray(input.contributions), 'Choose a valid bid or shipment and safe spice amounts.');
|
||||
requireFunding(typeof input.payer === 'string' && input.seats.some(seat => seat.id === input.payer),
|
||||
'Choose a seated payer.');
|
||||
const reconciled = reconcileCoalitionPledges(input);
|
||||
requireFunding(reconciled.refunds.length === 0, 'Reconcile and refund stale pledge escrow before payment.');
|
||||
const pledges = new Map<string, CoalitionPledge>();
|
||||
for (const pledge of reconciled.pledges) pledges.set(pairKey(pledge.donor, pledge.recipient), pledge);
|
||||
const donors = new Set<string>();
|
||||
const contributions: { donor: string; amount: number }[] = [];
|
||||
let own = input.cost;
|
||||
for (const share of input.contributions) {
|
||||
requireFunding(record(share) && typeof share.donor === 'string' && amount(share.amount) && share.amount > 0 &&
|
||||
!donors.has(share.donor), 'Declare each donating ally once with a positive safe amount.');
|
||||
const pledge = pledges.get(pairKey(share.donor, input.payer));
|
||||
requireFunding(pledge && share.amount <= pledge.amount,
|
||||
'Each contribution needs enough current escrow pledged to this payer.');
|
||||
requireFunding(share.amount <= own, 'Contributions cannot exceed the cost.');
|
||||
own -= share.amount;
|
||||
donors.add(share.donor);
|
||||
contributions.push({ donor: share.donor, amount: share.amount });
|
||||
}
|
||||
requireFunding(own <= input.ownAvailable, 'The payer cannot cover the remaining cost.');
|
||||
return { purpose: input.purpose, payer: input.payer, cost: input.cost, own, contributions };
|
||||
}
|
||||
|
||||
/** Revalidate the complete current input at the moment of payment and return
|
||||
* a new pledge ledger. The engine must debit quote.own and install this ledger
|
||||
* in the same authoritative action; recipient routing and replay protection
|
||||
* remain native transaction responsibilities. Never apply a stale quote alone. */
|
||||
export function settleCoalitionFunding(input: CoalitionFundingInput): {
|
||||
quote: CoalitionFundingQuote;
|
||||
pledges: CoalitionPledge[];
|
||||
} {
|
||||
const quote = quoteCoalitionFunding(input);
|
||||
const debits = new Map(quote.contributions.map(share => [share.donor, share.amount]));
|
||||
const pledges = input.pledges.map(pledge => ({ ...pledge,
|
||||
amount: pledge.amount - (pledge.recipient === quote.payer ? debits.get(pledge.donor) ?? 0 : 0),
|
||||
}));
|
||||
return { quote, pledges };
|
||||
}
|
||||
+2
-2
@@ -31695,7 +31695,7 @@ function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeade
|
||||
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
|
||||
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
|
||||
];
|
||||
if (q.turn !== g.turn || q.target !== p.id || ![2, 4, 5].includes(g.phase) ||
|
||||
if (q.turn !== g.turn || q.target !== p.id || ![2, 3, 4, 5].includes(g.phase) ||
|
||||
(g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
|
||||
(row.status === 'waiting' || row.status === 'bound')) ||
|
||||
otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
|
||||
@@ -45802,7 +45802,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
|
||||
? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && [2, 4, 5].includes(g.phase) &&
|
||||
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && [2, 3, 4, 5].includes(g.phase) &&
|
||||
!g.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
|
||||
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
|
||||
!g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard
|
||||
|
||||
+1
-1
@@ -3305,7 +3305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'In a quiet Basic Charity, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
|
||||
'In a quiet Basic Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
|
||||
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
||||
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
|
||||
|
||||
+2
-2
@@ -602,11 +602,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
|
||||
v.turn === g.turn && typeof v.leader === 'string' &&
|
||||
leaders(target.faction).some(leader => leader.id === v.leader) &&
|
||||
g.status === 'playing' && !g.advanced && [2, 4, 5].includes(g.phase) && !g.expansions.length &&
|
||||
g.status === 'playing' && !g.advanced && [2, 3, 4, 5].includes(g.phase) && !g.expansions.length &&
|
||||
!g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
|
||||
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose a native leader in a quiet Basic Charity, Revival or Shipment and Movement window.');
|
||||
'Choose a native leader in a quiet Basic Charity, Bidding, Revival or Shipment and Movement window.');
|
||||
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
|
||||
}
|
||||
if (v.kind === 'battlePlan') {
|
||||
|
||||
Reference in new issue
Block a user