export const MECHANIC_EVENT_KINDS = [ 'shot_fired', 'weapon_hit', 'skill_deployed', 'signature_ability_deployed', 'skill_hit', 'skill_multishot_triggered', 'agent_hit', 'health_damage_taken', 'damage_dealt', 'enemy_killed', 'assist', 'ally_downed', 'agent_downed', 'ally_rescued', 'grenade_thrown', 'medical_kit_used', 'healing_applied', 'extra_health_granted', 'extra_health_changed', 'movement_snapshot', 'proximity_snapshot', 'firing_started', 'firing_stopped', 'reload_started', 'reload_completed', 'weapon_swapped', 'tick', ] as const; export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY = 'alliesWithin30MetersWithAgentGrantedBuff' as const; export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD = `values.${PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY}` as const; export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT = 'allies-within-30m-with-agent-granted-buff' as const; export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number]; export type MechanicEventDefinition = { key: MechanicEventKind; label: string; description: string; fields?: readonly { path: string; label: string; type: 'boolean' | 'number' | 'string'; required?: boolean; minimum?: number; integer?: boolean; }[]; strictFields?: boolean; }; export const MECHANIC_EVENT_REGISTRY: Readonly< Record > = { shot_fired: { key: 'shot_fired', label: 'Shot fired', description: 'A round leaves the equipped weapon; hit confirmation is not required.', fields: [ { path: 'values.hit', label: 'Shot hit', type: 'boolean', }, { path: 'values.magazineTwentyPercentBandsCrossed', label: '20% magazine bands crossed', type: 'number', }, ], }, weapon_hit: { key: 'weapon_hit', label: 'Weapon hit', description: 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, critical hits, aiming state, cover state, and distance. An optional area-target sample lists valid entities by distance from the hit target.', fields: [ { path: 'targetId', label: 'Target ID', type: 'string' }, { path: 'values.previousTargetId', label: 'Previous target ID', type: 'string', }, { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, { path: 'values.aiming', label: 'Aiming down sights', type: 'boolean' }, { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean', }, { path: 'values.distanceMeters', label: 'Target distance', type: 'number', }, ], }, skill_deployed: { key: 'skill_deployed', label: 'Skill deployed', description: 'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.', fields: [ { path: 'values.abilityId', label: 'Deployed ability ID', type: 'string', }, { path: 'values.assignedAbilityDurationSeconds', label: 'Assigned ability duration', type: 'number', minimum: 0, }, ], }, signature_ability_deployed: { key: 'signature_ability_deployed', label: 'Signature Ability deployed', description: 'The Agent deploys or activates their Signature Ability. An assigned-ability deployment may supply its active duration.', fields: [ { path: 'values.assignedAbilityDurationSeconds', label: 'Assigned ability duration', type: 'number', minimum: 0, }, ], }, skill_hit: { key: 'skill_hit', label: 'Skill hit', description: 'A deployed skill connects with a target. Critical and distance details belong in event values. An optional area-target sample lists valid entities by distance from the hit target.', fields: [ { path: 'values.abilityId', label: 'Source ability ID', type: 'string' }, { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, { path: 'values.assignedAbilityActive', label: 'Assigned ability active', type: 'boolean', }, { path: 'values.distanceMeters', label: 'Target distance', type: 'number', }, ], }, skill_multishot_triggered: { key: 'skill_multishot_triggered', label: 'Skill Multi-Shot triggered', description: 'A deployed skill triggers an additional Multi-Shot.', }, agent_hit: { key: 'agent_hit', label: 'Agent hit', description: 'An incoming attack connects, including hits absorbed entirely by Extra Health.', }, health_damage_taken: { key: 'health_damage_taken', label: 'Health damage taken', description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.', fields: [ { path: 'values.distanceMeters', label: 'Attacker distance', type: 'number', }, { path: 'values.recentSkillDeploymentSeconds', label: 'Seconds since relevant Skill deployment', type: 'number', }, ], }, damage_dealt: { key: 'damage_dealt', label: 'Damage dealt', description: 'The Agent deals damage; source, target, distance, and critical details belong in event values.', }, enemy_killed: { key: 'enemy_killed', label: 'Enemy killed', description: "An enemy dies. Weapon or skill source and whether the source item's assigned ability is active may be supplied when a mechanic requires the distinction.", fields: [ { path: 'values.headshot', label: 'Headshot killing shot', type: 'boolean', }, { path: 'values.critical', label: 'Critical killing shot', type: 'boolean', }, { path: 'values.source', label: 'Killing source', type: 'string' }, { path: 'values.assignedAbilityActive', label: 'Assigned ability active', type: 'boolean', }, { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean', }, { path: 'values.distanceMeters', label: 'Enemy distance', type: 'number', }, ], }, assist: { key: 'assist', label: 'Assist', description: "The Agent receives credit for assisting with an enemy defeat. The event may identify whether the source item's assigned ability is active.", fields: [ { path: 'values.assignedAbilityActive', label: 'Assigned ability active', type: 'boolean', }, { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean', }, ], }, ally_downed: { key: 'ally_downed', label: 'Ally downed', description: 'An allied Agent enters the downed state.', fields: [ { path: 'values.distanceMeters', label: 'Ally distance', type: 'number' }, ], }, agent_downed: { key: 'agent_downed', label: 'Agent downed', description: 'The Agent enters the downed state.', }, ally_rescued: { key: 'ally_rescued', label: 'Ally rescued', description: 'The Agent completes a rescue of a downed ally.', }, grenade_thrown: { key: 'grenade_thrown', label: 'Grenade thrown', description: 'The Agent throws a grenade and consumes it from inventory.', }, medical_kit_used: { key: 'medical_kit_used', label: 'Medical Kit used', description: 'The Agent consumes a Medical Kit.', }, healing_applied: { key: 'healing_applied', label: 'Healing applied', description: 'Health is restored; source and recipient details identify giving versus receiving healing.', }, extra_health_granted: { key: 'extra_health_granted', label: 'Extra Health granted', description: 'The Agent grants Extra Health to a recipient.', }, extra_health_changed: { key: 'extra_health_changed', label: 'Extra Health changed', description: 'The Agent’s remaining Extra Health changes, including grants, reductions, and the transition to zero.', strictFields: true, fields: [ { path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true, }, { path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0, }, ], }, movement_snapshot: { key: 'movement_snapshot', label: 'Movement snapshot', description: 'A one-second sample of the Agent movement state.', fields: [ { path: 'values.state', label: 'Movement state (immobile, moving, or sprinting)', type: 'string', }, ], }, proximity_snapshot: { key: 'proximity_snapshot', label: 'Proximity snapshot', description: 'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.', fields: [ { path: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD, label: 'Allies within 30 meters with an Agent-granted buff', type: 'number', minimum: 0, integer: true, }, ], }, firing_started: { key: 'firing_started', label: 'Firing started', description: 'The equipped weapon begins an uninterrupted firing segment.', }, firing_stopped: { key: 'firing_stopped', label: 'Firing stopped', description: 'The equipped weapon ends an uninterrupted firing segment.', }, reload_started: { key: 'reload_started', label: 'Reload started', description: 'The equipped weapon begins reloading.', fields: [ { path: 'values.emptyMagazine', label: 'Empty magazine', type: 'boolean', }, ], }, reload_completed: { key: 'reload_completed', label: 'Reload completed', description: 'The equipped weapon finishes reloading.', }, weapon_swapped: { key: 'weapon_swapped', label: 'Weapon swapped', description: 'The Agent changes the equipped active weapon.', }, tick: { key: 'tick', label: 'Simulation tick', description: 'A deterministic timeline sample used by duration and periodic mechanics.', }, }; export type Scalar = string | number | boolean | null; export type StateScope = 'agent' | 'target'; export type CalculationMode = 'maximum' | 'custom'; export type OutcomePhase = 'on_event' | 'after_event' | 'on_enter' | 'on_exit' | 'while_active'; export type MechanicAreaTarget = { entityId: string; population: 'ally' | 'enemy'; distanceMeters: number; }; export type MechanicAreaTargetSample = { center: 'event_target'; validTargets: MechanicAreaTarget[]; }; export const MAX_MECHANIC_AREA_TARGETS = 256; export type MechanicEvent = { kind: MechanicEventKind; at: number; actorId?: string | null; targetId?: string | null; values?: Record; areaTargetSample?: MechanicAreaTargetSample; }; export type ScenarioInputDefinition = { key: string; label: string; kind: 'toggle' | 'slider' | 'number'; readiness?: 'ready' | 'requires_assumption'; description?: string; defaultValue?: number | boolean; bestCaseValue?: number | boolean; min?: number; max?: number; }; export type MechanicScenario = | { mode: 'maximum'; encounterSeconds: number; randomSeed?: number } | { mode: 'custom'; encounterSeconds: number; inputs?: Record; randomSeed?: number; }; export type EntitySelector = | { kind: 'agent' } | { kind: 'event_actor' } | { kind: 'event_target' } | { kind: 'event_field'; path: string }; export type ValueReference = | { source: 'event'; path: string } | { source: 'scenario'; key: string } | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } | { source: 'attribute'; attributeId: string } | { source: 'equipment'; path: string } | { source: 'assigned_ability'; path: 'active' | 'remainingDurationSeconds'; } | { source: 'reducer' } | { source: 'pool'; pool: string; entity?: EntitySelector }; export const MECHANIC_COMBAT_REFERENCE_REGISTRY = { currentHealth: { label: 'Current Health', description: 'The Agent’s resolved current Health.', }, currentHealthPercent: { label: 'Current Health percentage', description: 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.', }, } as const; export type NumericExpression = | number | { ref: ValueReference } | { op: 'floor'; value: NumericExpression } | { op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max'; left: NumericExpression; right: NumericExpression; } | { op: 'clamp'; value: NumericExpression; min: NumericExpression; max: NumericExpression; }; export type Predicate = | { kind: 'true' } | { kind: 'exists'; value: ValueReference } | { kind: 'compare'; left: ValueReference | Scalar; operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; right: ValueReference | Scalar; } | { kind: 'all'; predicates: Predicate[] } | { kind: 'any'; predicates: Predicate[] } | { kind: 'not'; predicate: Predicate }; export type Reducer = | { kind: 'none' } | { kind: 'count'; threshold: number; reset?: 'zero' | 'subtract' | 'never'; groupBy?: ValueReference; } | { kind: 'modulo'; every: number; groupBy?: ValueReference } | { kind: 'distinct_count'; threshold: number; windowSeconds: number; distinctBy: ValueReference; } | { kind: 'consecutive_count'; threshold: number; groupBy?: ValueReference } | { kind: 'sliding_count'; threshold: number; windowSeconds: number; reset?: 'zero' | 'subtract' | 'never'; groupBy?: ValueReference; } | { kind: 'duration'; seconds: number; repeat?: boolean; groupBy?: ValueReference; } | { kind: 'periodic'; intervalSeconds: NumericExpression; immediate?: boolean; groupBy?: ValueReference; } | { kind: 'derived_scalar'; value: NumericExpression }; export type PoolDefinition = { key: string; label: string; scope: StateScope; initial: number; min?: number; max?: number; bestCaseValue?: number; expiresAfterSeconds?: number; }; type PoolMutationBase = { pool: string; entity?: EntitySelector; }; export type ClauseMutation = | { kind: 'none' } | (PoolMutationBase & { kind: 'add' | 'set' | 'consume' | 'derive'; value: NumericExpression; }) | (PoolMutationBase & { kind: 'clear' }) | { kind: 'transfer'; pool: string; from: EntitySelector; to: EntitySelector; value?: NumericExpression | 'all'; clearSource?: boolean; } | { kind: 'extend_duration'; target: 'assigned_ability'; seconds: NumericExpression; } | { kind: 'start_cooldown_on_max'; stackPool: string; cooldownPool: string; }; export type OutcomeBasis = | 'flat' | 'max_health_percent' | 'current_health_percent' | 'weapon_base_percent'; export type MechanicOutcomeScope = | 'self' | 'equipped_weapon' | 'inactive_weapon' | 'target' | 'allies' | 'self_and_allies' | 'assigned_ability' | 'all_skills'; export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge'; export type MechanicOutcomeTargeting = | { kind: 'within_radius'; population: 'allies' | 'enemies'; radiusMeters: number; } | { kind: 'event_target_area'; center: 'event_target'; validTargets: 'enemies'; radiusMeters: number; }; export type MechanicOutcome = { id: string; label: string; phase: OutcomePhase; kind: | 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'weapon_reload' | 'marker'; scope: MechanicOutcomeScope; value: NumericExpression; basis: OutcomeBasis; resource?: MechanicResource; delivery?: 'instant' | 'continuous'; durationSeconds?: number; combatTiming?: 'pre_event' | 'current'; attributeId?: string; statePool?: string; perStack?: boolean; minStacks?: number; maxStacks?: number; targeting?: MechanicOutcomeTargeting; probabilityPercent?: number; }; export type MechanicClause = { id: string; label?: string; recipeKey?: string; when: MechanicEventKind; if?: Predicate; reducer: Reducer; cooldownSeconds?: number; weight?: NumericExpression; procChancePercent?: number; requiresAssignedAbilityActive?: boolean; mutations: ClauseMutation[]; outcomes?: MechanicOutcome[]; }; export type MechanicProgram = { id: string; name: string; scenarioInputs?: ScenarioInputDefinition[]; pools?: PoolDefinition[]; clauses: MechanicClause[]; }; function mechanicDefinitionsEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; if (typeof left !== typeof right || left === null || right === null) return false; if (Array.isArray(left) || Array.isArray(right)) { if ( !Array.isArray(left) || !Array.isArray(right) || left.length !== right.length ) return false; return left.every((value, index) => mechanicDefinitionsEqual(value, right[index]), ); } if (typeof left !== 'object') return false; const leftRecord = left as Record; const rightRecord = right as Record; const leftKeys = Object.keys(leftRecord).sort(); const rightKeys = Object.keys(rightRecord).sort(); return ( leftKeys.length === rightKeys.length && leftKeys.every( (key, index) => key === rightKeys[index] && mechanicDefinitionsEqual(leftRecord[key], rightRecord[key]), ) ); } export type PoolSnapshot = { pool: string; scope: StateScope; entityId: string; value: number; expiresAt: number | null; }; export type MutationRecord = { kind: ClauseMutation['kind']; pool?: string; entityId?: string; before?: number; after?: number; targetEntityId?: string; targetBefore?: number; targetAfter?: number; }; export type MechanicEmission = { clauseId: string; at: number; weight: number; reducerValue: number; mutations: MutationRecord[]; }; export type MechanicOutcomeEmission = { clauseId: string; outcomeId: string; at: number; targetId: string | null; phase: OutcomePhase; kind: MechanicOutcome['kind']; scope: MechanicOutcome['scope']; resource: MechanicResource | null; attributeId: string | null; basis: OutcomeBasis; combatTiming: 'pre_event' | 'current'; value: number; resolvedValue: number; appliedValue: number; weight: number; stateValue: number; combatBefore: MechanicCombatContext | null; combatAfter: MechanicCombatContext | null; delivery: 'instant' | 'continuous'; deliveryStart: number | null; deliveryEnd: number | null; totalResolvedValue: number; targeting: MechanicOutcomeTargeting | null; probabilityPercent: number | null; probabilityRoll: number | null; }; export type MechanicEvaluation = { status: 'OK' | 'INVALID'; errors: string[]; programId: string; mode: CalculationMode; scenarioInputs: Record; readiness: { status: 'READY' | 'REQUIRES_ASSUMPTIONS'; missingInputs: string[]; }; emissions: MechanicEmission[]; outcomes: MechanicOutcomeEmission[]; finalState: PoolSnapshot[]; finalCombat: MechanicCombatContext | null; equipment: MechanicEquipmentContext | null; estimatedActivationCount: number; randomSeed: number | null; script: string; }; export type MechanicCombatContext = { maxHealth: number; currentHealth: number; maxArmor?: number; currentArmor?: number; maxAmmo?: number; currentAmmo?: number; maxSignatureAbilityCharge?: number; currentSignatureAbilityCharge?: number; weaponBaseDamage?: number; attributes?: Record; values?: Record; }; export type MechanicEquipmentContext = { protocolTalentUpgrades?: number; assignedAbility?: { active: boolean; remainingDurationSeconds: number; }; values?: Record; }; export type ValidationResult = { ok: true; value: T } | { ok: false; errors: string[] }; type RuntimePool = PoolSnapshot; type RuntimeAssignedAbility = { active: boolean; expiresAt: number | null; }; type EvaluationContext = { event: MechanicEvent; scenario: Record; reducerValue: number; pools: Map; poolDefinitions: Map; combat: MechanicCombatContext | null; preEventCombat: MechanicCombatContext | null; equipment: MechanicEquipmentContext | null; assignedAbility: RuntimeAssignedAbility; mode: CalculationMode; random: DeterministicRandom; runtimeErrors: string[]; }; type ContinuousDelivery = { clauseId: string; outcomeId: string; targetId: string | null; phase: OutcomePhase; scope: MechanicOutcomeScope; resource: MechanicResource; attributeId: string | null; basis: OutcomeBasis; combatTiming: 'pre_event' | 'current'; value: number; totalResolvedValue: number; weight: number; stateValue: number; start: number; end: number; appliedThrough: number; targeting: MechanicOutcomeTargeting | null; probabilityPercent: number | null; probabilityRoll: number | null; }; type DeterministicRandom = { next: () => number; }; type ReducerState = { count: number; timestamps: number[]; distinctTimestamps: Map; previousGroup: string | null; startedAt: number | null; lastPeriodicIndex: number; durationEmitted: boolean; }; type ReducerEmission = { at: number; value: number }; const AGENT_ENTITY = 'agent'; const DEFAULT_TARGET_ENTITY = 'target'; const EPSILON = 0.000_001; const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const objectValue = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const formatNumber = (value: number) => Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))); const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); function hashSeed(value: string) { let hash = 2_166_136_261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16_777_619); } return hash >>> 0; } function deterministicRandom(seed: number): DeterministicRandom { let state = seed >>> 0; return { next: () => { state = (state + 0x6d2b79f5) >>> 0; let value = state; value = Math.imul(value ^ (value >>> 15), value | 1); value ^= value + Math.imul(value ^ (value >>> 7), value | 61); return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; }, }; } function getPath(source: unknown, path: string): unknown { let current = source; for (const segment of path.split('.').filter(Boolean)) { if (typeof current !== 'object' || current === null) return undefined; current = (current as Record)[segment]; } return current; } function entityId( selector: EntitySelector | undefined, event: MechanicEvent, scope: StateScope, ): string | null { if (!selector) return scope === 'agent' ? AGENT_ENTITY : (event.targetId ?? null); switch (selector.kind) { case 'agent': return AGENT_ENTITY; case 'event_actor': return event.actorId ?? AGENT_ENTITY; case 'event_target': return event.targetId ?? null; case 'event_field': { const value = getPath(event, selector.path); return typeof value === 'string' && value.trim() ? value : null; } } } function poolStorageKey(pool: string, entity: string) { return `${pool}\u0000${entity}`; } function stateValue( pool: string, selector: EntitySelector | undefined, context: EvaluationContext, ): number { const definition = context.poolDefinitions.get(pool); if (!definition) return 0; const entity = entityId(selector, context.event, definition.scope); if (!entity) return definition.initial; return ( context.pools.get(poolStorageKey(pool, entity))?.value ?? definition.initial ); } function resolveReference( reference: ValueReference, context: EvaluationContext, ): unknown { switch (reference.source) { case 'event': return getPath(context.event, reference.path); case 'scenario': return context.scenario[reference.key]; case 'combat': { const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat; if (reference.path === 'currentHealthPercent') { if ( !combat || !finiteNumber(combat.maxHealth) || combat.maxHealth <= 0 || !finiteNumber(combat.currentHealth) ) return undefined; return clamp((combat.currentHealth / combat.maxHealth) * 100, 0, 100); } return getPath(combat, reference.path); } case 'attribute': return context.combat?.attributes?.[reference.attributeId]; case 'equipment': return getPath(context.equipment, reference.path); case 'assigned_ability': { const remaining = Math.max( 0, (context.assignedAbility.expiresAt ?? context.event.at) - context.event.at, ); if (reference.path === 'active') return context.assignedAbility.active && remaining > EPSILON; return remaining; } case 'reducer': return context.reducerValue; case 'pool': return stateValue(reference.pool, reference.entity, context); } } function numericValue( expression: NumericExpression, context: EvaluationContext, ): number { if (typeof expression === 'number') return expression; if ('ref' in expression) { const value = resolveReference(expression.ref, context); return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0; } if (expression.op === 'floor') return Math.floor(numericValue(expression.value, context)); if (expression.op === 'clamp') { return clamp( numericValue(expression.value, context), numericValue(expression.min, context), numericValue(expression.max, context), ); } const left = numericValue(expression.left, context); const right = numericValue(expression.right, context); switch (expression.op) { case 'add': return left + right; case 'subtract': return left - right; case 'multiply': return left * right; case 'divide': return Math.abs(right) <= EPSILON ? 0 : left / right; case 'min': return Math.min(left, right); case 'max': return Math.max(left, right); } } function operandValue( operand: ValueReference | Scalar, context: EvaluationContext, ): unknown { if (typeof operand === 'object' && operand !== null && 'source' in operand) { return resolveReference(operand, context); } return operand; } function predicateMatches( predicate: Predicate | undefined, context: EvaluationContext, ): boolean { if (!predicate || predicate.kind === 'true') return true; if (predicate.kind === 'exists') { const value = resolveReference(predicate.value, context); return value !== undefined && value !== null; } if (predicate.kind === 'all') return predicate.predicates.every((item) => predicateMatches(item, context), ); if (predicate.kind === 'any') return predicate.predicates.some((item) => predicateMatches(item, context)); if (predicate.kind === 'not') return !predicateMatches(predicate.predicate, context); const left = operandValue(predicate.left, context); const right = operandValue(predicate.right, context); switch (predicate.operator) { case 'eq': return left === right; case 'ne': return left !== right; case 'gt': return ( typeof left === 'number' && typeof right === 'number' && left > right ); case 'gte': return ( typeof left === 'number' && typeof right === 'number' && left >= right ); case 'lt': return ( typeof left === 'number' && typeof right === 'number' && left < right ); case 'lte': return ( typeof left === 'number' && typeof right === 'number' && left <= right ); } } function scenarioValues( program: MechanicProgram, scenario: MechanicScenario, ): { values: Record; missing: string[] } { const supplied = scenario.mode === 'custom' ? (scenario.inputs ?? {}) : {}; const values: Record = {}; const missing: string[] = []; for (const input of program.scenarioInputs ?? []) { const value = scenario.mode === 'maximum' ? input.bestCaseValue : (supplied[input.key] ?? input.defaultValue); if (value === undefined) { missing.push(input.key); continue; } values[input.key] = value; } return { values, missing }; } function freshReducerState(): ReducerState { return { count: 0, timestamps: [], distinctTimestamps: new Map(), previousGroup: null, startedAt: null, lastPeriodicIndex: -1, durationEmitted: false, }; } function reducerGroup(reducer: Reducer, context: EvaluationContext): string { if (!('groupBy' in reducer) || !reducer.groupBy) return 'global'; const value = resolveReference(reducer.groupBy, context); return value === undefined || value === null ? 'missing' : String(value); } function resetCount( state: ReducerState, threshold: number, reset: 'zero' | 'subtract' | 'never', ) { if (reset === 'zero') state.count = 0; else if (reset === 'subtract') state.count = Math.max(0, state.count - threshold); } function reduceEvent( reducer: Reducer, matched: boolean, event: MechanicEvent, context: EvaluationContext, state: ReducerState, ): ReducerEmission[] { if (reducer.kind === 'none') return matched ? [{ at: event.at, value: 1 }] : []; if (reducer.kind === 'derived_scalar') { return matched ? [{ at: event.at, value: numericValue(reducer.value, context) }] : []; } if (reducer.kind === 'count') { if (!matched) return []; state.count += 1; if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) return []; if (state.count < reducer.threshold) return []; const value = state.count; if ((reducer.reset ?? 'zero') === 'never') state.durationEmitted = true; resetCount(state, reducer.threshold, reducer.reset ?? 'zero'); return [{ at: event.at, value }]; } if (reducer.kind === 'modulo') { if (!matched) return []; state.count += 1; return state.count % reducer.every === 0 ? [{ at: event.at, value: state.count }] : []; } if (reducer.kind === 'distinct_count') { if (!matched) return []; const value = resolveReference(reducer.distinctBy, context); if (value === undefined || value === null) return []; for (const [key, at] of state.distinctTimestamps) { if (event.at - at > reducer.windowSeconds + EPSILON) state.distinctTimestamps.delete(key); } state.distinctTimestamps.set(String(value), event.at); if (state.distinctTimestamps.size < reducer.threshold) return []; const count = state.distinctTimestamps.size; state.distinctTimestamps.clear(); return [{ at: event.at, value: count }]; } if (reducer.kind === 'consecutive_count') { if (!matched) { state.count = 0; state.previousGroup = null; return []; } const value = reducer.groupBy ? resolveReference(reducer.groupBy, context) : 'global'; if (value === undefined || value === null) { state.count = 0; state.previousGroup = null; return []; } const group = String(value); state.count = state.previousGroup === group ? state.count + 1 : 1; state.previousGroup = group; if (state.count < reducer.threshold) return []; const count = state.count; state.count = 0; return [{ at: event.at, value: count }]; } if (reducer.kind === 'sliding_count') { if (!matched) return []; state.timestamps = state.timestamps.filter( (at) => event.at - at <= reducer.windowSeconds + EPSILON, ); state.timestamps.push(event.at); state.count = state.timestamps.length; if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) return []; if (state.count < reducer.threshold) return []; const value = state.count; const reset = reducer.reset ?? 'zero'; if (reset === 'never') state.durationEmitted = true; if (reset === 'zero') state.timestamps = []; else if (reset === 'subtract') state.timestamps.splice(0, reducer.threshold); state.count = state.timestamps.length; return [{ at: event.at, value }]; } if (reducer.kind === 'duration') { if (!matched) { state.startedAt = null; state.durationEmitted = false; return []; } if (state.startedAt === null) state.startedAt = event.at; const elapsed = event.at - state.startedAt; if (elapsed + EPSILON < reducer.seconds) return []; if (!reducer.repeat && state.durationEmitted) return []; const index = Math.floor((elapsed + EPSILON) / reducer.seconds); if (reducer.repeat && index <= state.lastPeriodicIndex) return []; state.durationEmitted = true; state.lastPeriodicIndex = index; return [{ at: event.at, value: elapsed }]; } if (!matched) { state.startedAt = null; state.lastPeriodicIndex = -1; return []; } if (state.startedAt === null) { state.startedAt = event.at; state.lastPeriodicIndex = reducer.immediate ? 0 : -1; return reducer.immediate ? [{ at: event.at, value: 1 }] : []; } const intervalSeconds = numericValue(reducer.intervalSeconds, context); if (!finiteNumber(intervalSeconds) || intervalSeconds <= 0) return []; const elapsed = event.at - state.startedAt; const index = Math.floor((elapsed + EPSILON) / intervalSeconds); const firstIndex = Math.max(state.lastPeriodicIndex + 1, 1); const emissions: ReducerEmission[] = []; for (let current = firstIndex; current <= index; current += 1) { emissions.push({ at: state.startedAt + current * intervalSeconds, value: current, }); } state.lastPeriodicIndex = Math.max(state.lastPeriodicIndex, index); return emissions; } function setPoolValue( definition: PoolDefinition, entity: string, value: number, at: number, pools: Map, ): { before: number; after: number } { const key = poolStorageKey(definition.key, entity); const current = pools.get(key); const before = current?.value ?? definition.initial; const after = clamp( Number.isFinite(value) ? value : before, definition.min ?? Number.NEGATIVE_INFINITY, definition.max ?? Number.POSITIVE_INFINITY, ); pools.set(key, { pool: definition.key, scope: definition.scope, entityId: entity, value: after, expiresAt: definition.expiresAfterSeconds === undefined ? null : at + definition.expiresAfterSeconds, }); return { before, after }; } function applyMutation( mutation: ClauseMutation, at: number, context: EvaluationContext, weight = 1, ): MutationRecord { if (mutation.kind === 'none') return { kind: 'none' }; if (mutation.kind === 'extend_duration') { const before = Math.max(0, (context.assignedAbility.expiresAt ?? at) - at); if (!context.assignedAbility.active || before <= EPSILON) { context.assignedAbility.active = false; context.assignedAbility.expiresAt = null; return { kind: mutation.kind }; } const seconds = numericValue(mutation.seconds, context); if (seconds <= 0) return { kind: mutation.kind, before, after: before }; context.assignedAbility.expiresAt = (context.assignedAbility.expiresAt as number) + seconds * clamp(weight, 0, 1); return { kind: mutation.kind, before, after: Math.max(0, context.assignedAbility.expiresAt - at), }; } if (mutation.kind === 'start_cooldown_on_max') return { kind: mutation.kind }; const definition = context.poolDefinitions.get(mutation.pool); if (!definition) return { kind: mutation.kind, pool: mutation.pool }; const mutationWeight = clamp(weight, 0, 1); if (mutation.kind === 'transfer') { const from = entityId(mutation.from, context.event, definition.scope); const to = entityId(mutation.to, context.event, definition.scope); if (!from || !to) return { kind: 'transfer', pool: mutation.pool }; const sourceBefore = stateValue(mutation.pool, mutation.from, context); const targetBefore = stateValue(mutation.pool, mutation.to, context); const requested = mutation.value === undefined || mutation.value === 'all' ? sourceBefore : numericValue(mutation.value, context); const amount = clamp(requested, 0, sourceBefore) * mutationWeight; const sourceAfter = mutation.clearSource === false ? sourceBefore : sourceBefore - amount; const targetAfter = targetBefore + amount; if (mutationWeight > 0) { setPoolValue(definition, from, sourceAfter, at, context.pools); setPoolValue(definition, to, targetAfter, at, context.pools); } return { kind: 'transfer', pool: mutation.pool, entityId: from, before: sourceBefore, after: mutationWeight > 0 ? stateValue(mutation.pool, mutation.from, context) : sourceBefore, targetEntityId: to, targetBefore, targetAfter: mutationWeight > 0 ? stateValue(mutation.pool, mutation.to, context) : targetBefore, }; } const entity = entityId(mutation.entity, context.event, definition.scope); if (!entity) return { kind: mutation.kind, pool: mutation.pool }; const before = stateValue(mutation.pool, mutation.entity, context); let requested = before; if (mutation.kind === 'clear') requested = definition.initial; else { const value = numericValue(mutation.value, context); if (mutation.kind === 'add') requested = before + value; else if (mutation.kind === 'consume') requested = before - value; else requested = value; } const weightedRequest = before + (requested - before) * mutationWeight; const changed = mutationWeight > 0 ? setPoolValue(definition, entity, weightedRequest, at, context.pools) : { before, after: before }; return { kind: mutation.kind, pool: mutation.pool, entityId: entity, before: changed.before, after: changed.after, }; } function startCooldownOnMax( mutation: Extract, context: EvaluationContext, priorMutations: MutationRecord[], at: number, ): MutationRecord { const stackDefinition = context.poolDefinitions.get(mutation.stackPool); const cooldownDefinition = context.poolDefinitions.get(mutation.cooldownPool); const reachedMaximum = priorMutations.some( (record) => record.pool === mutation.stackPool && record.before !== undefined && record.after !== undefined && record.before < (stackDefinition?.max ?? Number.POSITIVE_INFINITY) && record.after >= (stackDefinition?.max ?? Number.POSITIVE_INFINITY), ); if (!reachedMaximum || !cooldownDefinition) return { kind: mutation.kind, pool: mutation.cooldownPool }; const entity = cooldownDefinition.scope === 'agent' ? AGENT_ENTITY : context.event.targetId; if (!entity) return { kind: mutation.kind, pool: mutation.cooldownPool }; const changed = setPoolValue( cooldownDefinition, entity, 1, at, context.pools, ); return { kind: mutation.kind, pool: mutation.cooldownPool, entityId: entity, before: changed.before, after: changed.after, }; } function outcomeState( outcome: MechanicOutcome, context: EvaluationContext, ): number { return outcome.statePool ? stateValue(outcome.statePool, undefined, context) : 1; } function phaseMatches( phase: OutcomePhase, stateBefore: number, stateAfter: number, ) { if (phase === 'on_event' || phase === 'after_event') return true; if (phase === 'on_enter') return stateBefore <= 0 && stateAfter > 0; if (phase === 'on_exit') return stateBefore > 0 && stateAfter <= 0; return stateAfter > 0; } function copyCombat( combat: MechanicCombatContext | null, ): MechanicCombatContext | null { return combat ? { ...combat, attributes: combat.attributes ? { ...combat.attributes } : undefined, values: combat.values ? { ...combat.values } : undefined, } : null; } function resolveOutcomeValue( outcome: MechanicOutcome, value: number, context: EvaluationContext, ): number { if (outcome.basis === 'flat') return value; const combat = outcome.combatTiming === 'pre_event' ? context.preEventCombat : context.combat; if (!combat) return 0; if (outcome.basis === 'max_health_percent') return (combat.maxHealth * value) / 100; if (outcome.basis === 'current_health_percent') return (combat.currentHealth * value) / 100; return ((combat.weaponBaseDamage ?? 0) * value) / 100; } function outcomeTargetIds( clause: MechanicClause, outcome: MechanicOutcome, context: EvaluationContext, ): Array | null { if (outcome.targeting?.kind !== 'event_target_area') { return [context.event.targetId ?? null]; } const sample = context.event.areaTargetSample; if (!context.event.targetId || !sample) { const error = `Clause "${clause.id}" outcome "${outcome.id}" requires an authoritative event-target area sample.`; if (!context.runtimeErrors.includes(error)) context.runtimeErrors.push(error); return null; } return sample.validTargets .filter( (target) => target.population === 'enemy' && target.distanceMeters <= outcome.targeting!.radiusMeters, ) .map((target) => target.entityId) .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); } function outcomeResource(outcome: MechanicOutcome): MechanicResource { return outcome.resource ?? 'health'; } function combatResource( combat: MechanicCombatContext, resource: MechanicResource, ): { current: number; maximum: number; setCurrent: (value: number) => void; } | null { if (resource === 'health') { return { current: combat.currentHealth, maximum: combat.maxHealth, setCurrent: (value) => { combat.currentHealth = value; }, }; } if ( resource === 'armor' && finiteNumber(combat.currentArmor) && finiteNumber(combat.maxArmor) ) { return { current: combat.currentArmor, maximum: combat.maxArmor, setCurrent: (value) => { combat.currentArmor = value; }, }; } if ( resource === 'ammo' && finiteNumber(combat.currentAmmo) && finiteNumber(combat.maxAmmo) ) { return { current: combat.currentAmmo, maximum: combat.maxAmmo, setCurrent: (value) => { combat.currentAmmo = value; }, }; } if (resource === 'signature_ability_charge') { const maximum = finiteNumber(combat.maxSignatureAbilityCharge) ? combat.maxSignatureAbilityCharge : 1000; const current = finiteNumber(combat.currentSignatureAbilityCharge) ? combat.currentSignatureAbilityCharge : 0; return { current, maximum, setCurrent: (value) => { combat.currentSignatureAbilityCharge = value; }, }; } return null; } function emitOutcomes( clause: MechanicClause, phase: 'before' | 'after', at: number, weight: number, before: Map, context: EvaluationContext, continuousDeliveries?: ContinuousDelivery[], ): MechanicOutcomeEmission[] { const emissions: MechanicOutcomeEmission[] = []; const stateValues = (outcome: MechanicOutcome) => { const current = outcomeState(outcome, context); const previous = outcome.statePool ? (before.get(outcome.statePool) ?? current) : current; return { current, previous, effective: outcome.phase === 'on_exit' ? previous : current, }; }; const eligible = (clause.outcomes ?? []) .filter((outcome) => { if (phase === 'before' && outcome.phase !== 'on_event') return false; if (phase === 'after' && outcome.phase === 'on_event') return false; const { current, previous, effective } = stateValues(outcome); if (!phaseMatches(outcome.phase, previous, current)) return false; if (outcome.minStacks !== undefined && effective < outcome.minStacks) return false; if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) return false; return true; }) .sort((left, right) => { const priority = (outcome: MechanicOutcome) => outcome.kind === 'resource_cost' ? 0 : outcome.kind === 'restoration' ? 2 : 1; return priority(left) - priority(right); }); for (const outcome of eligible) { const { effective: current } = stateValues(outcome); const probabilityPercent = outcome.probabilityPercent ?? null; const probabilityRoll = probabilityPercent === null ? null : context.mode === 'maximum' ? 0 : context.random.next() * 100; if ( probabilityPercent !== null && (probabilityPercent <= 0 || (context.mode !== 'maximum' && (probabilityRoll as number) >= probabilityPercent)) ) { continue; } const baseValue = numericValue(outcome.value, context); const value = outcome.perStack ? baseValue * current : baseValue; const resolvedValue = resolveOutcomeValue(outcome, value, context); const combatBefore = copyCombat(context.combat); let appliedValue = resolvedValue * weight; const resource = outcome.kind === 'restoration' || outcome.kind === 'resource_cost' ? outcomeResource(outcome) : null; const targetResource = context.combat && resource ? combatResource(context.combat, resource) : null; if (outcome.kind === 'restoration' && outcome.delivery === 'continuous') { const durationSeconds = outcome.durationSeconds as number; continuousDeliveries?.push({ clauseId: clause.id, outcomeId: outcome.id, targetId: context.event.targetId ?? null, phase: outcome.phase, scope: outcome.scope, resource: outcomeResource(outcome), attributeId: outcome.attributeId ?? null, basis: outcome.basis, combatTiming: outcome.combatTiming ?? 'current', value, totalResolvedValue: Math.max(0, resolvedValue * weight), weight, stateValue: current, start: at, end: at + durationSeconds, appliedThrough: at, targeting: outcome.targeting ?? null, probabilityPercent, probabilityRoll, }); continue; } if (outcome.kind === 'resource_cost' && targetResource) { appliedValue = Math.min( targetResource.current, Math.max(0, appliedValue), ); targetResource.setCurrent(targetResource.current - appliedValue); } else if (outcome.kind === 'restoration' && targetResource) { appliedValue = Math.min( targetResource.maximum - targetResource.current, Math.max(0, appliedValue), ); targetResource.setCurrent(targetResource.current + appliedValue); } else if ( (outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource ) { appliedValue = 0; } const targetIds = outcomeTargetIds(clause, outcome, context); if (!targetIds) continue; const emittedAt = outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at; for (const targetId of targetIds) { emissions.push({ clauseId: clause.id, outcomeId: outcome.id, at: emittedAt, targetId, phase: outcome.phase, kind: outcome.kind, scope: outcome.scope, resource, attributeId: outcome.attributeId ?? null, basis: outcome.basis, combatTiming: outcome.combatTiming ?? 'current', value, resolvedValue, appliedValue, weight, stateValue: current, combatBefore, combatAfter: copyCombat(context.combat), delivery: 'instant', deliveryStart: null, deliveryEnd: null, totalResolvedValue: resolvedValue * weight, targeting: outcome.targeting ?? null, probabilityPercent, probabilityRoll, }); } } return emissions; } function appendScheduledOutcomes( target: MechanicOutcomeEmission[], candidates: MechanicOutcomeEmission[], triggeredAt: number, encounterSeconds: number, ) { for (const candidate of candidates) { if (candidate.kind !== 'weapon_reload') { target.push(candidate); continue; } for (let index = target.length - 1; index >= 0; index -= 1) { const pending = target[index]; if ( pending.kind === 'weapon_reload' && pending.clauseId === candidate.clauseId && pending.outcomeId === candidate.outcomeId && pending.scope === candidate.scope && pending.at > triggeredAt + EPSILON ) { target.splice(index, 1); } } if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate); } } function advanceContinuousDeliveries( at: number, deliveries: ContinuousDelivery[], combat: MechanicCombatContext | null, ): MechanicOutcomeEmission[] { const emissions: MechanicOutcomeEmission[] = []; for (const delivery of deliveries) { const through = Math.min(at, delivery.end); const elapsed = through - delivery.appliedThrough; if (elapsed <= EPSILON) continue; const duration = delivery.end - delivery.start; const resolvedValue = (delivery.totalResolvedValue * elapsed) / duration; const combatBefore = copyCombat(combat); const targetResource = combat ? combatResource(combat, delivery.resource) : null; let appliedValue = 0; if (targetResource) { appliedValue = Math.min( targetResource.maximum - targetResource.current, Math.max(0, resolvedValue), ); targetResource.setCurrent(targetResource.current + appliedValue); } delivery.appliedThrough = through; emissions.push({ clauseId: delivery.clauseId, outcomeId: delivery.outcomeId, at: through, targetId: delivery.targetId, phase: delivery.phase, kind: 'restoration', scope: delivery.scope, resource: delivery.resource, attributeId: delivery.attributeId, basis: delivery.basis, combatTiming: delivery.combatTiming, value: delivery.value, resolvedValue, appliedValue, weight: delivery.weight, stateValue: delivery.stateValue, combatBefore, combatAfter: copyCombat(combat), delivery: 'continuous', deliveryStart: delivery.start, deliveryEnd: delivery.end, totalResolvedValue: delivery.totalResolvedValue, targeting: delivery.targeting, probabilityPercent: delivery.probabilityPercent, probabilityRoll: delivery.probabilityRoll, }); } for (let index = deliveries.length - 1; index >= 0; index -= 1) { if (deliveries[index].appliedThrough >= deliveries[index].end - EPSILON) { deliveries.splice(index, 1); } } return emissions; } function expirePools( at: number, clauses: MechanicClause[], scenario: Record, pools: Map, definitions: Map, combat: MechanicCombatContext | null, equipment: MechanicEquipmentContext | null, assignedAbility: RuntimeAssignedAbility, mode: CalculationMode, random: DeterministicRandom, continuousDeliveries: ContinuousDelivery[], runtimeErrors: string[], ): MechanicOutcomeEmission[] { const expired = [...pools.entries()] .filter( ([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON, ) .sort( (left, right) => (left[1].expiresAt ?? 0) - (right[1].expiresAt ?? 0) || left[0].localeCompare(right[0]), ); const emissions: MechanicOutcomeEmission[] = []; let index = 0; while (index < expired.length) { const expiryAt = expired[index][1].expiresAt as number; emissions.push( ...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat), ); const group: typeof expired = []; while ( index < expired.length && Math.abs((expired[index][1].expiresAt as number) - expiryAt) <= EPSILON ) { group.push(expired[index]); index += 1; } const preEventCombat = copyCombat(combat); for (const [key, state] of group) { const definition = definitions.get(state.pool); if (!definition) continue; pools.set(key, { ...state, value: definition.initial, expiresAt: null }); } for (const [, state] of group) { const definition = definitions.get(state.pool); if (!definition) continue; const event: MechanicEvent = { kind: 'tick', at: expiryAt, actorId: AGENT_ENTITY, targetId: definition.scope === 'target' ? state.entityId : DEFAULT_TARGET_ENTITY, }; const context: EvaluationContext = { event, scenario, reducerValue: 0, pools, poolDefinitions: definitions, combat, preEventCombat, equipment, assignedAbility, mode, random, runtimeErrors, }; const before = new Map([[state.pool, state.value]]); for (const clause of clauses) { const exitOutcomes = (clause.outcomes ?? []).filter( (outcome) => outcome.phase === 'on_exit' && outcome.statePool === state.pool, ); if (!exitOutcomes.length) continue; emissions.push( ...emitOutcomes( { ...clause, outcomes: exitOutcomes }, 'after', expiryAt, 1, before, context, continuousDeliveries, ), ); } } } return emissions; } function initialPools( definitions: Map, mode: CalculationMode, ): Map { const pools = new Map(); for (const definition of definitions.values()) { if (mode === 'custom' && definition.scope === 'target') continue; const entity = definition.scope === 'agent' ? AGENT_ENTITY : DEFAULT_TARGET_ENTITY; const value = mode === 'maximum' ? (definition.bestCaseValue ?? definition.max ?? definition.initial) : definition.initial; pools.set(poolStorageKey(definition.key, entity), { pool: definition.key, scope: definition.scope, entityId: entity, value, expiresAt: null, }); } return pools; } function invalidEvaluation( program: MechanicProgram, scenario: MechanicScenario, errors: string[], ): MechanicEvaluation { return { status: 'INVALID', errors, programId: program.id, mode: scenario.mode, scenarioInputs: {}, readiness: { status: 'REQUIRES_ASSUMPTIONS', missingInputs: [] }, emissions: [], outcomes: [], finalState: [], finalCombat: null, equipment: null, estimatedActivationCount: 0, randomSeed: null, script: formatMechanicProgram(program), }; } function validateReference( reference: ValueReference, pools: Set, inputs: Set, errors: string[], path: string, ) { if (reference.source === 'pool' && !pools.has(reference.pool)) { errors.push(`${path} references unknown pool "${reference.pool}".`); } if (reference.source === 'scenario' && !inputs.has(reference.key)) { errors.push( `${path} references unknown scenario input "${reference.key}".`, ); } if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`); if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`); if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`); if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`); if ( reference.source === 'assigned_ability' && !['active', 'remainingDurationSeconds'].includes(reference.path) ) { errors.push(`${path} references an unsupported assigned-ability field.`); } } function validateStrictEventReferences( value: unknown, eventKind: MechanicEventKind, errors: string[], path: string, ) { const definition = MECHANIC_EVENT_REGISTRY[eventKind]; if (!definition || value === null || typeof value !== 'object') return; if ( 'source' in value && value.source === 'event' && 'path' in value && typeof value.path === 'string' && !definition.fields?.some((field) => field.path === value.path) && definition.strictFields ) { errors.push( `${path} references event field "${value.path}" that is not defined for ${definition.label}.`, ); } for (const [key, child] of Object.entries(value)) { validateStrictEventReferences(child, eventKind, errors, `${path} ${key}`); } } function containsEventReference(value: unknown, path: string): boolean { if (!value || typeof value !== 'object') return false; if ( 'source' in value && value.source === 'event' && 'path' in value && value.path === path ) { return true; } return Object.values(value).some((child) => containsEventReference(child, path), ); } function validateStrictEventPredicate( predicate: Predicate | undefined, eventKind: MechanicEventKind, errors: string[], path: string, ) { const definition = MECHANIC_EVENT_REGISTRY[eventKind]; if (!definition || !predicate) return; if (predicate.kind === 'not') { if ( eventKind === 'weapon_hit' && containsEventReference(predicate.predicate, 'values.aiming') ) { errors.push( `${path} must compare Aiming down sights explicitly instead of negating missing evidence.`, ); } validateStrictEventPredicate( predicate.predicate, eventKind, errors, `${path} NOT`, ); return; } if (predicate.kind === 'all' || predicate.kind === 'any') { predicate.predicates.forEach((item, index) => validateStrictEventPredicate( item, eventKind, errors, `${path} ${index + 1}`, ), ); return; } if (predicate.kind !== 'compare') return; const eventReference = typeof predicate.left === 'object' && predicate.left?.source === 'event' ? predicate.left : typeof predicate.right === 'object' && predicate.right?.source === 'event' ? predicate.right : null; const scalar = eventReference === predicate.left ? predicate.right : predicate.left; if (!eventReference) return; if ( eventKind === 'weapon_hit' && eventReference.path === 'values.aiming' && (predicate.operator !== 'eq' || typeof scalar !== 'boolean') ) { errors.push( `${path} must compare Aiming down sights explicitly with true or false.`, ); } if (typeof scalar === 'object') return; const field = definition.fields?.find( (item) => item.path === eventReference.path, ); if (!field || scalar === null) return; if (typeof scalar !== field.type) { errors.push( `${path} must compare ${field.label} with a ${field.type} value.`, ); } if (field.type !== 'number' && !['eq', 'ne'].includes(predicate.operator)) { errors.push(`${path} must use equality comparison for ${field.label}.`); } } function validateMechanicEvents(events: MechanicEvent[]) { const errors: string[] = []; for (const [index, event] of events.entries()) { const definition = MECHANIC_EVENT_REGISTRY[event.kind]; if (!definition) continue; for (const field of definition.fields ?? []) { const value = getPath(event, field.path); const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`; if (value === undefined || value === null) { if (field.required) errors.push(`${fieldPath} is required.`); continue; } if ( typeof value !== field.type || (field.type === 'number' && !finiteNumber(value)) ) { errors.push(`${fieldPath} must be a ${field.type}.`); continue; } if ( field.type === 'number' && field.minimum !== undefined && (value as number) < field.minimum ) { errors.push(`${fieldPath} must be ${field.minimum} or greater.`); } if ( field.type === 'number' && field.integer && !Number.isInteger(value) ) { errors.push(`${fieldPath} must be an integer.`); } } const hasExtraHealth = getPath(event, 'values.hasExtraHealth'); const currentExtraHealth = getPath(event, 'values.currentExtraHealth'); if ( event.kind === 'extra_health_changed' && typeof hasExtraHealth === 'boolean' && finiteNumber(currentExtraHealth) && hasExtraHealth !== currentExtraHealth > 0 ) { errors.push( `Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`, ); } const sample = event.areaTargetSample as unknown; if (sample === undefined) continue; const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`; if ( !['weapon_hit', 'skill_hit'].includes(event.kind) || !objectValue(sample) || sample.center !== 'event_target' || !Array.isArray(sample.validTargets) || typeof event.targetId !== 'string' || !event.targetId.trim() ) { errors.push( `${samplePath} requires a weapon-hit or skill-hit event target and event-target-centered valid targets.`, ); continue; } if (sample.validTargets.length > MAX_MECHANIC_AREA_TARGETS) { errors.push( `${samplePath} cannot contain more than ${MAX_MECHANIC_AREA_TARGETS} valid targets.`, ); continue; } const seen = new Set(); let hasCenterTarget = false; for (const [targetIndex, rawTarget] of sample.validTargets.entries()) { const targetPath = `${samplePath} target ${targetIndex + 1}`; if ( !objectValue(rawTarget) || typeof rawTarget.entityId !== 'string' || !rawTarget.entityId.trim() || !['ally', 'enemy'].includes(String(rawTarget.population)) || !finiteNumber(rawTarget.distanceMeters) || rawTarget.distanceMeters < 0 ) { errors.push( `${targetPath} requires an entity ID, ally or enemy population, and a non-negative distance.`, ); continue; } if (seen.has(rawTarget.entityId)) { errors.push(`${targetPath} duplicates entity "${rawTarget.entityId}".`); } seen.add(rawTarget.entityId); if ( rawTarget.entityId === event.targetId && rawTarget.population === 'enemy' && rawTarget.distanceMeters === 0 ) { hasCenterTarget = true; } } if (!hasCenterTarget) { errors.push( `${samplePath} must include the event target as an enemy at 0 meters.`, ); } } return errors; } export function mechanicOutcomeTargetingError(outcome: { kind: MechanicOutcome['kind']; scope: MechanicOutcomeScope; phase: OutcomePhase; targeting?: unknown; }): string | null { const targeting = outcome.targeting; if (targeting === undefined) return null; if (!objectValue(targeting)) return 'has invalid radius targeting.'; if (targeting.kind === 'within_radius') { if ( !['allies', 'enemies'].includes(String(targeting.population)) || !finiteNumber(targeting.radiusMeters) || targeting.radiusMeters <= 0 ) { return 'has invalid radius targeting.'; } if ( targeting.population === 'allies' && outcome.scope !== 'allies' && outcome.scope !== 'self_and_allies' ) { return 'ally radius targeting requires allies or self-and-allies scope.'; } if (targeting.population === 'enemies' && outcome.scope !== 'target') { return 'enemy radius targeting requires target scope.'; } return null; } if (targeting.kind !== 'event_target_area') { return 'has invalid radius targeting.'; } if ( Object.keys(targeting).some( (key) => !['kind', 'center', 'validTargets', 'radiusMeters'].includes(key), ) || targeting.center !== 'event_target' || targeting.validTargets !== 'enemies' || !finiteNumber(targeting.radiusMeters) || targeting.radiusMeters <= 0 ) { return 'has invalid event-target area damage.'; } if (outcome.kind !== 'direct_damage' || outcome.scope !== 'target') { return 'event-target area damage requires target-scoped direct damage.'; } if (outcome.phase !== 'on_event' && outcome.phase !== 'after_event') { return 'event-target area damage requires an on-event or after-event phase.'; } return null; } function visitExpression( expression: NumericExpression, pools: Set, inputs: Set, errors: string[], path: string, ) { if (typeof expression === 'number') { if (!finiteNumber(expression)) errors.push(`${path} must be finite.`); return; } if ('ref' in expression) { validateReference(expression.ref, pools, inputs, errors, path); return; } if (expression.op === 'floor') { visitExpression(expression.value, pools, inputs, errors, `${path} value`); return; } if (expression.op === 'clamp') { visitExpression(expression.value, pools, inputs, errors, `${path} value`); visitExpression(expression.min, pools, inputs, errors, `${path} minimum`); visitExpression(expression.max, pools, inputs, errors, `${path} maximum`); return; } visitExpression( expression.left, pools, inputs, errors, `${path} left operand`, ); visitExpression( expression.right, pools, inputs, errors, `${path} right operand`, ); } function visitPredicate( predicate: Predicate, pools: Set, inputs: Set, errors: string[], path: string, ) { if (predicate.kind === 'true') return; if (predicate.kind === 'exists') { validateReference(predicate.value, pools, inputs, errors, path); return; } if (predicate.kind === 'not') { visitPredicate(predicate.predicate, pools, inputs, errors, `${path} NOT`); return; } if (predicate.kind === 'all' || predicate.kind === 'any') { if (!predicate.predicates.length) errors.push( `${path} ${predicate.kind.toUpperCase()} requires a predicate.`, ); predicate.predicates.forEach((item, index) => visitPredicate(item, pools, inputs, errors, `${path} ${index + 1}`), ); return; } if (typeof predicate.left === 'object' && predicate.left !== null) { validateReference( predicate.left, pools, inputs, errors, `${path} left operand`, ); } if (typeof predicate.right === 'object' && predicate.right !== null) { validateReference( predicate.right, pools, inputs, errors, `${path} right operand`, ); } } function expressionUsesCombat(expression: NumericExpression): boolean { if (typeof expression === 'number') return false; if ('ref' in expression) return expression.ref.source === 'combat'; if (expression.op === 'floor') return expressionUsesCombat(expression.value); if (expression.op === 'clamp') { return ( expressionUsesCombat(expression.value) || expressionUsesCombat(expression.min) || expressionUsesCombat(expression.max) ); } return ( expressionUsesCombat(expression.left) || expressionUsesCombat(expression.right) ); } function expressionUsesEquipment(expression: NumericExpression): boolean { if (typeof expression === 'number') return false; if ('ref' in expression) return expression.ref.source === 'equipment'; if (expression.op === 'floor') return expressionUsesEquipment(expression.value); if (expression.op === 'clamp') { return ( expressionUsesEquipment(expression.value) || expressionUsesEquipment(expression.min) || expressionUsesEquipment(expression.max) ); } return ( expressionUsesEquipment(expression.left) || expressionUsesEquipment(expression.right) ); } function predicateUsesCombat(predicate: Predicate): boolean { if (predicate.kind === 'true') return false; if (predicate.kind === 'exists') return predicate.value.source === 'combat'; if (predicate.kind === 'not') return predicateUsesCombat(predicate.predicate); if (predicate.kind === 'all' || predicate.kind === 'any') { return predicate.predicates.some(predicateUsesCombat); } return ( (typeof predicate.left === 'object' && predicate.left?.source === 'combat') || (typeof predicate.right === 'object' && predicate.right?.source === 'combat') ); } function predicateUsesEquipment(predicate: Predicate): boolean { if (predicate.kind === 'true') return false; if (predicate.kind === 'exists') return predicate.value.source === 'equipment'; if (predicate.kind === 'not') return predicateUsesEquipment(predicate.predicate); if (predicate.kind === 'all' || predicate.kind === 'any') { return predicate.predicates.some(predicateUsesEquipment); } return ( (typeof predicate.left === 'object' && predicate.left?.source === 'equipment') || (typeof predicate.right === 'object' && predicate.right?.source === 'equipment') ); } function programCombatRequirements(program: MechanicProgram) { let required = false; let weaponBaseDamage = false; let equipment = false; const attributeIds = new Set(); const visit = (value: unknown) => { if (!value || typeof value !== 'object') return; if ( 'source' in value && value.source === 'attribute' && 'attributeId' in value && typeof value.attributeId === 'string' ) { attributeIds.add(value.attributeId); } for (const child of Object.values(value)) visit(child); }; visit(program); for (const clause of program.clauses) { if (clause.if && predicateUsesCombat(clause.if)) required = true; if (clause.if && predicateUsesEquipment(clause.if)) equipment = true; if (clause.weight && expressionUsesCombat(clause.weight)) required = true; if (clause.weight && expressionUsesEquipment(clause.weight)) equipment = true; if ( clause.reducer.kind === 'derived_scalar' && expressionUsesCombat(clause.reducer.value) ) required = true; if ( clause.reducer.kind === 'derived_scalar' && expressionUsesEquipment(clause.reducer.value) ) equipment = true; for (const mutation of clause.mutations) { if (mutation.kind === 'extend_duration') { if (expressionUsesCombat(mutation.seconds)) required = true; if (expressionUsesEquipment(mutation.seconds)) equipment = true; continue; } if ( 'value' in mutation && mutation.value !== undefined && mutation.value !== 'all' ) { if (expressionUsesCombat(mutation.value)) required = true; if (expressionUsesEquipment(mutation.value)) equipment = true; } } for (const outcome of clause.outcomes ?? []) { if (outcome.basis !== 'flat' || expressionUsesCombat(outcome.value)) required = true; if (expressionUsesEquipment(outcome.value)) equipment = true; if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true; } } if (attributeIds.size) required = true; return { required, weaponBaseDamage, equipment, attributeIds }; } export function validateMechanicProgram( program: MechanicProgram, ): ValidationResult { const errors: string[] = []; if (!program.id.trim()) errors.push('Program ID is required.'); if (!program.name.trim()) errors.push('Program name is required.'); const inputKeys = new Set(); for (const [index, input] of (program.scenarioInputs ?? []).entries()) { const path = `Scenario input ${index + 1}`; if (!input.key.trim()) errors.push(`${path} requires a key.`); if (inputKeys.has(input.key)) errors.push(`Scenario input "${input.key}" is duplicated.`); inputKeys.add(input.key); const required = (input.readiness ?? 'ready') === 'ready'; if (input.kind === 'toggle') { if ( (required && (typeof input.defaultValue !== 'boolean' || typeof input.bestCaseValue !== 'boolean')) || (input.defaultValue !== undefined && typeof input.defaultValue !== 'boolean') || (input.bestCaseValue !== undefined && typeof input.bestCaseValue !== 'boolean') ) { errors.push(`${path} toggle values must be booleans.`); } } else { if ( (required && (!finiteNumber(input.defaultValue) || !finiteNumber(input.bestCaseValue))) || (input.defaultValue !== undefined && !finiteNumber(input.defaultValue)) || (input.bestCaseValue !== undefined && !finiteNumber(input.bestCaseValue)) ) { errors.push(`${path} values must be finite numbers.`); } if ( input.min !== undefined && input.max !== undefined && input.min > input.max ) { errors.push(`${path} minimum cannot exceed its maximum.`); } for (const value of [input.defaultValue, input.bestCaseValue]) { if (typeof value !== 'number') continue; if (input.min !== undefined && value < input.min) errors.push(`${path} value is below its minimum.`); if (input.max !== undefined && value > input.max) errors.push(`${path} value exceeds its maximum.`); } } } const poolKeys = new Set(); for (const [index, pool] of (program.pools ?? []).entries()) { const path = `Pool ${index + 1}`; if (!pool.key.trim()) errors.push(`${path} requires a key.`); if (poolKeys.has(pool.key)) errors.push(`Pool "${pool.key}" is duplicated.`); poolKeys.add(pool.key); if (!finiteNumber(pool.initial)) errors.push(`${path} initial value must be finite.`); if ( pool.min !== undefined && pool.max !== undefined && pool.min > pool.max ) { errors.push(`${path} minimum cannot exceed its maximum.`); } if (pool.min !== undefined && pool.initial < pool.min) errors.push(`${path} initial value is below its minimum.`); if (pool.max !== undefined && pool.initial > pool.max) errors.push(`${path} initial value exceeds its maximum.`); if (pool.bestCaseValue !== undefined) { if (!finiteNumber(pool.bestCaseValue)) errors.push(`${path} best-case value must be finite.`); if (pool.min !== undefined && pool.bestCaseValue < pool.min) errors.push(`${path} best-case value is below its minimum.`); if (pool.max !== undefined && pool.bestCaseValue > pool.max) errors.push(`${path} best-case value exceeds its maximum.`); } if ( pool.expiresAfterSeconds !== undefined && (!finiteNumber(pool.expiresAfterSeconds) || pool.expiresAfterSeconds <= 0) ) { errors.push(`${path} expiry must be greater than 0 seconds.`); } } const clauseIds = new Set(); const outcomeDefinitions = new Map(); for (const [index, clause] of program.clauses.entries()) { const path = `Clause ${index + 1}`; if (!clause.id.trim()) errors.push(`${path} requires an ID.`); if (clause.recipeKey !== undefined && !clause.recipeKey.trim()) { errors.push(`${path} recipe key cannot be empty.`); } if (clauseIds.has(clause.id)) errors.push(`Clause "${clause.id}" is duplicated.`); clauseIds.add(clause.id); if (!(MECHANIC_EVENT_KINDS as readonly string[]).includes(clause.when)) { errors.push(`${path} has an unknown event kind.`); } if (clause.if) visitPredicate( clause.if, poolKeys, inputKeys, errors, `${path} predicate`, ); validateStrictEventReferences( clause.if, clause.when, errors, `${path} predicate`, ); validateStrictEventPredicate( clause.if, clause.when, errors, `${path} predicate`, ); if (clause.weight) visitExpression( clause.weight, poolKeys, inputKeys, errors, `${path} weight`, ); validateStrictEventReferences( clause.weight, clause.when, errors, `${path} weight`, ); if ( clause.cooldownSeconds !== undefined && (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0) ) { errors.push(`${path} cooldown must be greater than 0 seconds.`); } if ( clause.procChancePercent !== undefined && (!finiteNumber(clause.procChancePercent) || clause.procChancePercent < 0 || clause.procChancePercent > 100) ) { errors.push(`${path} proc chance must be from 0 through 100 percent.`); } if ( clause.requiresAssignedAbilityActive !== undefined && typeof clause.requiresAssignedAbilityActive !== 'boolean' ) { errors.push( `${path} assigned-ability activity requirement must be a boolean.`, ); } const reducer = clause.reducer; if ( reducer.kind === 'count' && (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) ) { errors.push(`${path} count threshold must be a positive integer.`); } if ( reducer.kind === 'modulo' && (!Number.isInteger(reducer.every) || reducer.every <= 0) ) { errors.push(`${path} modulo interval must be a positive integer.`); } if (reducer.kind === 'distinct_count') { if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { errors.push( `${path} distinct-count threshold must be a positive integer.`, ); } if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { errors.push( `${path} distinct-count window must be greater than 0 seconds.`, ); } validateReference( reducer.distinctBy, poolKeys, inputKeys, errors, `${path} distinct-count value`, ); } if ( reducer.kind === 'consecutive_count' && (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) ) { errors.push( `${path} consecutive-count threshold must be a positive integer.`, ); } if (reducer.kind === 'sliding_count') { if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { errors.push( `${path} sliding-count threshold must be a positive integer.`, ); } if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { errors.push(`${path} sliding window must be greater than 0 seconds.`); } } if ( reducer.kind === 'duration' && (!finiteNumber(reducer.seconds) || reducer.seconds <= 0) ) { errors.push(`${path} duration must be greater than 0 seconds.`); } if (reducer.kind === 'periodic') { visitExpression( reducer.intervalSeconds, poolKeys, inputKeys, errors, `${path} periodic interval`, ); if ( finiteNumber(reducer.intervalSeconds) && reducer.intervalSeconds <= 0 ) { errors.push( `${path} periodic interval must be greater than 0 seconds.`, ); } } if ('groupBy' in reducer && reducer.groupBy) { validateReference( reducer.groupBy, poolKeys, inputKeys, errors, `${path} reducer group`, ); } if (reducer.kind === 'derived_scalar') { visitExpression( reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`, ); } validateStrictEventReferences( reducer, clause.when, errors, `${path} reducer`, ); for (const [mutationIndex, mutation] of clause.mutations.entries()) { if (mutation.kind === 'none') continue; const mutationPath = `${path} mutation ${mutationIndex + 1}`; if (mutation.kind === 'extend_duration') { if (mutation.target !== 'assigned_ability') errors.push(`${mutationPath} can only extend the assigned ability.`); visitExpression( mutation.seconds, poolKeys, inputKeys, errors, `${mutationPath} duration`, ); continue; } if (mutation.kind === 'start_cooldown_on_max') { const stack = (program.pools ?? []).find( (pool) => pool.key === mutation.stackPool, ); const cooldown = (program.pools ?? []).find( (pool) => pool.key === mutation.cooldownPool, ); if (!stack?.max || stack.max <= 0) errors.push(`${mutationPath} requires a capped stack pool.`); if ( !cooldown || cooldown.scope !== 'agent' || !cooldown.expiresAfterSeconds ) errors.push( `${mutationPath} requires an expiring Agent cooldown pool.`, ); if (mutation.stackPool === mutation.cooldownPool) errors.push(`${mutationPath} stack and cooldown pools must differ.`); continue; } if (!poolKeys.has(mutation.pool)) errors.push( `${mutationPath} references unknown pool "${mutation.pool}".`, ); if ( 'value' in mutation && mutation.value !== undefined && mutation.value !== 'all' ) { visitExpression( mutation.value, poolKeys, inputKeys, errors, `${mutationPath} value`, ); } if (mutation.kind === 'transfer') { const definition = (program.pools ?? []).find( (pool) => pool.key === mutation.pool, ); if (definition && definition.scope !== 'target') { errors.push( `${mutationPath} can only transfer a target-scoped pool.`, ); } } } validateStrictEventReferences( clause.mutations, clause.when, errors, `${path} mutations`, ); for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) { const outcomePath = `${path} outcome ${outcomeIndex + 1}`; if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); const existingOutcome = outcomeDefinitions.get(outcome.id); if ( existingOutcome && !mechanicDefinitionsEqual(existingOutcome, outcome) ) { errors.push(`Outcome "${outcome.id}" has conflicting definitions.`); } else if (!existingOutcome) { outcomeDefinitions.set(outcome.id, outcome); } if ( outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim() ) { errors.push(`${outcomePath} requires an Attribute ID.`); } if ( ![ 'self', 'equipped_weapon', 'inactive_weapon', 'target', 'allies', 'self_and_allies', 'assigned_ability', 'all_skills', ].includes(outcome.scope) ) { errors.push(`${outcomePath} has an unsupported scope.`); } if ( outcome.kind === 'weapon_reload' && outcome.scope !== 'equipped_weapon' && outcome.scope !== 'inactive_weapon' ) { errors.push( `${outcomePath} weapon reload must target the equipped or inactive weapon.`, ); } if ( outcome.scope === 'inactive_weapon' && outcome.kind !== 'weapon_reload' ) { errors.push( `${outcomePath} can only target the inactive weapon with a weapon reload.`, ); } const targetingError = mechanicOutcomeTargetingError(outcome); if (targetingError) errors.push(`${outcomePath} ${targetingError}`); if ( outcome.targeting?.kind === 'event_target_area' && !['weapon_hit', 'skill_hit'].includes(clause.when) ) { errors.push( `${outcomePath} event-target area damage requires a weapon-hit or skill-hit clause.`, ); } if ( outcome.kind === 'weapon_reload' && finiteNumber(outcome.value) && outcome.value < 0 ) { errors.push(`${outcomePath} weapon reload delay cannot be negative.`); } if ( (outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined ) { errors.push(`${outcomePath} requires a resource.`); } if ( (outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined && !['health', 'armor', 'ammo', 'signature_ability_charge'].includes( outcome.resource, ) ) { errors.push(`${outcomePath} has an unsupported resource.`); } if ( outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost' ) { errors.push( `${outcomePath} can only set a resource for restoration or resource-cost outcomes.`, ); } const delivery = outcome.delivery ?? 'instant'; if (!['instant', 'continuous'].includes(delivery)) { errors.push(`${outcomePath} has an unsupported delivery mode.`); } else if (delivery === 'continuous') { if (outcome.kind !== 'restoration') { errors.push( `${outcomePath} can only use continuous delivery for restoration.`, ); } if ( !finiteNumber(outcome.durationSeconds) || (outcome.durationSeconds ?? 0) <= 0 ) { errors.push( `${outcomePath} continuous delivery duration must be greater than 0 seconds.`, ); } } else if (outcome.durationSeconds !== undefined) { errors.push( `${outcomePath} can only set a duration for continuous delivery.`, ); } const allowedBasis: Record< MechanicOutcome['kind'], readonly OutcomeBasis[] > = { attribute_modifier: ['flat'], direct_damage: [ 'flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent', ], restoration: ['flat', 'max_health_percent', 'current_health_percent'], resource_cost: ['flat', 'max_health_percent', 'current_health_percent'], weapon_reload: ['flat'], marker: ['flat'], }; if (!allowedBasis[outcome.kind].includes(outcome.basis)) { errors.push(`${outcomePath} cannot use basis "${outcome.basis}".`); } if (outcome.statePool && !poolKeys.has(outcome.statePool)) { errors.push( `${outcomePath} references unknown pool "${outcome.statePool}".`, ); } if ( outcome.minStacks !== undefined && outcome.maxStacks !== undefined && outcome.minStacks > outcome.maxStacks ) { errors.push( `${outcomePath} minimum stacks cannot exceed maximum stacks.`, ); } visitExpression( outcome.value, poolKeys, inputKeys, errors, `${outcomePath} value`, ); if ( outcome.probabilityPercent !== undefined && (!finiteNumber(outcome.probabilityPercent) || outcome.probabilityPercent < 0 || outcome.probabilityPercent > 100) ) { errors.push( `${outcomePath} probability must be from 0 through 100 percent.`, ); } } validateStrictEventReferences( clause.outcomes, clause.when, errors, `${path} outcomes`, ); } return errors.length ? { ok: false, errors } : { ok: true, value: program }; } export function evaluateMechanicProgram( program: MechanicProgram, events: MechanicEvent[], scenario: MechanicScenario, combatContext?: MechanicCombatContext, equipmentContext?: MechanicEquipmentContext, ): MechanicEvaluation { const validation = validateMechanicProgram(program); const scenarioErrors: string[] = []; scenarioErrors.push(...validateMechanicEvents(events)); if ( !finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0 ) { scenarioErrors.push('Encounter seconds must be greater than 0.'); } if ( scenario.randomSeed !== undefined && (!Number.isInteger(scenario.randomSeed) || scenario.randomSeed < 0 || scenario.randomSeed > 0xffff_ffff) ) { scenarioErrors.push('Random seed must be an unsigned 32-bit integer.'); } if (scenario.mode === 'custom') { const definitions = new Map( (program.scenarioInputs ?? []).map((input) => [input.key, input]), ); for (const [key, value] of Object.entries(scenario.inputs ?? {})) { const definition = definitions.get(key); if (!definition) { scenarioErrors.push(`Custom scenario contains unknown input "${key}".`); continue; } if (definition.kind === 'toggle') { if (typeof value !== 'boolean') scenarioErrors.push(`Scenario input "${key}" must be a boolean.`); continue; } if (!finiteNumber(value)) { scenarioErrors.push(`Scenario input "${key}" must be a finite number.`); continue; } if (definition.min !== undefined && value < definition.min) { scenarioErrors.push(`Scenario input "${key}" is below its minimum.`); } if (definition.max !== undefined && value > definition.max) { scenarioErrors.push(`Scenario input "${key}" exceeds its maximum.`); } } } const combatRequirements = programCombatRequirements(program); if (combatRequirements.required && !combatContext) { scenarioErrors.push('This mechanic requires authoritative combat context.'); } if (combatContext) { if ( !finiteNumber(combatContext.maxHealth) || combatContext.maxHealth <= 0 ) { scenarioErrors.push('Combat maximum Health must be greater than 0.'); } if ( !finiteNumber(combatContext.currentHealth) || combatContext.currentHealth < 0 || combatContext.currentHealth > combatContext.maxHealth ) { scenarioErrors.push( 'Combat current Health must be between 0 and maximum Health.', ); } if ( combatContext.weaponBaseDamage !== undefined && (!finiteNumber(combatContext.weaponBaseDamage) || combatContext.weaponBaseDamage < 0) ) { scenarioErrors.push('Combat weapon base damage must be 0 or greater.'); } if ( combatRequirements.weaponBaseDamage && combatContext.weaponBaseDamage === undefined ) { scenarioErrors.push( 'This mechanic requires authoritative weapon base damage.', ); } for (const attributeId of combatRequirements.attributeIds) { if (!finiteNumber(combatContext.attributes?.[attributeId])) { scenarioErrors.push( `This mechanic requires authoritative Attribute ${attributeId}.`, ); } } } if (combatRequirements.equipment && !equipmentContext) { scenarioErrors.push( 'This mechanic requires authoritative equipment context.', ); } if ( equipmentContext?.protocolTalentUpgrades !== undefined && (!Number.isInteger(equipmentContext.protocolTalentUpgrades) || equipmentContext.protocolTalentUpgrades < 0 || equipmentContext.protocolTalentUpgrades > 5) ) { scenarioErrors.push( 'Protocol talent upgrades must be an integer from 0 to 5.', ); } if (equipmentContext?.assignedAbility) { const ability = equipmentContext.assignedAbility; if ( typeof ability.active !== 'boolean' || !finiteNumber(ability.remainingDurationSeconds) || ability.remainingDurationSeconds < 0 ) { scenarioErrors.push( 'Assigned ability state requires an active flag and a non-negative remaining duration.', ); } } if (!validation.ok || scenarioErrors.length) { return invalidEvaluation(program, scenario, [ ...(validation.ok ? [] : validation.errors), ...scenarioErrors, ]); } const resolvedScenario = scenarioValues(program, scenario); const inputs = resolvedScenario.values; const combat = copyCombat(combatContext ?? null); const equipment = equipmentContext ? { ...equipmentContext, values: equipmentContext.values ? { ...equipmentContext.values } : undefined, } : null; const poolDefinitions = new Map( (program.pools ?? []).map((pool) => [pool.key, pool]), ); const pools = initialPools(poolDefinitions, scenario.mode); const randomSeed = scenario.randomSeed ?? hashSeed(program.id); const random = deterministicRandom(randomSeed); const assignedAbility: RuntimeAssignedAbility = { active: Boolean(equipment?.assignedAbility?.active), expiresAt: equipment?.assignedAbility?.active && finiteNumber(equipment.assignedAbility.remainingDurationSeconds) && equipment.assignedAbility.remainingDurationSeconds > 0 ? equipment.assignedAbility.remainingDurationSeconds : null, }; const reducerStates = new Map(); const cooldowns = new Map(); const emissions: MechanicEmission[] = []; const outcomes: MechanicOutcomeEmission[] = []; const continuousDeliveries: ContinuousDelivery[] = []; const runtimeErrors: string[] = []; if (scenario.mode === 'maximum') { for (const clause of program.clauses) { if ( clause.when === 'tick' && events.some( (event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON, ) ) continue; const event: MechanicEvent = { kind: clause.when, at: 0, actorId: AGENT_ENTITY, targetId: DEFAULT_TARGET_ENTITY, }; const context: EvaluationContext = { event, scenario: inputs, reducerValue: 1, pools, poolDefinitions, combat, preEventCombat: copyCombat(combat), equipment, assignedAbility, mode: scenario.mode, random, runtimeErrors, }; if ( !predicateMatches(clause.if, context) || (clause.requiresAssignedAbilityActive && resolveReference( { source: 'assigned_ability', path: 'active' }, context, ) !== true) ) continue; const before = new Map( (clause.outcomes ?? []) .map((outcome) => outcome.statePool) .filter((pool): pool is string => Boolean(pool)) .map((pool) => [pool, stateValue(pool, undefined, context)]), ); appendScheduledOutcomes( outcomes, emitOutcomes( { ...clause, outcomes: (clause.outcomes ?? []).filter( (outcome) => outcome.phase === 'while_active', ), }, 'after', 0, 1, before, context, continuousDeliveries, ), 0, scenario.encounterSeconds, ); } } const ordered = events .map((event, index) => ({ event, index })) .filter( ({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= scenario.encounterSeconds, ) .sort( (left, right) => left.event.at - right.event.at || left.index - right.index, ) .map(({ event }) => event); let previousWeaponHitTargetId: string | null = null; for (const rawEvent of ordered) { const event: MechanicEvent = rawEvent.kind === 'weapon_hit' && rawEvent.targetId ? { ...rawEvent, values: { ...(rawEvent.values ?? {}), ...(previousWeaponHitTargetId === null ? {} : { previousTargetId: previousWeaponHitTargetId }), }, } : rawEvent; if ( assignedAbility.expiresAt !== null && assignedAbility.expiresAt <= event.at + EPSILON ) { assignedAbility.active = false; assignedAbility.expiresAt = null; } const activatedDuration = event.kind === 'skill_deployed' || event.kind === 'signature_ability_deployed' ? getPath(event, 'values.assignedAbilityDurationSeconds') : undefined; if (finiteNumber(activatedDuration) && activatedDuration > 0) { assignedAbility.active = true; assignedAbility.expiresAt = event.at + activatedDuration; } outcomes.push( ...expirePools( event.at, program.clauses, inputs, pools, poolDefinitions, combat, equipment, assignedAbility, scenario.mode, random, continuousDeliveries, runtimeErrors, ), ); outcomes.push( ...advanceContinuousDeliveries(event.at, continuousDeliveries, combat), ); const preEventCombat = copyCombat(combat); for (const clause of program.clauses) { if (clause.when !== event.kind) continue; const baseContext: EvaluationContext = { event, scenario: inputs, reducerValue: 0, pools, poolDefinitions, combat, preEventCombat, equipment, assignedAbility, mode: scenario.mode, random, runtimeErrors, }; const predicateMatched = predicateMatches(clause.if, baseContext) && (!clause.requiresAssignedAbilityActive || resolveReference( { source: 'assigned_ability', path: 'active' }, baseContext, ) === true); const group = reducerGroup(clause.reducer, baseContext); const stateKey = `${clause.id}\u0000${group}`; if (event.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; let matched = predicateMatched; if ( matched && clause.procChancePercent !== undefined && (clause.procChancePercent <= 0 || (scenario.mode !== 'maximum' && random.next() * 100 >= clause.procChancePercent)) ) { matched = false; } const reducerState = reducerStates.get(stateKey) ?? freshReducerState(); reducerStates.set(stateKey, reducerState); const reduced = reduceEvent( clause.reducer, matched, event, baseContext, reducerState, ); for (const reduction of reduced) { if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; const context = { ...baseContext, reducerValue: reduction.value }; const weight = clause.weight ? clamp(numericValue(clause.weight, context), 0, 1) : 1; const relevantPools = new Set( (clause.outcomes ?? []) .map((outcome) => outcome.statePool) .filter((pool): pool is string => Boolean(pool)), ); const before = new Map( [...relevantPools].map((pool) => [ pool, stateValue(pool, undefined, context), ]), ); appendScheduledOutcomes( outcomes, emitOutcomes( clause, 'before', reduction.at, weight, before, context, continuousDeliveries, ), reduction.at, scenario.encounterSeconds, ); const mutationRecords: MutationRecord[] = []; for (const mutation of clause.mutations) { mutationRecords.push( mutation.kind === 'start_cooldown_on_max' ? startCooldownOnMax( mutation, context, mutationRecords, reduction.at, ) : applyMutation(mutation, reduction.at, context, weight), ); } emissions.push({ clauseId: clause.id, at: reduction.at, weight, reducerValue: reduction.value, mutations: mutationRecords, }); if (clause.cooldownSeconds) { cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); } appendScheduledOutcomes( outcomes, emitOutcomes( clause, 'after', reduction.at, weight, before, context, continuousDeliveries, ), reduction.at, scenario.encounterSeconds, ); } } if (event.kind === 'weapon_hit' && event.targetId) { previousWeaponHitTargetId = event.targetId; } } outcomes.push( ...expirePools( scenario.encounterSeconds, program.clauses, inputs, pools, poolDefinitions, combat, equipment, assignedAbility, scenario.mode, random, continuousDeliveries, runtimeErrors, ), ); outcomes.push( ...advanceContinuousDeliveries( scenario.encounterSeconds, continuousDeliveries, combat, ), ); if (runtimeErrors.length) { return invalidEvaluation(program, scenario, runtimeErrors); } return { status: 'OK', errors: [], programId: program.id, mode: scenario.mode, scenarioInputs: inputs, readiness: { status: resolvedScenario.missing.length ? 'REQUIRES_ASSUMPTIONS' : 'READY', missingInputs: resolvedScenario.missing, }, emissions, outcomes, finalState: [...pools.values()].sort( (left, right) => left.pool.localeCompare(right.pool) || left.entityId.localeCompare(right.entityId), ), finalCombat: copyCombat(combat), equipment, estimatedActivationCount: emissions.reduce( (total, emission) => total + emission.weight, 0, ), randomSeed, script: formatMechanicProgram(program), }; } function formatEntity(selector: EntitySelector | undefined) { if (!selector) return ''; switch (selector.kind) { case 'agent': return ' FOR AGENT'; case 'event_actor': return ' FOR EVENT ACTOR'; case 'event_target': return ' FOR EVENT TARGET'; case 'event_field': return ` FOR EVENT.${selector.path}`; } } function formatReference(reference: ValueReference): string { switch (reference.source) { case 'event': return `EVENT.${reference.path}`; case 'scenario': return `SCENARIO.${reference.key}`; case 'combat': return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`; case 'attribute': return `ATTRIBUTE[${reference.attributeId}]`; case 'equipment': return `EQUIPMENT.${reference.path}`; case 'assigned_ability': return `ASSIGNED ABILITY.${reference.path}`; case 'reducer': return 'REDUCER VALUE'; case 'pool': return `${reference.pool}${formatEntity(reference.entity)}`; } } function formatExpression(expression: NumericExpression): string { if (typeof expression === 'number') return formatNumber(expression); if ('ref' in expression) return formatReference(expression.ref); if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`; if (expression.op === 'clamp') { return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`; } const operators = { add: '+', subtract: '-', multiply: '*', divide: '/', min: 'MIN', max: 'MAX', } as const; if (expression.op === 'min' || expression.op === 'max') { return `${operators[expression.op]}(${formatExpression(expression.left)}, ${formatExpression(expression.right)})`; } return `(${formatExpression(expression.left)} ${operators[expression.op]} ${formatExpression(expression.right)})`; } function formatOperand(operand: ValueReference | Scalar): string { if (typeof operand === 'object' && operand !== null) return formatReference(operand); if (typeof operand === 'string') return JSON.stringify(operand); return String(operand); } function formatPredicate(predicate: Predicate): string { if (predicate.kind === 'true') return 'TRUE'; if (predicate.kind === 'exists') return `EXISTS ${formatReference(predicate.value)}`; if (predicate.kind === 'not') return `NOT (${formatPredicate(predicate.predicate)})`; if (predicate.kind === 'all' || predicate.kind === 'any') { return predicate.predicates .map((item) => `(${formatPredicate(item)})`) .join(predicate.kind === 'all' ? ' AND ' : ' OR '); } const operators = { eq: '==', ne: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=', } as const; return `${formatOperand(predicate.left)} ${operators[predicate.operator]} ${formatOperand(predicate.right)}`; } function formatReducer(reducer: Reducer): string[] { const group = 'groupBy' in reducer && reducer.groupBy ? ` PER ${formatReference(reducer.groupBy)}` : ''; switch (reducer.kind) { case 'none': return []; case 'count': return [`COUNT ${formatNumber(reducer.threshold)} MATCHES${group}`]; case 'modulo': return [`EVERY ${formatNumber(reducer.every)} MATCHES${group}`]; case 'distinct_count': return [ `COUNT ${formatNumber(reducer.threshold)} DISTINCT ${formatReference(reducer.distinctBy)} WITHIN ${formatNumber(reducer.windowSeconds)}s`, ]; case 'consecutive_count': return [ `COUNT ${formatNumber(reducer.threshold)} CONSECUTIVE MATCHES${group}`, ]; case 'sliding_count': return [ `COUNT ${formatNumber(reducer.threshold)} MATCHES WITHIN ${formatNumber(reducer.windowSeconds)}s${group}`, ]; case 'duration': return [ `AFTER CONDITION HOLDS FOR ${formatNumber(reducer.seconds)}s${group}`, ]; case 'periodic': return [ `EVERY ${formatExpression(reducer.intervalSeconds)}s WHILE TRUE${group}`, ]; case 'derived_scalar': return [`DERIVE ${formatExpression(reducer.value)}`]; } } function formatMutation(mutation: ClauseMutation): string { if (mutation.kind === 'none') return 'NO STATE CHANGE'; if (mutation.kind === 'extend_duration') return `EXTEND ASSIGNED ABILITY DURATION BY ${formatExpression(mutation.seconds)}s`; if (mutation.kind === 'start_cooldown_on_max') return `START ${mutation.cooldownPool} WHEN ${mutation.stackPool} REACHES MAX`; if (mutation.kind === 'transfer') { const value = mutation.value === undefined || mutation.value === 'all' ? 'ALL' : formatExpression(mutation.value); return `TRANSFER ${value} ${mutation.pool}${formatEntity(mutation.from)} TO${formatEntity(mutation.to).replace(' FOR', '')}`; } if (mutation.kind === 'clear') return `CLEAR ${mutation.pool}${formatEntity(mutation.entity)}`; const verbs = { add: 'ADD', set: 'SET', consume: 'CONSUME', derive: 'DERIVE', } as const; const preposition = mutation.kind === 'consume' ? 'FROM' : mutation.kind === 'set' || mutation.kind === 'derive' ? 'AS' : 'TO'; return `${verbs[mutation.kind]} ${formatExpression(mutation.value)} ${preposition} ${mutation.pool}${formatEntity(mutation.entity)}`; } function formatOutcome(outcome: MechanicOutcome): string { const stackBand = outcome.minStacks !== undefined || outcome.maxStacks !== undefined ? ` AT STACKS ${outcome.minStacks ?? 0}..${outcome.maxStacks ?? 'MAX'}` : ''; const perStack = outcome.perStack ? ' PER STACK' : ''; const attribute = outcome.attributeId ? ` ${outcome.attributeId}` : ''; const resource = outcome.kind === 'restoration' || outcome.kind === 'resource_cost' ? ` ${outcomeResource(outcome).toUpperCase()}` : ''; const timing = outcome.basis === 'current_health_percent' ? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH` : ''; const delivery = outcome.delivery === 'continuous' ? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` : ''; const targeting = outcome.targeting?.kind === 'event_target_area' ? ` AS EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` : outcome.targeting ? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` : ''; const probability = outcome.probabilityPercent === undefined ? '' : `; ${formatNumber(outcome.probabilityPercent)}% PROC`; if (outcome.kind === 'weapon_reload') { const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE'; return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`; } return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery}${probability} // ${outcome.label}`; } export function formatMechanicProgram(program: MechanicProgram): string { const lines = [`MECHANIC ${program.name.toUpperCase()}`]; for (const pool of program.pools ?? []) { const bounds = pool.min !== undefined || pool.max !== undefined ? ` ${formatNumber(pool.min ?? 0)}..${pool.max === undefined ? 'UNBOUNDED' : formatNumber(pool.max)}` : ''; const expiry = pool.expiresAfterSeconds ? `; EXPIRES ${formatNumber(pool.expiresAfterSeconds)}s AFTER WRITE` : ''; lines.push( `STATE ${pool.key} [${pool.scope.toUpperCase()}]${bounds}${expiry}`, ); } for (const clause of program.clauses) { const recipe = clause.recipeKey ? ` [RECIPE ${clause.recipeKey}]` : ''; lines.push( '', `WHEN ${MECHANIC_EVENT_REGISTRY[clause.when].label.toUpperCase()} // ${clause.label ?? clause.id}${recipe}`, ); if (clause.if) lines.push(`IF ${formatPredicate(clause.if)}`); lines.push(...formatReducer(clause.reducer)); if (clause.cooldownSeconds) lines.push(`COOLDOWN ${formatNumber(clause.cooldownSeconds)}s`); if (clause.weight) lines.push(`WEIGHT ${formatExpression(clause.weight)}`); if (clause.procChancePercent !== undefined) lines.push(`PROC CHANCE ${formatNumber(clause.procChancePercent)}%`); if (clause.requiresAssignedAbilityActive) lines.push('REQUIRES ASSIGNED ABILITY ACTIVE'); lines.push(...clause.mutations.map(formatMutation)); lines.push(...(clause.outcomes ?? []).map(formatOutcome)); } return lines.join('\n'); }