1185 lines
73 KiB
TypeScript
1185 lines
73 KiB
TypeScript
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<TurnActionLeaf> };
|
||
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<NativeBattlePower, TurnActionPowerStage | null>;
|
||
};
|
||
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<Record<Exclude<PrintedCardEffect, 'hajr' | 'ghola'>, PrintedCardStage>>;
|
||
export type TurnActionWindowCards = Partial<Record<WindowCardEffect, { source: string; played: boolean; closed: boolean }>>;
|
||
export type TurnActionPromise = TurnActionClaim & {
|
||
event: string;
|
||
player: string;
|
||
asker: string;
|
||
answer: boolean;
|
||
/** Facts are frozen by the game-aware compiler when this answer is accepted. */
|
||
compiled: CompiledMixedAction<TurnActionLeaf>;
|
||
shipment: { pending: boolean; event: TravelEvent | null };
|
||
/** Largest physical ground group after this answer, never a sum of groups. */
|
||
progress: { territory: string; maximum: number }[];
|
||
/** 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<string, unknown> {
|
||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||
Object.keys(value).sort().join(',') === expected;
|
||
const identifier = (value: unknown): value is string =>
|
||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||
const whole = (value: unknown): value is number =>
|
||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||
const positive = (value: unknown): value is number => whole(value) && value > 0;
|
||
const physical = (value: unknown): value is number => whole(value) && value <= 20;
|
||
const printedTerritory = (value: unknown): value is string =>
|
||
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
|
||
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<string>): CompiledMixedAction<TurnActionLeaf> {
|
||
let leaves = 0;
|
||
const read = (input: unknown, depth: number): CompiledMixedAction<TurnActionLeaf> => {
|
||
check(object(input) && depth <= TURN_ACTION_MAX_DEPTH,
|
||
'Retain the bounded private answer-time turn-action expression.');
|
||
if (input.kind === 'and' || input.kind === 'or') {
|
||
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
|
||
input.terms.length <= TURN_ACTION_MAX_LEAVES,
|
||
'Retain the private answer-time AND/OR turn-action groups.');
|
||
return { kind: input.kind, terms: input.terms.map(term => read(term, depth + 1)) };
|
||
}
|
||
check(++leaves <= TURN_ACTION_MAX_LEAVES, 'Retain at most sixteen private turn-action clauses.');
|
||
if (input.kind === 'constant') {
|
||
check(keys(input, 'kind,value') && typeof input.value === 'boolean',
|
||
'Retain a definite private frozen fact answer.');
|
||
return { kind: 'constant', value: input.value };
|
||
}
|
||
check(input.kind === 'action' && keys(input, 'action,kind'),
|
||
'Retain a private frozen fact or original future action.');
|
||
const action = parseTurnActionLeaf(input.action);
|
||
check(originals.has(actionKey(action)), 'A private future-action clause must come from the original question.');
|
||
return { kind: 'action', action };
|
||
};
|
||
return read(value, 0);
|
||
}
|
||
|
||
function validateEvent(event: unknown): asserts event is TravelEvent {
|
||
// 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<TurnActionLeaf> {
|
||
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<TurnActionLeaf>;
|
||
try {
|
||
mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTurnActionLeaf, {
|
||
requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES,
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof MixedActionQuestionError) throw new TurnActionError(error.message);
|
||
throw error;
|
||
}
|
||
const originals = new Set<string>();
|
||
const destinations = new Set<string>();
|
||
const sectorDestinations = new Set<string>();
|
||
const recipients = new Set<string>();
|
||
const lots = new Set<number>();
|
||
let hasRevival = false;
|
||
let hasAlliance = false;
|
||
const combatSources = new Set<string>();
|
||
const planClaims = new Set<string>();
|
||
const powerClaims: Record<NativeBattlePower, Set<string>> = { voice: new Set(), prescience: new Set() };
|
||
let hasTraitor = false;
|
||
const cardEffects = new Set<PrintedCardEffect>();
|
||
const windowSources = new Map<WindowCardEffect, Set<string>>();
|
||
const stormSources = new Set<string>();
|
||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||
if (node.kind === 'action') {
|
||
originals.add(actionKey(node.action));
|
||
if (node.action.kind === 'move') {
|
||
destinations.add(node.action.territory);
|
||
if (node.action.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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
for (const entry of promise.progress) {
|
||
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
|
||
destinations.has(entry.territory) && physical(entry.maximum) && !seenDestinations.has(entry.territory),
|
||
'Retain unique named movement destinations and actual physical group maxima.');
|
||
seenDestinations.add(entry.territory);
|
||
}
|
||
check(sectorDestinations.size ? Array.isArray(promise.sectorProgress) : !('sectorProgress' in promise),
|
||
'Retain per-sector movement maxima only for original sector-qualified questions.');
|
||
const seenSectors = new Set<string>();
|
||
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<string>();
|
||
for (const payment of promise.payments) {
|
||
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) &&
|
||
recipients.has(payment.recipient) && whole(payment.amount) && !seenRecipients.has(payment.recipient),
|
||
'Retain unique named recipients and safe whole cumulative ordinary payment amounts.');
|
||
seenRecipients.add(payment.recipient);
|
||
}
|
||
return compiled;
|
||
}
|
||
|
||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||
if (action.kind === '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<TurnActionLeaf>, promise: TurnActionPromise): boolean {
|
||
if (node.kind === 'constant') return node.value;
|
||
if (node.kind === 'action') return actionValue(node.action, promise);
|
||
return node.kind === 'and' ? node.terms.every(term => evaluate(term, promise)) :
|
||
node.terms.some(term => evaluate(term, promise));
|
||
}
|
||
|
||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||
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<TurnActionLeaf>, promise: TurnActionPromise): boolean {
|
||
if (node.kind === 'constant') return true;
|
||
if (node.kind === 'action') return actionClosed(node.action, promise);
|
||
return node.terms.every(term => relevantClosed(term, promise));
|
||
}
|
||
|
||
/** Uses only this answer interval's actual events and private frozen facts. */
|
||
export function turnActionValue(promise: TurnActionPromise): boolean {
|
||
return evaluate(readPromise(promise), promise);
|
||
}
|
||
|
||
/** Whether the original question names payments, including folded alternatives. */
|
||
export function turnActionHasPayments(promise: TurnActionPromise): boolean {
|
||
readPromise(promise);
|
||
const visit = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||
? node.action.kind === 'payment' : node.kind !== 'fact' && node.terms.some(visit);
|
||
return visit(promise.mixed);
|
||
}
|
||
|
||
export function liveTurnActionPromises<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number): T[] {
|
||
check(Array.isArray(promises) && identifier(player) && positive(turn),
|
||
'Choose the turn-action respondent and turn.');
|
||
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
|
||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||
!promise.fulfilled && !promise.released);
|
||
}
|
||
|
||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||
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<TurnActionBranchLeaf, { kind: 'stormDial' }> =>
|
||
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<TurnActionBranchLeaf[]> {
|
||
const compiled = readPromise(promise);
|
||
if (promise.fulfilled || promise.released) { yield []; return; }
|
||
function* alternatives(node: CompiledMixedAction<TurnActionLeaf>, answer: boolean): Generator<TurnActionBranchLeaf[]> {
|
||
if (node.kind === 'constant') { if (node.value === answer) yield []; return; }
|
||
if (node.kind === 'action') {
|
||
const action = node.action;
|
||
const achieved = actionValue(action, promise);
|
||
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
|
||
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<TurnActionBranchLeaf[]> {
|
||
if (index === terms.length) { yield prefix; return; }
|
||
for (const suffix of alternatives(terms[index], answer)) {
|
||
const merged = mergeBranches(prefix, suffix);
|
||
if (merged) yield* combine(index + 1, merged);
|
||
}
|
||
}
|
||
yield* combine(0, []);
|
||
}
|
||
yield* alternatives(compiled, promise.answer);
|
||
}
|
||
|
||
function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||
const compiled = readPromise(update);
|
||
const satisfied = evaluate(compiled, update);
|
||
check(promise.answer || !satisfied, 'This action conflicts with your whole Truthtrance turn-action No answer.');
|
||
return promise.answer && satisfied || !promise.answer && relevantClosed(compiled, update)
|
||
? { ...update, fulfilled: true } : update;
|
||
}
|
||
|
||
/** Record one actual sealed native dial against its exact original source.
|
||
* Closing the stage never publishes its private numeric receipt. */
|
||
export function recordTurnActionStormDial<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T, T>();
|
||
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<string, BattlePowerClaim>();
|
||
const visit = (node: MixedActionExpression<TurnActionLeaf>): 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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<string, TurnActionPlanValue>();
|
||
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<T, T>();
|
||
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<string>();
|
||
const visit = (node: MixedActionExpression<TurnActionLeaf>): 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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T extends TurnActionPromise>(promises: readonly T[], turn: number,
|
||
source: string, allies: Readonly<Record<string, string | null>>): 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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(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<T extends TurnActionPromise>(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<T, T>();
|
||
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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number): T[] {
|
||
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||
return promises.map(promise => {
|
||
if (!live.has(promise)) return promise;
|
||
readPromise(promise);
|
||
const 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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number, event: TravelEvent | null): T[] {
|
||
if (event !== null) validateEvent(event);
|
||
const updates = new Map<T, T>();
|
||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||
readPromise(promise);
|
||
if (!promise.shipment.pending) continue;
|
||
const needsSector = (node: MixedActionExpression<TurnActionLeaf>): 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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number, event: TravelEvent): T[] {
|
||
validateEvent(event);
|
||
const updates = new Map<T, T>();
|
||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||
readPromise(promise);
|
||
if (promise.transportClosed || event.amount === 0) continue;
|
||
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||
? node.action.kind === 'move' && node.action.territory === event.territory
|
||
: node.kind !== 'fact' && node.terms.some(named);
|
||
if (!named(promise.mixed)) continue;
|
||
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
|
||
const namesSector = (node: MixedActionExpression<TurnActionLeaf>): 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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number, payment: TurnActionPayment): T[] {
|
||
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount),
|
||
'Retain the exact recipient and positive safe whole ordinary payment.');
|
||
const updates = new Map<T, T>();
|
||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||
readPromise(promise);
|
||
if (promise.paymentClosed) continue;
|
||
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||
? node.action.kind === 'payment' && node.action.recipient === payment.recipient
|
||
: node.kind !== 'fact' && node.terms.some(named);
|
||
if (!named(promise.mixed)) continue;
|
||
const old = promise.payments.find(entry => entry.recipient === payment.recipient)?.amount ?? 0;
|
||
const amount = old + payment.amount;
|
||
check(Number.isSafeInteger(amount), 'The cumulative ordinary payment exceeds the safe integer range.');
|
||
const payments = promise.payments.map(entry => ({ ...entry }));
|
||
const entry = payments.find(candidate => candidate.recipient === payment.recipient);
|
||
if (entry) entry.amount = amount;
|
||
else payments.push({ recipient: payment.recipient, amount });
|
||
updates.set(promise, settle(promise, { ...promise, payments }));
|
||
}
|
||
return promises.map(promise => updates.get(promise) ?? promise);
|
||
}
|
||
|
||
/** 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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number): T[] {
|
||
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||
return promises.map(promise => {
|
||
if (!live.has(promise)) return promise;
|
||
readPromise(promise);
|
||
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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number): T[] {
|
||
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||
return promises.map(promise => {
|
||
if (!live.has(promise)) return promise;
|
||
readPromise(promise);
|
||
return settle(promise, { ...promise, transportClosed: true,
|
||
shipment: { ...promise.shipment, pending: false }, ...(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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||
turn: number): T[] {
|
||
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||
return promises.map(promise => {
|
||
if (!live.has(promise)) return promise;
|
||
readPromise(promise);
|
||
const 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<string>();
|
||
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<TurnActionLeaf>): 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);
|
||
}
|
||
}
|