Files
Dune/game/phase-resource-quote.ts
2026-09-08 07:37:52 +10:00

132 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TECH_TOKENS, type TechId, type TechState } from './tech-tokens';
export class PhaseResourceError extends Error {
constructor(message: string) {
super(message);
this.name = 'PhaseResourceError';
}
}
function requireResources(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new PhaseResourceError(message);
}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
export type ResourceSeat = { id: string; spice: number };
export type AidRefund = { player: string; amount: number; balance: number };
export type PhaseResourceContext = {
phase: number;
players: readonly ResourceSeat[];
techTokens?: TechState | null;
aid: Readonly<Record<string, { recipient: string; amount: number }>>;
};
/** Refunds return escrow to its donor. The recipient's current alliance, funds,
* or continued eligibility is not needed to return that already committed spice. */
export function quoteAidRefunds(
players: readonly ResourceSeat[],
aid: PhaseResourceContext['aid'],
): AidRefund[] {
requireResources(
Array.isArray(players) &&
new Set(players.map((p) => p.id)).size === players.length &&
aid &&
typeof aid === 'object' &&
!Array.isArray(aid),
'Phase completion needs distinct seated donors and an aid ledger.',
);
return Object.entries(aid).map(([id, credit]) => {
const p = players.find((p) => p.id === id);
requireResources(
p &&
whole(credit?.amount) &&
whole(p.spice) &&
whole(p.spice + credit.amount),
'Phase completion needs valid seated aid refunds and balances.',
);
return {
player: id,
amount: credit.amount,
balance: p.spice + credit.amount,
};
});
}
export type PhaseResourceQuote = {
credits: (
| {
kind: 'tech';
token: TechId;
player: string;
amount: number;
balance: number;
}
| { kind: 'aid'; player: string; amount: number; balance: number }
)[];
tokenResets: TechId[];
refunds: AidRefund[];
clearAid: boolean;
balances: Record<string, number>;
};
/** Current-phase token payouts precede escrow refunds. No phase is entered,
* no token accrues income, and no already-settled phase prefix is replayed. */
export function quotePhaseResources(
g: PhaseResourceContext,
): PhaseResourceQuote {
requireResources(
whole(g.phase) &&
g.phase <= 8 &&
Array.isArray(g.players) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
g.players.every(
(p) => typeof p.id === 'string' && !!p.id && whole(p.spice),
),
'Phase resource settlement needs a current phase and valid player balances.',
);
const quote: PhaseResourceQuote = {
credits: [],
tokenResets: [],
refunds: [],
clearAid: [3, 5, 6].includes(g.phase),
balances: Object.fromEntries(g.players.map((p) => [p.id, p.spice])),
};
if (g.techTokens)
for (const rule of TECH_TOKENS) {
if (rule.phase !== g.phase) continue;
const token = g.techTokens[rule.id];
requireResources(
token &&
typeof token === 'object' &&
whole(token.spice) &&
(token.owner === null || g.players.some((p) => p.id === token.owner)),
'Current-phase technology income needs valid token custody and spice.',
);
if (!token.owner || !token.spice) continue;
const balance = quote.balances[token.owner] + token.spice;
requireResources(
whole(balance),
'Technology settlement would overflow the owner’s spice balance.',
);
quote.balances[token.owner] = balance;
quote.credits.push({
kind: 'tech',
token: rule.id,
player: token.owner,
amount: token.spice,
balance,
});
quote.tokenResets.push(rule.id);
}
if (quote.clearAid) {
quote.refunds = quoteAidRefunds(
g.players.map((p) => ({ id: p.id, spice: quote.balances[p.id] })),
g.aid,
);
for (const refund of quote.refunds) {
quote.credits.push({ kind: 'aid', ...refund });
quote.balances[refund.player] = refund.balance;
}
}
return quote;
}