import { TERRITORIES } from './board'; import { BribePromiseError, parseBribeClaim } from './bribe-promises'; import { BidPromiseError, parseBidClaim } from './bid-promises'; import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises'; import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-revival-question'; import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises'; import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question'; import { PlanClaimError } from './battle-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 } | { kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } | { kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } | { kind: 'traitorCall'; battle: string; territory: string; call: boolean }; export type TurnActionClaim = { turn: number; mixed: MixedActionExpression }; export type TurnActionPayment = { recipient: string; amount: number }; export type TurnActionBranchLeaf = TravelBranchLeaf | { kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean } | { kind: 'bid'; lot: number; source: string; minimum: number; answer: boolean } | { kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean } | { kind: 'leader'; leader: string; answer: boolean } | { kind: 'alliance'; partner: string; source: string; answer: boolean } | { kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } | { kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean; beneficiary: string; target: string; leader: string | null }; export type TurnActionPreparation = { bid: { lot: number; source: string; maximum: number; closed: boolean } | null; revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null; }; export type TurnActionBid = { lot: number; source: string; amount: number }; export type TurnActionAlliance = { source: string; partner: string | null; closed: boolean }; export type TurnActionPlanValue = { claim: MixedBattleActionClaim; value: boolean }; export type TurnActionCombat = { event: string; territory: string; plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null; traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null; }; 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; 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; /** Optional only for original shipment/movement/payment records. */ preparation?: TurnActionPreparation; /** Final membership is recorded only by the original Nexus closing event. */ alliance?: TurnActionAlliance; combat?: TurnActionCombat; }; 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 { return !!value && typeof value === 'object' && !Array.isArray(value); } const keys = (value: Record, 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); /** Identity membership is decoded by Truthtrance; here retain one strict atom. */ function parseCombatClaim(value: unknown): MixedBattleActionClaim { try { return parseBattleActionClaim(value, object(value) && value.kind === 'leader' && identifier(value.leader) ? [value.leader] : []); } catch (error) { if (error instanceof PlanClaimError) throw new TurnActionError(error.message); throw error; } } const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseCombatClaim(claim)); /** Public leaves carry only their native destination/recipient and minimum. */ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf { check(object(value), 'Choose one future ordinary bid, Revival, final Nexus alliance, reserve shipment, ground movement or bribe payment.'); try { if (value.kind === 'battlePlan' || value.kind === 'traitorCall') { check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') && identifier(value.battle) && identifier(value.territory), 'Retain the exact original current battle and native location.'); if (value.kind === 'battlePlan') return { kind: 'battlePlan', battle: value.battle, territory: value.territory, claim: parseCombatClaim(value.claim) }; check(typeof value.call === 'boolean', 'Choose the actual Reveal or Decline declaration.'); return { kind: 'traitorCall', battle: value.battle, territory: value.territory, call: value.call }; } if (value.kind === 'alliance') { check(keys(value, 'kind,partner'), 'Supply only one final Nexus alliance partner.'); const claim = parseAllianceClaim({ turn: 1, partner: value.partner }); return { kind: 'alliance', partner: claim.partner }; } if (value.kind === 'bid') { check(keys(value, 'kind,lot,minimum'), 'Supply only the current ordinary lot and positive bid minimum.'); const claim = parseBidClaim({ turn: 1, lot: value.lot, minimum: value.minimum }); return { kind: 'bid', lot: claim.lot, minimum: claim.minimum }; } if (value.kind === 'forces' || value.kind === 'leader') return parseRevivalActionClaim(value); 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 || error instanceof BidPromiseError || error instanceof RevivalExpressionPromiseError || error instanceof AlliancePromiseError) 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 { if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]); if (action.kind === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]); if (action.kind === 'alliance') return JSON.stringify([action.kind, action.partner]); if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]); if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]); if (action.kind === 'bid') return JSON.stringify([action.kind, action.lot, action.minimum]); return JSON.stringify(action.kind === 'payment' ? [action.kind, action.recipient, action.minimum] : [action.kind, action.territory, action.minimum]); } function readCompiled(value: unknown, originals: ReadonlySet): CompiledMixedAction { let leaves = 0; const read = (input: unknown, depth: number): CompiledMixedAction => { 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 { check(object(promise), 'Retain the complete original turn-action commitment and its metadata.'); const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn'.split(','); if ('preparation' in promise) fields.push('preparation'); if ('alliance' in promise) fields.push('alliance'); if ('combat' in promise) fields.push('combat'); check(keys(promise, fields.sort().join(',')) && 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; 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(); const destinations = new Set(); const recipients = new Set(); const lots = new Set(); let hasRevival = false; let hasAlliance = false; const combatSources = new Set(); const planClaims = new Set(); let hasTraitor = false; const visit = (node: MixedActionExpression): 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); if (node.action.kind === 'bid') lots.add(node.action.lot); if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true; if (node.action.kind === 'alliance') hasAlliance = true; if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall') combatSources.add(JSON.stringify([node.action.battle, node.action.territory])); if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim)); if (node.action.kind === 'traitorCall') hasTraitor = true; } else if (node.kind !== 'fact') node.terms.forEach(visit); }; visit(mixed); check(!lots.size || !!promise.preparation?.bid, 'Retain the original private lot binding for every ordinary bid leaf.'); check(!hasRevival || !!promise.preparation?.revival, 'Retain this answer’s ordinary Revival interval.'); check(!hasAlliance || !!promise.alliance, 'Retain the original private Nexus binding for every final-alliance leaf.'); check(!planClaims.size || !!promise.combat?.plan, 'Retain the original private plan stage for every battle-plan clause.'); check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.'); if ('combat' in promise) { const combat = promise.combat; check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) && identifier(combat.territory) && [...combatSources].every(source => source === JSON.stringify([combat.event, combat.territory])), 'Retain one original current combat event and territory.'); if (combat.plan !== null) { check(object(combat.plan) && keys(combat.plan, 'closed,values') && typeof combat.plan.closed === 'boolean' && (combat.plan.closed || combat.plan.values === null), 'Retain an open plan or an actual closed plan receipt.'); if (combat.plan.values !== null) { check(Array.isArray(combat.plan.values) && combat.plan.values.length === planClaims.size, 'Retain every distinct original plan predicate exactly once.'); const seen = new Set(); for (const entry of combat.plan.values) { check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual native plan values.'); const key = planKey(entry.claim); check(planClaims.has(key) && !seen.has(key), 'Retain only unique original plan predicates in the actual receipt.'); seen.add(key); } } } if (combat.traitor !== null) { const traitor = combat.traitor; check(object(traitor) && keys(traitor, 'beneficiary,call,closed,leader,target') && identifier(traitor.beneficiary) && identifier(traitor.target) && traitor.beneficiary !== traitor.target && (traitor.leader === null || identifier(traitor.leader)) && typeof traitor.closed === 'boolean' && (traitor.call === null || typeof traitor.call === 'boolean') && (traitor.closed || traitor.call === null), 'Retain the public original Traitor context and actual declaration only after it closes.'); } } if ('alliance' in promise) { const alliance = promise.alliance; check(object(alliance) && keys(alliance, 'closed,partner,source') && identifier(alliance.source) && typeof alliance.closed === 'boolean' && (alliance.partner === null || identifier(alliance.partner) && alliance.partner !== promise.player) && (alliance.closed || alliance.partner === null), 'Retain only the original Nexus source and its actual final membership after closure.'); } if ('preparation' in promise) { const preparation = promise.preparation; check(object(preparation) && keys(preparation, 'bid,revival'), 'Retain only the private native preparation stages.'); if (preparation.bid !== null) { const bid = preparation.bid; check(object(bid) && keys(bid, 'closed,lot,maximum,source') && positive(bid.lot) && identifier(bid.source) && whole(bid.maximum) && typeof bid.closed === 'boolean' && [...lots].every(lot => lot === bid.lot), 'Retain one original ordinary lot, source, actual maximum accepted bid and closure.'); } if (preparation.revival !== null) { const revival = preparation.revival; check(object(revival) && keys(revival, 'closed,elite,leaders,normal,total') && physical(revival.total) && physical(revival.normal) && physical(revival.elite) && revival.total === revival.normal + revival.elite && Array.isArray(revival.leaders) && revival.leaders.every(identifier) && new Set(revival.leaders).size === revival.leaders.length && typeof revival.closed === 'boolean', 'Retain typed actual ordinary force returns, unique personal leaders and Revival closure.'); } } 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(); 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(); 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 === 'battlePlan') return promise.combat?.plan?.values?.find(entry => planKey(entry.claim) === planKey(action.claim))?.value ?? false; if (action.kind === 'traitorCall') return !!promise.combat?.traitor?.closed && promise.combat.traitor.call !== null && promise.combat.traitor.call === action.call; if (action.kind === 'alliance') return !!promise.alliance?.closed && promise.alliance.partner === action.partner; if (action.kind === 'bid') return (promise.preparation?.bid?.maximum ?? 0) >= action.minimum; if (action.kind === 'forces') return (promise.preparation?.revival?.[action.counter] ?? 0) >= action.minimum; if (action.kind === 'leader') return promise.preparation?.revival?.leaders.includes(action.leader) ?? false; 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, 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 { if (action.kind === 'battlePlan') return promise.combat!.plan!.closed; if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed; if (action.kind === 'alliance') return promise.alliance!.closed; if (action.kind === 'bid') return promise.preparation!.bid!.closed; if (action.kind === 'forces' || action.kind === 'leader') return promise.preparation!.revival!.closed; return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship' ? !promise.shipment.pending : promise.transportClosed; } function relevantClosed(node: CompiledMixedAction, 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): boolean => node.kind === 'action' ? node.action.kind === 'payment' : node.kind !== 'fact' && node.terms.some(visit); return visit(promise.mixed); } export function liveTurnActionPromises(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 { if (left.kind !== right.kind) return false; if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader; if (left.kind === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source; if (left.kind === 'battlePlan') return right.kind === 'battlePlan' && left.battle === right.battle && left.territory === right.territory && planKey(left.claim) === planKey(right.claim); if (left.kind === 'traitorCall') return right.kind === 'traitorCall' && left.battle === right.battle && left.territory === right.territory && left.call === right.call && left.beneficiary === right.beneficiary && left.target === right.target && left.leader === right.leader; if (left.kind === 'forces') return right.kind === 'forces' && left.counter === right.counter && left.progress === right.progress; if (left.kind === 'bid') return right.kind === 'bid' && left.lot === right.lot && left.source === right.source; if (left.kind === 'payment') return right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress; return (right.kind === 'ship' || right.kind === 'move') && 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) { if (leaf.kind === 'traitorCall') { const prior = merged.find(other => other.kind === 'traitorCall' && other.battle === leaf.battle && other.territory === leaf.territory && other.beneficiary === leaf.beneficiary && other.target === leaf.target && other.leader === leaf.leader); if (prior?.kind === 'traitorCall' && (prior.answer ? prior.call : !prior.call) !== (leaf.answer ? leaf.call : !leaf.call)) return null; if (!prior) merged.push({ ...leaf }); continue; } if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan') { const prior = merged.find(other => sameAction(other, leaf)); if (prior && prior.answer !== leaf.answer) return null; if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' && other.answer && other.source === leaf.source && other.partner !== leaf.partner)) return null; if (!prior) merged.push({ ...leaf }); continue; } const same = merged.find(other => sameAction(other, leaf) && other.answer === leaf.answer); if (same && 'minimum' in 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 && 'minimum' in yes && 'minimum' in 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 { const compiled = readPromise(promise); if (promise.fulfilled || promise.released) { yield []; return; } function* alternatives(node: CompiledMixedAction, answer: boolean): Generator { 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; } if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.source, answer }]; else if (action.kind === 'traitorCall') { const { beneficiary, target, leader } = promise.combat!.traitor!; yield [{ ...action, beneficiary, target, leader, answer }]; } else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }]; else if (action.kind === 'forces') yield [{ ...action, progress: promise.preparation!.revival![action.counter], answer }]; else if (action.kind === 'payment') yield [{ ...action, answer, progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }]; else yield [{ ...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 { 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(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; } /** A union receipt may serve several answers; each stored row retains exactly * its original distinct atomic predicates and no unrelated native plan values. */ export function recordTurnActionPlan(promises: readonly T[], player: string, turn: number, event: string, territory: string, values: readonly TurnActionPlanValue[]): T[] { check(identifier(event) && identifier(territory) && Array.isArray(values), 'Retain the actual original battle-plan event and predicate values.'); const receipt = new Map(); for (const entry of values) { check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual plan predicate values.'); const claim = parseCombatClaim(entry.claim), key = planKey(claim); check(!receipt.has(key), 'Retain each actual plan predicate once.'); receipt.set(key, { claim, value: entry.value }); } const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const combat = promise.combat; if (!combat?.plan || combat.plan.closed || combat.event !== event || combat.territory !== territory) continue; const claims = new Set(); const visit = (node: MixedActionExpression): void => { if (node.kind === 'action' && node.action.kind === 'battlePlan') claims.add(planKey(node.action.claim)); else if (node.kind !== 'fact' && node.kind !== 'action') node.terms.forEach(visit); }; visit(promise.mixed); const actual = [...claims].map(key => { const value = receipt.get(key); check(value, 'Retain the actual value of every original plan predicate.'); return { claim: { ...value.claim }, value: value.value }; }); updates.set(promise, settle(promise, { ...promise, combat: { ...combat, plan: { closed: true, values: actual } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** The actual native declaration is independent of later prevention/effect. */ export function recordTurnActionTraitor(promises: readonly T[], player: string, turn: number, event: string, territory: string, call: boolean): T[] { check(identifier(event) && identifier(territory) && typeof call === 'boolean', 'Retain the actual original battle and Reveal or Decline declaration.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const combat = promise.combat; if (!combat?.traitor || combat.traitor.closed || combat.event !== event || combat.territory !== territory) continue; updates.set(promise, settle(promise, { ...promise, combat: { ...combat, traitor: { ...combat.traitor, closed: true, call } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Closing without the original native event records absence, never a zero * dial or Decline. Existing closed receipts and terminal rows stay untouched. */ export function closeTurnActionCombat(promises: readonly T[], turn: number, event: string): T[] { check(Array.isArray(promises) && positive(turn) && identifier(event), 'Choose the original combat opportunity.'); for (const promise of promises) check(object(promise), 'Retain an original turn-action record.'); return promises.map(promise => { if (promise.turn !== turn || promise.fulfilled || promise.released) return promise; readPromise(promise); const combat = promise.combat; if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed)) return promise; return settle(promise, { ...promise, combat: { ...combat, plan: combat.plan && !combat.plan.closed ? { closed: true, values: null } : combat.plan, traitor: combat.traitor && !combat.traitor.closed ? { ...combat.traitor, closed: true, call: null } : combat.traitor, } }); }); } /** Only the exact original Nexus closure records final membership. Temporary * proposals/pairs never call this hook. A newly impossible whole No releases; * the engine rejects own voluntary closure by inspecting that detached result. */ export function settleTurnActionAlliance(promises: readonly T[], turn: number, source: string, allies: Readonly>): T[] { check(Array.isArray(promises) && positive(turn) && identifier(source) && object(allies) && Object.entries(allies).every(([player, partner]) => identifier(player) && (partner === null || identifier(partner) && partner !== player)), 'Retain the original Nexus and its actual final alliance snapshot.'); for (const promise of promises) check(object(promise), 'Retain an original turn-action record.'); return promises.map(promise => { if (promise.turn !== turn || promise.fulfilled || promise.released) return promise; readPromise(promise); const stage = promise.alliance; if (!stage || stage.closed || stage.source !== source) return promise; check(Object.prototype.hasOwnProperty.call(allies, promise.player), 'Retain the respondent’s actual final membership at this original Nexus closure.'); const update = { ...promise, alliance: { ...stage, partner: allies[promise.player], closed: true } }; if (!promise.answer && turnActionValue(update)) return { ...update, released: true }; return settle(promise, update); }); } /** Only an actually accepted ordinary declaration counts for its original lot. * Repeated declarations retain their largest amount, never their sum or invoice. */ export function recordTurnActionBid(promises: readonly T[], player: string, turn: number, event: TurnActionBid): T[] { check(object(event) && keys(event, 'amount,lot,source') && positive(event.lot) && identifier(event.source) && positive(event.amount), 'Retain the original ordinary lot, private source and positive actual accepted bid.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.preparation?.bid; if (!stage || stage.closed || stage.source !== event.source || stage.lot !== event.lot || event.amount <= stage.maximum) continue; updates.set(promise, settle(promise, { ...promise, preparation: { ...promise.preparation!, bid: { ...stage, maximum: event.amount } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Close this physical ordinary lot, or one respondent's actual opportunity. */ export function closeTurnActionBidding(promises: readonly T[], turn: number, source: string, player?: string): T[] { check(Array.isArray(promises) && positive(turn) && identifier(source) && (player === undefined || identifier(player)), 'Choose the original ordinary lot and optional respondent.'); for (const promise of promises) check(object(promise), 'Retain an original turn-action record.'); return promises.map(promise => { if (promise.turn !== turn || player !== undefined && promise.player !== player || promise.fulfilled || promise.released) return promise; readPromise(promise); const stage = promise.preparation?.bid; if (!stage || stage.closed || stage.source !== source) return promise; return settle(promise, { ...promise, preparation: { ...promise.preparation!, bid: { ...stage, closed: true } } }); }); } /** Completed ordinary native returns only. Card/special/foreign returns must * not call this hook, and each personal identity is recorded only once. */ export function recordTurnActionRevival(promises: readonly T[], player: string, turn: number, event: RevivalExpressionReturn): T[] { check(object(event) && (event.kind === 'forces' && keys(event, 'amount,elite,kind') && physical(event.amount) && physical(event.elite) && event.elite <= event.amount || event.kind === 'leader' && keys(event, 'kind,leader') && identifier(event.leader)), 'Retain an actual typed ordinary force return or personal leader identity.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.preparation?.revival; if (!stage || stage.closed || event.kind === 'forces' && event.amount === 0 || event.kind === 'leader' && stage.leaders.includes(event.leader)) continue; const revival = event.kind === 'forces' ? { ...stage, total: stage.total + event.amount, normal: stage.normal + event.amount - event.elite, elite: stage.elite + event.elite, leaders: [...stage.leaders] } : { ...stage, leaders: [...stage.leaders, event.leader] }; updates.set(promise, settle(promise, { ...promise, preparation: { ...promise.preparation!, revival } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** The respondent's ordinary Revival stage closes independently of transport. */ export function closeTurnActionRevival(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 stage = promise.preparation?.revival; if (!stage || stage.closed) return promise; return settle(promise, { ...promise, preparation: { ...promise.preparation!, revival: { ...stage, closed: true } } }); }); } /** Exactly one original completed future reserve shipment or actual skip. */ export function recordTurnActionShipment(promises: readonly T[], player: string, turn: number, event: TravelEvent | null): T[] { if (event !== null) validateEvent(event); const updates = new Map(); 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(promises: readonly T[], player: string, turn: number, event: TravelEvent): T[] { validateEvent(event); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); if (promise.transportClosed || event.amount === 0) continue; const named = (node: MixedActionExpression): 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(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(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); if (promise.paymentClosed) continue; const named = (node: MixedActionExpression): 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(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(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 }, ...(promise.preparation ? { preparation: { bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null, revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null, } } : {}) }; const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed || promise.combat.traitor && !promise.combat.traitor.closed); const fulfilled = !(promise.alliance && !promise.alliance.closed) && !unresolvedCombat && 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(); for (const promise of promises) { const compiled = readPromise(promise); const satisfied = evaluate(compiled, promise); const allClosed = promise.transportClosed && promise.paymentClosed && (!promise.preparation?.bid || promise.preparation.bid.closed) && (!promise.preparation?.revival || promise.preparation.revival.closed) && (!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) && (!promise.combat?.traitor || promise.combat.traitor.closed); const recipientsValid = (node: MixedActionExpression): boolean => node.kind === 'action' ? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player : 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.alliance || promise.alliance.partner === null || players.includes(promise.alliance.partner)) && (!promise.combat?.traitor || players.includes(promise.combat.traitor.beneficiary) && players.includes(promise.combat.traitor.target) && promise.combat.traitor.target !== promise.player) && (promise.released || (promise.answer || !satisfied) && (!promise.answer || promise.fulfilled === satisfied) && (!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) && (!allClosed || promise.fulfilled)), 'The turn-action commitment lost its event, participants or whole-answer progress.'); events.add(promise.event); } }