import { TERRITORIES, validLocation } 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 { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play'; import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play'; import { StormDialQuestionError, parseStormDialClaim, parseStormDialSource, stormDialMatches, type StormDialClaim } from './storm-dial-question'; import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope'; import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim, type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question'; import { MixedActionQuestionError, parseMixedActionExpression, type CompiledMixedAction, type MixedActionExpression, } from './mixed-action-question'; import { parseTravelActionClaim, TravelPromiseError, type TravelActionClaim, type TravelBranchLeaf, type TravelEvent, type MovementSectorProgress, } from './travel-expression-promises'; import type { TruthFact } from './truthtrance'; export const TURN_ACTION_MAX_LEAVES = 16; export const TURN_ACTION_MAX_DEPTH = 4; /** Native whole answers retain Semuta's own discard continuation. Other * unsupported interruption/module envelopes stay separately gated. */ export function turnActionPromiseModeSupported(game: NativePromiseScope & { kullPreview?: unknown; nexusKullPreview?: unknown; richeseBetrayalPreview?: unknown; }): boolean { return nativePromiseScopeSupported(game) && !game.kullPreview && !game.nexusKullPreview && !game.richeseBetrayalPreview; } export type TurnActionLeaf = TravelActionClaim | StormDialClaim | { 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 } | { kind: 'cardPlay'; effect: PrintedCardEffect } | { kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim } | { kind: 'windowCardPlay'; effect: WindowCardEffect; source: string }; export type TurnActionClaim = { turn: number; mixed: MixedActionExpression }; export type TurnActionPayment = { recipient: string; amount: number }; export type TurnActionBranchLeaf = TravelBranchLeaf | (StormDialClaim & { answer: boolean }) | { 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 } | { kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean } | { kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim; owner: string; beneficiary: string; target: string; answer: boolean } | { kind: 'windowCardPlay'; effect: WindowCardEffect; source: string; answer: boolean }; 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 TurnActionPowerStage = { beneficiary: string; target: string; closed: boolean; values: { claim: BattlePowerClaim; value: boolean }[] | null; }; 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; powers?: Record; }; type PrintedCardStage = { played: boolean; closed: boolean } | null; /** The first two keys retain the legacy schema; later printed roles are optional. */ export type TurnActionCardPlays = Record<'hajr' | 'ghola', PrintedCardStage> & Partial, PrintedCardStage>>; export type TurnActionWindowCards = Partial>; 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 }[]; /** Present only for original questions naming a specific movement sector. */ sectorProgress?: MovementSectorProgress[]; /** 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; /** Accepted printed-role plays after this answer only, never earlier counts. */ cardPlays?: TurnActionCardPlays; windowCards?: TurnActionWindowCards; /** A submitted dial is private to its respondent until the native reveal. */ stormDial?: { source: string; closed: boolean; actual: number | null }; }; 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); const windowEffect = (value: unknown): value is WindowCardEffect => WINDOW_CARD_EFFECTS.some(effect => effect === 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)); function parsePowerClaim(value: unknown): BattlePowerClaim { try { return parseBattlePowerClaim(value); } catch (error) { if (error instanceof BattlePowerQuestionError) throw new TurnActionError(error.message); throw error; } } const powerKey = (claim: BattlePowerClaim): string => JSON.stringify(parsePowerClaim(claim)); function powerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclaration): boolean { try { return battlePowerMatches(claim, declaration); } catch (error) { if (error instanceof BattlePowerQuestionError) throw new TurnActionError(error.message); throw error; } } /** Public leaves carry only their native destination/recipient and minimum. */ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf { check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.'); try { if (value.kind === 'stormDial') return parseStormDialClaim(value); if (value.kind === 'windowCardPlay') { check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source), 'Choose one original phase-opening, Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.'); return { kind: 'windowCardPlay', effect: value.effect, source: value.source }; } if (value.kind === 'battlePower') { check(keys(value, 'battle,claim,kind,territory') && identifier(value.battle) && identifier(value.territory), 'Retain the original battle and native Voice or Prescience declaration clause.'); return { kind: 'battlePower', battle: value.battle, territory: value.territory, claim: parsePowerClaim(value.claim) }; } if (value.kind === 'cardPlay') { check(keys(value, 'effect,kind') && PRINTED_CARD_EFFECTS.some(effect => effect === value.effect), 'Choose only a supported printed-card play after this answer.'); return { kind: 'cardPlay', effect: value.effect as PrintedCardEffect }; } 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 || error instanceof StormDialQuestionError) 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 === 'stormDial') return JSON.stringify(action.compare && action.compare !== 'eq' ? [action.kind, action.source, action.value, action.compare] : [action.kind, action.source, action.value]); if (action.kind === 'windowCardPlay') return JSON.stringify([action.kind, action.effect, action.source]); if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]); if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]); 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]); if ((action.kind === 'ship' || action.kind === 'move') && action.sector !== undefined) return JSON.stringify([action.kind, action.territory, action.minimum, action.sector]); 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 { // Events are native physical arrivals, which may be at the mobile // stronghold or a revealed Discovery. Question claims remain printed-only; // the engine independently validates that a dynamic site actually exists. check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) && typeof event.territory === 'string' && physical(event.amount) && ('sector' in event ? typeof event.sector === 'number' && Number.isSafeInteger(event.sector) && validLocation(event.territory, event.sector) : printedTerritory(event.territory) || validLocation(event.territory, 0)), 'Retain a structurally valid destination, any actual sector and zero to twenty 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 ('sectorProgress' in promise) fields.push('sectorProgress'); if ('preparation' in promise) fields.push('preparation'); if ('alliance' in promise) fields.push('alliance'); if ('combat' in promise) fields.push('combat'); if ('cardPlays' in promise) fields.push('cardPlays'); if ('windowCards' in promise) fields.push('windowCards'); if ('stormDial' in promise) fields.push('stormDial'); 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 sectorDestinations = new Set(); const recipients = new Set(); const lots = new Set(); let hasRevival = false; let hasAlliance = false; const combatSources = new Set(); const planClaims = new Set(); const powerClaims: Record> = { voice: new Set(), prescience: new Set() }; let hasTraitor = false; const cardEffects = new Set(); const windowSources = new Map>(); const stormSources = new Set(); 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.sector !== undefined) sectorDestinations.add(JSON.stringify([node.action.territory, node.action.sector])); } 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' || node.action.kind === 'battlePower') 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; if (node.action.kind === 'cardPlay') cardEffects.add(node.action.effect); if (node.action.kind === 'stormDial') stormSources.add(node.action.source); if (node.action.kind === 'battlePower') powerClaims[battlePowerOf(node.action.claim)].add(powerKey(node.action.claim)); if (node.action.kind === 'windowCardPlay') { const sources = windowSources.get(node.action.effect) ?? new Set(); sources.add(node.action.source); windowSources.set(node.action.effect, sources); } } 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.'); for (const power of ['voice', 'prescience'] as const) check(!powerClaims[power].size || !!promise.combat?.powers?.[power], 'Retain the original private native battle-power stage, including folded original clauses.'); check(!stormSources.size || !!promise.stormDial, 'Retain the original native storm-dial interval, including folded clauses.'); if ('stormDial' in promise) { const stage = promise.stormDial; const source = stage && parseStormDialSource(stage.source); check(object(stage) && keys(stage, 'actual,closed,source') && source && source.turn === promise.turn && source.player === promise.player && stormSources.size === 1 && stormSources.has(stage.source) && typeof stage.closed === 'boolean' && (stage.actual === null || typeof stage.actual === 'number' && Number.isSafeInteger(stage.actual) && stage.actual >= (source.turn === 1 ? 0 : 1) && stage.actual <= (source.turn === 1 ? 20 : 3)) && (stage.closed || stage.actual === null), 'Retain the respondent’s exact original storm source and actual submitted value only after closure.'); } for (const effect of cardEffects) check(!!promise.cardPlays?.[effect], 'Retain the original future interval for each printed-card play clause.'); for (const effect of windowSources.keys()) check(!!promise.windowCards?.[effect], 'Retain the original native window for every printed-card window clause, including folded ones.'); if ('windowCards' in promise) { check(object(promise.windowCards) && keys(promise.windowCards, [...windowSources.keys()].sort().join(',')), 'Retain exactly the original named window-card effects.'); for (const [effect, sources] of windowSources) { const stage = promise.windowCards[effect]; check(object(stage) && keys(stage, 'closed,played,source') && identifier(stage.source) && sources.size === 1 && sources.has(stage.source) && typeof stage.played === 'boolean' && typeof stage.closed === 'boolean', 'Retain the exact original window source, actual printed play and closure.'); } } if ('cardPlays' in promise) { check(object(promise.cardPlays) && Object.hasOwn(promise.cardPlays, 'hajr') && Object.hasOwn(promise.cardPlays, 'ghola') && Object.keys(promise.cardPlays).every(key => PRINTED_CARD_EFFECTS.some(effect => effect === key)), 'Retain only the native printed-card play stages and original Hajr/Ghola keys.'); for (const effect of PRINTED_CARD_EFFECTS) { if (!Object.hasOwn(promise.cardPlays, effect)) continue; const stage = promise.cardPlays[effect]; check(stage === null || object(stage) && keys(stage, 'closed,played') && typeof stage.played === 'boolean' && typeof stage.closed === 'boolean', 'Retain the actual printed-role play and native stage closure.'); } } if ('combat' in promise) { const combat = promise.combat; check(object(combat) && (keys(combat, 'event,plan,territory,traitor') || keys(combat, 'event,plan,powers,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 ('powers' in combat) { check(object(combat.powers) && keys(combat.powers, 'prescience,voice'), 'Retain only the two native battle-power stages.'); for (const power of ['voice', 'prescience'] as const) { const stage = combat.powers[power]; if (stage === null) continue; check(object(stage) && keys(stage, 'beneficiary,closed,target,values') && identifier(stage.beneficiary) && identifier(stage.target) && stage.beneficiary !== stage.target && typeof stage.closed === 'boolean' && (stage.closed || stage.values === null), 'Retain the public native power side and actual closed declaration receipt.'); if (stage.values === null) continue; check(Array.isArray(stage.values) && stage.values.length === powerClaims[power].size, 'Retain every distinct original predicate for this native power exactly once.'); const seen = new Set(); for (const entry of stage.values) { check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual battle-power predicate values.'); const claim = parsePowerClaim(entry.claim), key = powerKey(claim); check(battlePowerOf(claim) === power && powerClaims[power].has(key) && !seen.has(key), 'Retain only unique original predicates of this native power in the actual receipt.'); seen.add(key); } } } 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(sectorDestinations.size ? Array.isArray(promise.sectorProgress) : !('sectorProgress' in promise), 'Retain per-sector movement maxima only for original sector-qualified questions.'); const seenSectors = new Set(); for (const entry of promise.sectorProgress ?? []) { const key = object(entry) ? JSON.stringify([entry.territory, entry.sector]) : ''; check(object(entry) && keys(entry, 'maximum,sector,territory') && sectorDestinations.has(key) && physical(entry.maximum) && !seenSectors.has(key) && (promise.progress.find(row => row.territory === entry.territory)?.maximum ?? 0) >= entry.maximum, 'Retain unique named physical sector maxima under the actual territory maximum.'); seenSectors.add(key); } 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 === 'stormDial') return promise.stormDial?.source === action.source && promise.stormDial.actual !== null && stormDialMatches(action, promise.stormDial.actual); if (action.kind === 'windowCardPlay') return promise.windowCards?.[action.effect]?.played ?? false; if (action.kind === 'battlePower') return promise.combat?.powers?.[battlePowerOf(action.claim)]?.values?.find(entry => powerKey(entry.claim) === powerKey(action.claim))?.value ?? false; if (action.kind === 'cardPlay') return promise.cardPlays?.[action.effect]?.played ?? false; 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 && (action.sector === undefined || promise.shipment.event.sector === action.sector) && promise.shipment.event.amount >= action.minimum; return ((action.sector === undefined ? promise.progress.find(entry => entry.territory === action.territory)?.maximum : promise.sectorProgress?.find(entry => entry.territory === action.territory && entry.sector === action.sector)?.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 === 'stormDial') return promise.stormDial!.closed; if (action.kind === 'windowCardPlay') return promise.windowCards![action.effect]!.closed; if (action.kind === 'battlePower') return promise.combat!.powers![battlePowerOf(action.claim)]!.closed; if (action.kind === 'cardPlay') return promise.cardPlays![action.effect]!.closed; 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 === 'stormDial') return right.kind === 'stormDial' && left.source === right.source && left.value === right.value && (left.compare ?? 'eq') === (right.compare ?? 'eq'); if (left.kind === 'windowCardPlay') return right.kind === 'windowCardPlay' && left.effect === right.effect && left.source === right.source; if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle && left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary && left.target === right.target && powerKey(left.claim) === powerKey(right.claim); if (left.kind === 'cardPlay') return right.kind === 'cardPlay' && left.effect === right.effect; 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 && left.sector === right.sector; } /** Legal values for all signed leaves tied to the same original dial. A caller * may combine leaves from several accepted promises before quoting one action. */ export function turnActionStormDialChoices(leaves: readonly TurnActionBranchLeaf[], source: string): number[] { const decoded = parseStormDialSource(source); if (!decoded) return []; const named = leaves.filter((leaf): leaf is Extract => leaf.kind === 'stormDial' && leaf.source === source); const min = decoded.turn === 1 ? 0 : 1, max = decoded.turn === 1 ? 20 : 3; return Array.from({ length: max - min + 1 }, (_, index) => index + min) .filter(value => named.every(leaf => stormDialMatches({ kind: 'stormDial', source: leaf.source, value: leaf.value, ...(leaf.compare ? { compare: leaf.compare } : {}) }, value) === leaf.answer)); } /** 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 === 'stormDial') { const prior = merged.find(other => sameAction(other, leaf)); if (prior && prior.answer !== leaf.answer) return null; if (!prior) merged.push({ ...leaf }); if (!turnActionStormDialChoices(merged, leaf.source).length) return null; continue; } if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' || leaf.kind === 'battlePower' || leaf.kind === 'windowCardPlay') { 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.kind === 'move') { if (leaf.kind === 'ship' && leaf.answer && merged.some(other => other.kind === 'ship' && other.answer && (other.territory !== leaf.territory || other.sector !== undefined && leaf.sector !== undefined && other.sector !== leaf.sector))) return null; // One exact positive action necessarily satisfies a broader negative // condition at an equal or lower minimum. A broad positive and an exact // negative can coexist when another legal sector is available. for (const required of merged) if (required.kind === leaf.kind && required.answer) for (const excluded of merged) if (excluded.kind === leaf.kind && !excluded.answer && required.territory === excluded.territory && required.minimum >= excluded.minimum && (excluded.sector === undefined || required.sector !== undefined && required.sector === excluded.sector)) 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 === 'battlePower') { const stage = promise.combat!.powers![battlePowerOf(action.claim)]!; yield [{ ...action, owner: promise.player, beneficiary: stage.beneficiary, target: stage.target, 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; } /** Record one actual sealed native dial against its exact original source. * Closing the stage never publishes its private numeric receipt. */ export function recordTurnActionStormDial(promises: readonly T[], player: string, turn: number, source: string, actual: number): T[] { const identity = parseStormDialSource(source); check(identity && identity.turn === turn && identity.player === player, 'Retain the original respondent’s storm-dial source.'); try { stormDialMatches({ kind: 'stormDial', source, value: actual }, actual); } catch (error) { if (error instanceof StormDialQuestionError) throw new TurnActionError(error.message); throw error; } const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.stormDial; if (!stage || stage.closed || stage.source !== source) continue; updates.set(promise, settle(promise, { ...promise, stormDial: { ...stage, closed: true, actual } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Closing without a submission records absence, never a synthetic zero. */ export function closeTurnActionStormDial(promises: readonly T[], player: string, turn: number, source: string): T[] { const identity = parseStormDialSource(source); check(identity && identity.turn === turn && identity.player === player, 'Retain the original respondent’s storm-dial source.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.stormDial; if (!stage || stage.closed || stage.source !== source) continue; updates.set(promise, settle(promise, { ...promise, stormDial: { ...stage, closed: true } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Only an accepted printed-role play in this exact original native window * counts. Effect amounts, earlier plays, disposal and recovery do not call it. */ export function recordTurnActionWindowCardPlay(promises: readonly T[], player: string, turn: number, effect: WindowCardEffect, source: string): T[] { check(windowEffect(effect) && identifier(source), 'Record one accepted original-window printed-card play.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.windowCards?.[effect]; if (!stage || stage.closed || stage.played || stage.source !== source) continue; updates.set(promise, settle(promise, { ...promise, windowCards: { ...promise.windowCards!, [effect]: { ...stage, played: true } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Close only this original effect/window, preserving every later alternative. */ export function closeTurnActionWindowCardPlay(promises: readonly T[], player: string, turn: number, effect: WindowCardEffect, source: string): T[] { check(windowEffect(effect) && identifier(source), 'Close one original native printed-card window.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.windowCards?.[effect]; if (!stage || stage.closed || stage.source !== source) continue; updates.set(promise, settle(promise, { ...promise, windowCards: { ...promise.windowCards!, [effect]: { ...stage, closed: true } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Record the actual owned declaration before any later Karama prevention. * Its exact predicates stay true/false even if the power's effect is denied. */ export function recordTurnActionBattlePower(promises: readonly T[], player: string, turn: number, event: string, territory: string, declaration: BattlePowerDeclaration): T[] { check(identifier(event) && identifier(territory) && object(declaration) && (declaration.power === 'voice' || declaration.power === 'prescience'), 'Retain the actual original native battle-power declaration.'); powerMatches({ kind: declaration.power, use: false }, declaration); const power = declaration.power; const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const combat = promise.combat, stage = combat?.powers?.[power]; if (!combat || !stage || stage.closed || combat.event !== event || combat.territory !== territory) continue; const claims = new Map(); const visit = (node: MixedActionExpression): void => { if (node.kind === 'action' && node.action.kind === 'battlePower' && battlePowerOf(node.action.claim) === power) claims.set(powerKey(node.action.claim), parsePowerClaim(node.action.claim)); else if (node.kind !== 'fact' && node.kind !== 'action') node.terms.forEach(visit); }; visit(promise.mixed); const values = [...claims.values()].map(claim => ({ claim, value: powerMatches(claim, declaration) })); updates.set(promise, settle(promise, { ...promise, combat: { ...combat, powers: { ...combat.powers!, [power]: { ...stage, closed: true, values } } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Losing only this native opportunity records absence, never Decline, and * preserves the plan, other power and every already closed receipt. */ export function closeTurnActionBattlePower(promises: readonly T[], player: string, turn: number, event: string, territory: string, power: NativeBattlePower): T[] { check(identifier(event) && identifier(territory) && (power === 'voice' || power === 'prescience'), 'Choose the original native battle-power opportunity.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const combat = promise.combat, stage = combat?.powers?.[power]; if (!combat || !stage || stage.closed || combat.event !== event || combat.territory !== territory) continue; updates.set(promise, settle(promise, { ...promise, combat: { ...combat, powers: { ...combat.powers!, [power]: { ...stage, closed: true, values: null } } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** Accepted printed-role play only. Native callers separately own effect * success, disposal, recovery and real card custody; none alone count as this * event. Semuta requires its physical exchange, not commitment or inspection. */ export function recordTurnActionCardPlay(promises: readonly T[], player: string, turn: number, effect: PrintedCardEffect): T[] { check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted supported printed-card play.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); const stage = promise.cardPlays?.[effect]; if (!stage || stage.closed || stage.played) continue; updates.set(promise, settle(promise, { ...promise, cardPlays: { ...promise.cardPlays!, [effect]: { ...stage, played: true } } })); } return promises.map(promise => updates.get(promise) ?? promise); } /** 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) && (!combat.powers?.voice || combat.powers.voice.closed) && (!combat.powers?.prescience || combat.powers.prescience.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, ...(combat.powers ? { powers: { voice: combat.powers.voice && !combat.powers.voice.closed ? { ...combat.powers.voice, closed: true, values: null } : combat.powers.voice, prescience: combat.powers.prescience && !combat.powers.prescience.closed ? { ...combat.powers.prescience, closed: true, values: null } : combat.powers.prescience, } } : {}), } }); }); } /** 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; const needsSector = (node: MixedActionExpression): boolean => node.kind === 'action' ? node.action.kind === 'ship' && node.action.sector !== undefined : node.kind !== 'fact' && node.terms.some(needsSector); const recorded = event === null ? null : { territory: event.territory, amount: event.amount, ...(needsSector(promise.mixed) && event.sector !== undefined ? { sector: event.sector } : {}) }; updates.set(promise, settle(promise, { ...promise, shipment: { pending: false, event: recorded } })); } 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; const namesSector = (node: MixedActionExpression): boolean => node.kind === 'action' ? node.action.kind === 'move' && node.action.territory === event.territory && node.action.sector === event.sector && event.sector !== undefined : node.kind !== 'fact' && node.terms.some(namesSector); const sectorNamed = event.sector !== undefined && namesSector(promise.mixed); const oldSector = sectorNamed ? promise.sectorProgress!.find(entry => entry.territory === event.territory && entry.sector === event.sector)?.maximum ?? 0 : 0; if (event.amount <= old && (!sectorNamed || event.amount <= oldSector)) continue; const progress = promise.progress.map(entry => ({ ...entry })); if (event.amount > old) { const entry = progress.find(candidate => candidate.territory === event.territory); if (entry) entry.maximum = event.amount; else progress.push({ territory: event.territory, maximum: event.amount }); } let sectorProgress = promise.sectorProgress; if (sectorNamed && event.amount > oldSector) { sectorProgress = promise.sectorProgress!.map(entry => ({ ...entry })); const entry = sectorProgress.find(candidate => candidate.territory === event.territory && candidate.sector === event.sector); if (entry) entry.maximum = event.amount; else sectorProgress.push({ territory: event.territory, sector: event.sector!, maximum: event.amount }); } updates.set(promise, settle(promise, { ...promise, progress, ...(sectorProgress ? { sectorProgress } : {}) })); } 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); } /** A known payment ban can end ordinary payments before the full turn ends. * Other native opportunities, including later Ghola, retain their own stages. */ export function closeTurnActionPayments(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); if (promise.paymentClosed) return promise; return settle(promise, { ...promise, paymentClosed: true }); }); } /** Close transport and its native Hajr/Ornithopter stages; later card or payment alternatives can 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 }, ...(promise.cardPlays ? { cardPlays: { ...promise.cardPlays, hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null, ...(promise.cardPlays.ornithopter ? { ornithopter: { ...promise.cardPlays.ornithopter, closed: true } } : {}), } } : {}) }); }); } /** 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 windowCards: TurnActionWindowCards = {}; for (const effect of WINDOW_CARD_EFFECTS) { const stage = promise.windowCards?.[effect]; if (stage) windowCards[effect] = { ...stage, closed: true }; } 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, } } : {}), ...(promise.cardPlays ? { cardPlays: Object.fromEntries(PRINTED_CARD_EFFECTS .filter(effect => Object.hasOwn(promise.cardPlays!, effect)).map(effect => [effect, promise.cardPlays![effect] ? { ...promise.cardPlays![effect]!, closed: true } : null])) as TurnActionCardPlays } : {}), ...(promise.windowCards ? { windowCards } : {}), ...(promise.stormDial ? { stormDial: { ...promise.stormDial, closed: true } } : {}) }; const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed || promise.combat.traitor && !promise.combat.traitor.closed || promise.combat.powers?.voice && !promise.combat.powers.voice.closed || promise.combat.powers?.prescience && !promise.combat.powers.prescience.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) && (!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) && (!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) && WINDOW_CARD_EFFECTS.every(effect => promise.windowCards?.[effect]?.closed ?? true) && (!promise.stormDial || promise.stormDial.closed) && PRINTED_CARD_EFFECTS.every(effect => promise.cardPlays?.[effect]?.closed ?? true); 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) && (['voice', 'prescience'] as const).every(power => { const stage = promise.combat?.powers?.[power]; return !stage || players.includes(stage.beneficiary) && players.includes(stage.target) && stage.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); } }