162 lines
8.4 KiB
TypeScript
162 lines
8.4 KiB
TypeScript
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
|
import { FACTIONS } from './catalog';
|
|
|
|
export type BribeClaim = { turn: number; recipient: string; minimum: number };
|
|
export type BribePromise = BribeClaim & {
|
|
event: string;
|
|
player: string;
|
|
asker: string;
|
|
answer: boolean;
|
|
/** Matching ordinary payments after this answer, before their later receipt. */
|
|
progress: number;
|
|
fulfilled?: boolean;
|
|
released?: boolean;
|
|
};
|
|
type BribePayment = { recipient: string; amount: number };
|
|
|
|
export class BribePromiseError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'BribePromiseError';
|
|
}
|
|
}
|
|
|
|
const identifier = (value: unknown): value is string =>
|
|
typeof value === 'string' && value.length > 0 && value === value.trim();
|
|
const positiveInteger = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
|
|
|
export function parseBribeClaim(value: unknown): BribeClaim {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
throw new BribePromiseError('Choose a current-turn ordinary bribe question.');
|
|
const input = value as Record<string, unknown>;
|
|
if (!positiveInteger(input.turn) || !identifier(input.recipient) || !positiveInteger(input.minimum))
|
|
throw new BribePromiseError('Choose the current turn, exact recipient and a positive safe whole bribe minimum.');
|
|
return { turn: input.turn, recipient: input.recipient, minimum: input.minimum };
|
|
}
|
|
|
|
/** The ordinary, grouped and mixed bribe families share this admission. The
|
|
* printed Kull extension is deliberately limited to the existing fresh Basic
|
|
* CHOAM, Ixian, Tleilaxu, Ecaz, Moritani and classic roster; other previews keep their own composition boundaries.
|
|
* This quote grants no start permission or future unowned funds. */
|
|
export function bribePromiseModeSupported(game: NativePromiseScope & {
|
|
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
|
|
richeseBetrayalPreview?: unknown; guildBetrayalPreview?: unknown;
|
|
nexusIxianReplacementPreview?: unknown; nexusIxianBetrayalPreview?: unknown;
|
|
nexusHarkonnenBetrayalPreview?: unknown; moritaniAssassinatePreview?: unknown;
|
|
mobileStronghold?: unknown;
|
|
}, options: {allowKullRichese?: boolean} = {}): boolean {
|
|
if (!nativePromiseScopeSupported(game) || game.nexusKullPreview ||
|
|
game.richeseBetrayalPreview) return false;
|
|
if (game.semutaPreview) return game.semutaPreview === true && !game.advanced &&
|
|
!game.kullPreview && game.expansions.includes('choam') &&
|
|
game.players.some(player => player.faction === 'richese') &&
|
|
!game.guildBetrayalPreview && !game.nexusIxianReplacementPreview &&
|
|
!game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview &&
|
|
!game.moritaniAssassinatePreview;
|
|
if (!game.kullPreview) return true;
|
|
return game.kullPreview === true && game.advanced === false && (game.expansions.length === 2 ||
|
|
game.expansions.length === 3 && game.expansions.includes('ecaz')) &&
|
|
game.expansions.includes('choam') && game.expansions.includes('ix') &&
|
|
game.players.some(player => player.faction === 'choam') &&
|
|
new Set(game.players.map(player => player.faction)).size === game.players.length &&
|
|
game.players.every(player => player.faction === 'choam' || player.faction === 'ixians' ||
|
|
options.allowKullRichese && player.faction === 'richese' ||
|
|
player.faction === 'tleilaxu' || game.expansions.includes('ecaz') &&
|
|
(player.faction === 'ecaz' || player.faction === 'moritani') ||
|
|
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')) &&
|
|
!game.guildBetrayalPreview && !game.nexusIxianReplacementPreview &&
|
|
!game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview &&
|
|
!game.moritaniAssassinatePreview &&
|
|
(!game.mobileStronghold || game.players.some(player => player.faction === 'ixians'));
|
|
}
|
|
|
|
export function bribeClaimText(claim: BribeClaim, recipientName: (id: string) => string = id => id): string {
|
|
return `pay at least ${claim.minimum} spice to ${recipientName(claim.recipient)} in ordinary bribes from now until this turn ends`;
|
|
}
|
|
|
|
export function liveBribePromises(promises: readonly BribePromise[], player: string, turn: number): BribePromise[] {
|
|
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
|
!promise.fulfilled && !promise.released);
|
|
}
|
|
|
|
function validateProgress(promise: BribePromise): void {
|
|
parseBribeClaim(promise);
|
|
if (typeof promise.answer !== 'boolean' || !Number.isSafeInteger(promise.progress) || promise.progress < 0 ||
|
|
!optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) ||
|
|
(promise.fulfilled && promise.released) ||
|
|
(!promise.answer && promise.progress >= promise.minimum) ||
|
|
(promise.answer && !promise.released && !!promise.fulfilled !== (promise.progress >= promise.minimum)))
|
|
throw new BribePromiseError('The bribe commitment has invalid cumulative answer progress.');
|
|
}
|
|
|
|
/** Provisional cumulative composition: only the actual payer's completed
|
|
* ordinary payments count. Prior payments, card effects, gifts and the later
|
|
* Mentat receipt do not call this function or add progress again. */
|
|
export function recordPromisedBribe(promises: readonly BribePromise[], player: string, turn: number,
|
|
payment: BribePayment): BribePromise[] {
|
|
if (!payment || typeof payment !== 'object' || Array.isArray(payment) ||
|
|
!identifier(payment.recipient) || !positiveInteger(payment.amount))
|
|
throw new BribePromiseError('Retain the exact recipient and positive safe whole ordinary bribe payment.');
|
|
const progress = new Map<BribePromise, number>();
|
|
for (const promise of liveBribePromises(promises, player, turn)) {
|
|
if (promise.recipient !== payment.recipient) continue;
|
|
validateProgress(promise);
|
|
const next = promise.progress + payment.amount;
|
|
if (!Number.isSafeInteger(next))
|
|
throw new BribePromiseError('The cumulative bribe amount exceeds the safe integer range.');
|
|
if (!promise.answer && next >= promise.minimum)
|
|
throw new BribePromiseError('This payment conflicts with your cumulative Truthtrance No answer.');
|
|
progress.set(promise, next);
|
|
}
|
|
return promises.map(promise => {
|
|
const next = progress.get(promise);
|
|
return next === undefined ? promise : {
|
|
...promise, progress: next,
|
|
...(promise.answer && next >= promise.minimum ? { fulfilled: true } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Minimal final additional ordinary payments, grouped by exact recipient.
|
|
* Overlapping Yes answers share the same future payment; different recipients
|
|
* each require their own spice. No caps remain binding after smaller payments. */
|
|
export function bribePromiseRequirements(promises: readonly BribePromise[], player: string, turn: number): BribePayment[] | null {
|
|
const live = liveBribePromises(promises, player, turn);
|
|
const required = new Map<string, number>();
|
|
for (const promise of live) {
|
|
validateProgress(promise);
|
|
if (promise.answer)
|
|
required.set(promise.recipient, Math.max(required.get(promise.recipient) ?? 0, promise.minimum - promise.progress));
|
|
}
|
|
for (const promise of live) {
|
|
const amount = required.get(promise.recipient) ?? 0;
|
|
if (!promise.answer && amount >= promise.minimum - promise.progress) return null;
|
|
if (!Number.isSafeInteger(promise.progress + amount)) return null;
|
|
}
|
|
let total = 0;
|
|
const payments: BribePayment[] = [];
|
|
for (const [recipient, amount] of required) {
|
|
total += amount;
|
|
if (!Number.isSafeInteger(total)) return null;
|
|
if (amount > 0) payments.push({ recipient, amount });
|
|
}
|
|
return payments;
|
|
}
|
|
|
|
export function validateBribePromises(promises: readonly BribePromise[], players: readonly string[]): void {
|
|
if (!Array.isArray(promises)) throw new BribePromiseError('The bribe commitment record is invalid.');
|
|
const events = new Set<string>();
|
|
for (const promise of promises) {
|
|
parseBribeClaim(promise);
|
|
if (!identifier(promise.event) || events.has(promise.event) ||
|
|
!identifier(promise.player) || !identifier(promise.asker) ||
|
|
!players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.recipient) ||
|
|
promise.player === promise.asker || promise.player === promise.recipient)
|
|
throw new BribePromiseError('The bribe commitment lost its original event or participants.');
|
|
validateProgress(promise);
|
|
events.add(promise.event);
|
|
}
|
|
}
|