Files
Dune/game/bribe-expression-promises.ts

275 lines
14 KiB
TypeScript

import { bribePromiseRequirements, liveBribePromises, type BribePromise } from './bribe-promises';
export { bribePromiseModeSupported as bribeExpressionPromiseModeSupported } from './bribe-promises';
export type BribeExpression = { kind: 'payment'; recipient: string; minimum: number } |
{ kind: 'and' | 'or'; terms: BribeExpression[] };
export type BribeExpressionClaim = { turn: number; claim: BribeExpression };
export type BribeExpressionPayment = { recipient: string; amount: number };
export type BribeExpressionProgress = BribeExpressionPayment[];
export type BribeExpressionPromise = BribeExpressionClaim & {
event: string; player: string; asker: string; answer: boolean;
/** Matching actual ordinary payments after this answer, excluding their later receipt. */
progress: BribeExpressionProgress;
fulfilled?: boolean; released?: boolean; order?: number;
};
export type BribeExpressionBranchLeaf = {
recipient: string; minimum: number; progress: number; answer: boolean;
};
export const BRIBE_EXPRESSION_MAX_DEPTH = 4;
export const BRIBE_EXPRESSION_MAX_LEAVES = 16;
export class BribeExpressionPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'BribeExpressionPromiseError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new BribeExpressionPromiseError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const positive = (value: unknown): value is number => whole(value) && value > 0;
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
/** Software limits match revival expressions; spice minima have no artificial cap.
* Every subtree is parsed before an answer can short-circuit evaluation. */
export function parseBribeExpression(value: unknown): BribeExpression {
let leaves = 0;
const parse = (input: unknown, depth: number): BribeExpression => {
check(object(input) && depth <= BRIBE_EXPRESSION_MAX_DEPTH,
'Use an ordinary bribe expression within four nested groups.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= BRIBE_EXPRESSION_MAX_LEAVES,
'Join two or more ordinary bribe statements with AND or OR.');
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
}
check(++leaves <= BRIBE_EXPRESSION_MAX_LEAVES,
'Use at most sixteen ordinary bribe statements.');
check(input.kind === 'payment' && keys(input, 'kind,minimum,recipient') &&
identifier(input.recipient) && positive(input.minimum),
'Choose an exact ordinary bribe recipient and a positive safe whole spice minimum.');
return { kind: 'payment', recipient: input.recipient, minimum: input.minimum };
};
return parse(value, 0);
}
export function parseBribeExpressionClaim(value: unknown): BribeExpressionClaim {
check(object(value) && keys(value, 'claim,turn') && positive(value.turn),
'Choose the current turn and an ordinary bribe expression.');
return { turn: value.turn, claim: parseBribeExpression(value.claim) };
}
export function bribeExpressionText(expression: BribeExpression,
recipientName: (id: string) => string = id => id): string {
const text = (node: BribeExpression): string => 'terms' in node ?
`(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})` :
`pay at least ${node.minimum} spice to ${recipientName(node.recipient)} in ordinary bribes`;
return text(parseBribeExpression(expression));
}
export function bribeExpressionRecipients(expression: BribeExpression): string[] {
const recipients = new Set<string>();
const visit = (node: BribeExpression): void => {
if ('terms' in node) node.terms.forEach(visit);
else recipients.add(node.recipient);
};
visit(parseBribeExpression(expression));
return [...recipients];
}
function validateProgress(value: unknown): asserts value is BribeExpressionProgress {
check(Array.isArray(value), 'Retain cumulative ordinary bribe payments by exact recipient.');
const recipients = new Set<string>();
for (const payment of value) {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) &&
whole(payment.amount) && !recipients.has(payment.recipient),
'Retain unique recipients and safe whole cumulative ordinary bribe amounts.');
recipients.add(payment.recipient);
}
}
function evaluate(expression: BribeExpression, progress: readonly BribeExpressionPayment[]): boolean {
if ('terms' in expression) return expression.kind === 'and' ?
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
return (progress.find(payment => payment.recipient === expression.recipient)?.amount ?? 0) >= expression.minimum;
}
export function evaluateBribeExpression(expression: BribeExpression, progress: readonly BribeExpressionPayment[]): boolean {
const parsed = parseBribeExpression(expression);
validateProgress(progress);
return evaluate(parsed, progress);
}
export function liveBribeExpressionPromises(promises: readonly BribeExpressionPromise[], player: string,
turn: number): BribeExpressionPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function readPromise(promise: BribeExpressionPromise): BribeExpression {
check(object(promise), 'The ordinary bribe expression commitment record is invalid.');
const { claim } = parseBribeExpressionClaim({ turn: promise.turn, claim: promise.claim });
validateProgress(promise.progress);
const recipients = bribeExpressionRecipients(claim);
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
!(promise.fulfilled && promise.released) && (promise.order === undefined || positive(promise.order)) &&
promise.progress.every(payment => recipients.includes(payment.recipient)),
'Retain a definite whole bribe answer and its own matching future payment progress.');
return claim;
}
/** Only the completed ordinary payer action calls this function. Past payments,
* gifts, special transfers and the later Mentat receipt never call it again. */
export function recordBribeExpressionPayment(promises: readonly BribeExpressionPromise[], player: string,
turn: number, payment: BribeExpressionPayment): BribeExpressionPromise[] {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount),
'Retain the exact recipient and positive safe whole ordinary bribe payment.');
const updates = new Map<BribeExpressionPromise, BribeExpressionPromise>();
for (const promise of liveBribeExpressionPromises(promises, player, turn)) {
const claim = readPromise(promise);
if (!bribeExpressionRecipients(claim).includes(payment.recipient)) continue;
const old = promise.progress.find(entry => entry.recipient === payment.recipient)?.amount ?? 0;
const next = old + payment.amount;
check(Number.isSafeInteger(next), 'The cumulative ordinary bribe amount exceeds the safe integer range.');
const progress = promise.progress.map(entry => ({ ...entry }));
const entry = progress.find(entry => entry.recipient === payment.recipient);
if (entry) entry.amount = next;
else progress.push({ recipient: payment.recipient, amount: next });
const satisfied = evaluate(claim, progress);
check(promise.answer || !satisfied,
'This payment conflicts with your whole Truthtrance ordinary bribe No answer.');
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
}
return promises.map(promise => updates.get(promise) ?? promise);
}
function mergeBranches(left: BribeExpressionBranchLeaf[], right: BribeExpressionBranchLeaf[]): BribeExpressionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
const same = merged.find(other => other.recipient === leaf.recipient && other.answer === leaf.answer);
if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
else merged.push({ ...leaf });
const yes = merged.find(other => other.recipient === leaf.recipient && other.answer);
const no = merged.find(other => other.recipient === leaf.recipient && !other.answer);
if (yes && no && yes.minimum >= no.minimum) return null;
}
return merged;
}
/** Whole No uses De Morgan; a No to AND leaves alternatives. No route is stored,
* and each leaf retains its own answer interval for later joint composition. */
export function* bribeExpressionBranches(promise: BribeExpressionPromise): Generator<BribeExpressionBranchLeaf[]> {
const expression = readPromise(promise);
if (promise.fulfilled || promise.released) { yield []; return; }
function* alternatives(node: BribeExpression, answer: boolean): Generator<BribeExpressionBranchLeaf[]> {
if (node.kind === 'payment') {
if (evaluate(node, promise.progress)) { if (answer) yield []; return; }
yield [{ recipient: node.recipient, minimum: node.minimum,
progress: promise.progress.find(entry => entry.recipient === node.recipient)?.amount ?? 0, answer }];
return;
}
const conjunction = node.kind === 'and' ? answer : !answer;
if (!conjunction) {
for (const term of node.terms) yield* alternatives(term, answer);
return;
}
const terms = node.terms;
function* combine(index: number, prefix: BribeExpressionBranchLeaf[]): Generator<BribeExpressionBranchLeaf[]> {
if (index === terms.length) { yield prefix; return; }
for (const suffix of alternatives(terms[index], answer)) {
const merged = mergeBranches(prefix, suffix);
if (merged) yield* combine(index + 1, merged);
}
}
yield* combine(0, []);
}
if (promise.answer && evaluate(expression, promise.progress)) { yield []; return; }
yield* alternatives(expression, promise.answer);
}
type Bounds = Map<string, { minimum: number; maximum: number }>;
function addBound(bounds: Bounds, recipient: string, minimum: number, maximum: number): Bounds | null {
const old = bounds.get(recipient) ?? { minimum: 0, maximum: Number.MAX_SAFE_INTEGER };
const next = { minimum: Math.max(old.minimum, minimum), maximum: Math.min(old.maximum, maximum) };
if (next.minimum > next.maximum) return null;
const merged = new Map(bounds);
merged.set(recipient, next);
return merged;
}
function addLeaf(bounds: Bounds, leaf: BribeExpressionBranchLeaf): Bounds | null {
return addBound(bounds, leaf.recipient, leaf.answer ? Math.max(0, leaf.minimum - leaf.progress) : 0,
leaf.answer ? Number.MAX_SAFE_INTEGER - leaf.progress : leaf.minimum - leaf.progress - 1);
}
/** Minimal additional payments per legal route across scalar and whole answers.
* Same-recipient requirements share payments; distinct recipients spend separate
* spice. The caller owns purse availability and actual ordinary payment timing. */
export function* bribeCompletionCandidates(atomics: readonly BribePromise[], expressions: readonly BribeExpressionPromise[],
player: string, turn: number): Generator<BribeExpressionPayment[]> {
if (bribePromiseRequirements(atomics, player, turn) === null) return;
let initial: Bounds | null = new Map();
for (const promise of liveBribePromises(atomics, player, turn)) {
initial = addLeaf(initial, promise);
if (!initial) return;
}
const live = liveBribeExpressionPromises(expressions, player, turn);
for (const promise of live) {
readPromise(promise);
// An allocation to another answer must also fit nonselected-branch ledgers.
for (const payment of promise.progress) {
initial = addBound(initial, payment.recipient, 0, Number.MAX_SAFE_INTEGER - payment.amount);
if (!initial) return;
}
}
const seen = new Set<string>();
function* combine(index: number, bounds: Bounds): Generator<BribeExpressionPayment[]> {
if (index === live.length) {
const payments: BribeExpressionPayment[] = [];
let total = 0;
for (const [recipient, { minimum }] of bounds) {
total += minimum;
if (!Number.isSafeInteger(total)) return;
if (minimum > 0) payments.push({ recipient, amount: minimum });
}
const key = JSON.stringify([...payments].sort((left, right) => left.recipient.localeCompare(right.recipient)));
if (!seen.has(key)) { seen.add(key); yield payments; }
return;
}
for (const branch of bribeExpressionBranches(live[index])) {
let next: Bounds | null = bounds;
for (const leaf of branch) {
next = addLeaf(next, leaf);
if (!next) break;
}
if (next) yield* combine(index + 1, next);
}
}
yield* combine(0, initial);
}
export function validateBribeExpressionPromises(promises: readonly BribeExpressionPromise[], players: readonly string[]): void {
check(Array.isArray(promises), 'The ordinary bribe expression commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
const claim = readPromise(promise);
const satisfied = evaluate(claim, promise.progress);
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
bribeExpressionRecipients(claim).every(recipient => players.includes(recipient) && recipient !== promise.player) &&
(promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
'The ordinary bribe expression lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}