Files
Dune/game/turn-action-promises.ts
T

409 lines
21 KiB
TypeScript
Raw 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 { TERRITORIES } from './board';
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
import {
MixedActionQuestionError,
parseMixedActionExpression,
type CompiledMixedAction,
type MixedActionExpression,
} from './mixed-action-question';
import {
parseTravelActionClaim,
TravelPromiseError,
type TravelActionClaim,
type TravelBranchLeaf,
type TravelEvent,
} from './travel-expression-promises';
import type { TruthFact } from './truthtrance';
export const TURN_ACTION_MAX_LEAVES = 16;
export const TURN_ACTION_MAX_DEPTH = 4;
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number };
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
export type TurnActionPayment = { recipient: string; amount: number };
export type TurnActionBranchLeaf = TravelBranchLeaf |
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean };
export type TurnActionPromise = TurnActionClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
/** Facts are frozen by the game-aware compiler when this answer is accepted. */
compiled: CompiledMixedAction<TurnActionLeaf>;
shipment: { pending: boolean; event: TravelEvent | null };
/** Largest physical ground group after this answer, never a sum of groups. */
progress: { territory: string; maximum: number }[];
/** Actual completed ordinary payer actions after this answer. */
payments: TurnActionPayment[];
transportClosed: boolean;
paymentClosed: boolean;
fulfilled: boolean;
released: boolean;
};
export class TurnActionError extends Error {
constructor(message: string) {
super(message);
this.name = 'TurnActionError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new TurnActionError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
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 physical = (value: unknown): value is number => whole(value) && value <= 20;
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Public leaves carry only their native destination/recipient and minimum. */
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
check(object(value), 'Choose one future reserve shipment, ground movement or ordinary bribe payment.');
try {
if (value.kind !== 'payment') return parseTravelActionClaim(value);
check(keys(value, 'kind,minimum,recipient'), 'Supply only the ordinary payment recipient and minimum.');
const claim = parseBribeClaim({ turn: 1, recipient: value.recipient, minimum: value.minimum });
return { kind: 'payment', recipient: claim.recipient, minimum: claim.minimum };
} catch (error) {
if (error instanceof TravelPromiseError || error instanceof BribePromiseError)
throw new TurnActionError(error.message);
throw error;
}
}
/** The engine owns game-aware fact decoding; retain and bound its original tree. */
function storedFact(value: unknown, depth = 0): TruthFact {
check(object(value) && identifier(value.kind) && depth <= TURN_ACTION_MAX_DEPTH &&
value.kind !== 'constant' && value.kind !== 'action' && value.kind !== 'fact',
'Retain the original current-fact statement.');
if (value.kind === 'and' || value.kind === 'or') {
check(keys(value, 'kind,terms') && Array.isArray(value.terms) &&
value.terms.length >= 2 && value.terms.length <= TURN_ACTION_MAX_LEAVES,
'Retain a bounded original current-fact group.');
return { kind: value.kind, terms: value.terms.map(term => storedFact(term, depth + 1)) };
}
return value as unknown as TruthFact;
}
function actionKey(action: TurnActionLeaf): string {
return JSON.stringify(action.kind === 'payment'
? [action.kind, action.recipient, action.minimum] : [action.kind, action.territory, action.minimum]);
}
function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledMixedAction<TurnActionLeaf> {
let leaves = 0;
const read = (input: unknown, depth: number): CompiledMixedAction<TurnActionLeaf> => {
check(object(input) && depth <= TURN_ACTION_MAX_DEPTH,
'Retain the bounded private answer-time turn-action expression.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= TURN_ACTION_MAX_LEAVES,
'Retain the private answer-time AND/OR turn-action groups.');
return { kind: input.kind, terms: input.terms.map(term => read(term, depth + 1)) };
}
check(++leaves <= TURN_ACTION_MAX_LEAVES, 'Retain at most sixteen private turn-action clauses.');
if (input.kind === 'constant') {
check(keys(input, 'kind,value') && typeof input.value === 'boolean',
'Retain a definite private frozen fact answer.');
return { kind: 'constant', value: input.value };
}
check(input.kind === 'action' && keys(input, 'action,kind'),
'Retain a private frozen fact or original future action.');
const action = parseTurnActionLeaf(input.action);
check(originals.has(actionKey(action)), 'A private future-action clause must come from the original question.');
return { kind: 'action', action };
};
return read(value, 0);
}
function validateEvent(event: unknown): asserts event is TravelEvent {
check(object(event) && keys(event, 'amount,territory') && printedTerritory(event.territory) && physical(event.amount),
'Retain the printed destination and zero to twenty actual physical forces.');
}
function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
check(object(promise) && keys(promise,
'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn') &&
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
'Retain the complete original turn-action commitment and its metadata.');
let mixed: MixedActionExpression<TurnActionLeaf>;
try {
mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTurnActionLeaf, {
requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES,
});
} catch (error) {
if (error instanceof MixedActionQuestionError) throw new TurnActionError(error.message);
throw error;
}
const originals = new Set<string>();
const destinations = new Set<string>();
const recipients = new Set<string>();
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
if (node.action.kind === 'move') destinations.add(node.action.territory);
if (node.action.kind === 'payment') recipients.add(node.action.recipient);
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
const compiled = readCompiled(promise.compiled, originals);
check(typeof promise.answer === 'boolean' && typeof promise.fulfilled === 'boolean' &&
typeof promise.released === 'boolean' && typeof promise.transportClosed === 'boolean' &&
typeof promise.paymentClosed === 'boolean' && !(promise.fulfilled && promise.released),
'Retain a definite whole turn-action answer and lifecycle.');
check(object(promise.shipment) && keys(promise.shipment, 'event,pending') &&
typeof promise.shipment.pending === 'boolean' && (!promise.shipment.pending || promise.shipment.event === null) &&
(!promise.transportClosed || !promise.shipment.pending),
'Retain this answer’s single pending or received reserve shipment.');
if (promise.shipment.event !== null) validateEvent(promise.shipment.event);
check(Array.isArray(promise.progress), 'Retain this answer’s physical ground-movement maxima.');
const seenDestinations = new Set<string>();
for (const entry of promise.progress) {
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
destinations.has(entry.territory) && physical(entry.maximum) && !seenDestinations.has(entry.territory),
'Retain unique named movement destinations and actual physical group maxima.');
seenDestinations.add(entry.territory);
}
check(Array.isArray(promise.payments), 'Retain this answer’s cumulative ordinary payments.');
const seenRecipients = new Set<string>();
for (const payment of promise.payments) {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) &&
recipients.has(payment.recipient) && whole(payment.amount) && !seenRecipients.has(payment.recipient),
'Retain unique named recipients and safe whole cumulative ordinary payment amounts.');
seenRecipients.add(payment.recipient);
}
return compiled;
}
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'payment') return (promise.payments.find(payment =>
payment.recipient === action.recipient)?.amount ?? 0) >= action.minimum;
if (action.kind === 'ship') return promise.shipment.event !== null &&
promise.shipment.event.territory === action.territory && promise.shipment.event.amount >= action.minimum;
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
}
function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
if (node.kind === 'constant') return node.value;
if (node.kind === 'action') return actionValue(node.action, promise);
return node.kind === 'and' ? node.terms.every(term => evaluate(term, promise)) :
node.terms.some(term => evaluate(term, promise));
}
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship'
? !promise.shipment.pending : promise.transportClosed;
}
function relevantClosed(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
if (node.kind === 'constant') return true;
if (node.kind === 'action') return actionClosed(node.action, promise);
return node.terms.every(term => relevantClosed(term, promise));
}
/** Uses only this answer interval's actual events and private frozen facts. */
export function turnActionValue(promise: TurnActionPromise): boolean {
return evaluate(readPromise(promise), promise);
}
/** Whether the original question names payments, including folded alternatives. */
export function turnActionHasPayments(promise: TurnActionPromise): boolean {
readPromise(promise);
const visit = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'payment' : node.kind !== 'fact' && node.terms.some(visit);
return visit(promise.mixed);
}
export function liveTurnActionPromises<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
check(Array.isArray(promises) && identifier(player) && positive(turn),
'Choose the turn-action respondent and turn.');
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
return left.kind === right.kind && (left.kind === 'payment'
? right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress
: right.kind !== 'payment' && left.territory === right.territory);
}
/** Called within one record only: payment progress belongs to that interval. */
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
const same = merged.find(other => sameAction(other, leaf) && 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 => sameAction(other, leaf) && other.answer);
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
if (yes && no && yes.minimum >= no.minimum) return null;
if (leaf.kind === 'ship' && leaf.answer && merged.some(other =>
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
}
return merged;
}
/** Lazy signed DNF; native ledgers retain costs, entitlements and feasibility. */
export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnActionBranchLeaf[]> {
const compiled = readPromise(promise);
if (promise.fulfilled || promise.released) { yield []; return; }
function* alternatives(node: CompiledMixedAction<TurnActionLeaf>, answer: boolean): Generator<TurnActionBranchLeaf[]> {
if (node.kind === 'constant') { if (node.value === answer) yield []; return; }
if (node.kind === 'action') {
const action = node.action;
const achieved = actionValue(action, promise);
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
yield [action.kind === 'payment' ? { ...action, answer,
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }
: { ...action, 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: TurnActionBranchLeaf[]): Generator<TurnActionBranchLeaf[]> {
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, []);
}
yield* alternatives(compiled, promise.answer);
}
function settle<T extends TurnActionPromise>(promise: T, update: T): T {
const compiled = readPromise(update);
const satisfied = evaluate(compiled, update);
check(promise.answer || !satisfied, 'This action conflicts with your whole Truthtrance turn-action No answer.');
return promise.answer && satisfied || !promise.answer && relevantClosed(compiled, update)
? { ...update, fulfilled: true } : update;
}
/** Exactly one original completed future reserve shipment or actual skip. */
export function recordTurnActionShipment<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TravelEvent | null): T[] {
if (event !== null) validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (!promise.shipment.pending) continue;
updates.set(promise, settle(promise, { ...promise,
shipment: { pending: false, event: event === null ? null : { ...event } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Only an ordinary physical ground group counts, never a shipment or marker. */
export function recordTurnActionMovement<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TravelEvent): T[] {
validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (promise.transportClosed || event.amount === 0) continue;
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'move' && node.action.territory === event.territory
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) continue;
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
if (event.amount <= old) continue;
const progress = promise.progress.map(entry => ({ ...entry }));
const entry = progress.find(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
updates.set(promise, settle(promise, { ...promise, progress }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Call only at the actual completed ordinary payer action, never at receipt. */
export function recordTurnActionPayment<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, payment: TurnActionPayment): T[] {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount),
'Retain the exact recipient and positive safe whole ordinary payment.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (promise.paymentClosed) continue;
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'payment' && node.action.recipient === payment.recipient
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) continue;
const old = promise.payments.find(entry => entry.recipient === payment.recipient)?.amount ?? 0;
const amount = old + payment.amount;
check(Number.isSafeInteger(amount), 'The cumulative ordinary payment exceeds the safe integer range.');
const payments = promise.payments.map(entry => ({ ...entry }));
const entry = payments.find(candidate => candidate.recipient === payment.recipient);
if (entry) entry.amount = amount;
else payments.push({ recipient: payment.recipient, amount });
updates.set(promise, settle(promise, { ...promise, payments }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Close transport only; a later ordinary payment can still complete the tree.
* The native joint solver, rather than this ledger, rejects own illegal closure. */
export function closeTurnActionTransport<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
const live = new Set(liveTurnActionPromises(promises, player, turn));
return promises.map(promise => {
if (!live.has(promise)) return promise;
readPromise(promise);
return settle(promise, { ...promise, transportClosed: true,
shipment: { ...promise.shipment, pending: false } });
});
}
/** Actual turn expiry closes every family. The engine owns own-expiry preflight;
* an externally impossible unsatisfied answer becomes a released terminal row. */
export function finishTurnActionTurn<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
const live = new Set(liveTurnActionPromises(promises, player, turn));
return promises.map(promise => {
if (!live.has(promise)) return promise;
readPromise(promise);
const closed = { ...promise, transportClosed: true, paymentClosed: true,
shipment: { ...promise.shipment, pending: false } };
const fulfilled = turnActionValue(closed) === promise.answer;
return { ...closed, fulfilled, released: !fulfilled };
});
}
/** Structure/progress only: root retains fact authority, nonally targeting and
* native payment/force legality. Impossible live Yes routes are not released here. */
export function validateTurnActionPromises(promises: readonly TurnActionPromise[], players: readonly string[]): void {
check(Array.isArray(promises) && Array.isArray(players), 'Retain the turn-action commitments and participants.');
const events = new Set<string>();
for (const promise of promises) {
const compiled = readPromise(promise);
const satisfied = evaluate(compiled, promise);
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
: node.kind === 'fact' || node.terms.every(recipientsValid);
check(!events.has(promise.event) && players.includes(promise.player) && players.includes(promise.asker) &&
promise.player !== promise.asker && recipientsValid(promise.mixed) &&
(promise.released || (promise.answer || !satisfied) &&
(!promise.answer || promise.fulfilled === satisfied) &&
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
(!(promise.transportClosed && promise.paymentClosed) || promise.fulfilled)),
'The turn-action commitment lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}