import { MECHANIC_EVENT_KINDS, validateMechanicProgram, type ClauseMutation, type MechanicClause, type MechanicEventKind, type MechanicOutcome, type MechanicProgram, type NumericExpression, type OutcomeBasis, type OutcomePhase, type PoolDefinition, type Predicate, type Reducer, type ScenarioInputDefinition, type ValueReference, } from './mechanic.js'; type DatabaseValue = string | number | bigint | boolean | null | undefined; export type ConditionalEffectRuleRow = { id: DatabaseValue; source_type: DatabaseValue; source_id: DatabaseValue; configured: DatabaseValue; script_version: DatabaseValue; simulation_readiness: DatabaseValue; scenario_inputs_json: DatabaseValue; }; export type ConditionalEffectStatePoolRow = { id: DatabaseValue; rule_id: DatabaseValue; state_key: DatabaseValue; label: DatabaseValue; state_kind: DatabaseValue; state_scope: DatabaseValue; initial_value: DatabaseValue; minimum_value: DatabaseValue; maximum_value: DatabaseValue; best_case_value: DatabaseValue; duration_seconds: DatabaseValue; expiry_policy: DatabaseValue; refresh_policy: DatabaseValue; parameters_json: DatabaseValue; sort_order: DatabaseValue; }; export type ConditionalEffectClauseRow = { id: DatabaseValue; rule_id: DatabaseValue; clause_key: DatabaseValue; label: DatabaseValue; trigger_id: DatabaseValue; event_kind?: DatabaseValue; recipe_key?: DatabaseValue; parameters_json: DatabaseValue; recipe_parameters_json?: DatabaseValue; conditions_json: DatabaseValue; reducer_json: DatabaseValue; weight_expression_json?: DatabaseValue; emission_kind: DatabaseValue; state_pool_id: DatabaseValue; mutations_json: DatabaseValue; cooldown_seconds: DatabaseValue; output_branch: DatabaseValue; sort_order: DatabaseValue; active: DatabaseValue; }; export type ConditionalEffectOutcomeRow = { id: DatabaseValue; rule_id: DatabaseValue; clause_id: DatabaseValue; state_pool_id: DatabaseValue; effect_key: DatabaseValue; variant_key: DatabaseValue; outcome_key: DatabaseValue; application_kind: DatabaseValue; effect_scope: DatabaseValue; stat_attribute_id: DatabaseValue; value: DatabaseValue; value_expression_json: DatabaseValue; value_basis: DatabaseValue; resource_kind?: DatabaseValue; combat_timing?: DatabaseValue; delivery_mode?: DatabaseValue; delivery_duration_seconds?: DatabaseValue; targeting_json?: DatabaseValue; probability_percent?: DatabaseValue; value_scaling: DatabaseValue; unit: DatabaseValue; damage_basis: DatabaseValue; application_phase: DatabaseValue; min_state_value: DatabaseValue; max_state_value: DatabaseValue; description_slot: DatabaseValue; sort_order: DatabaseValue; }; export type TriggerActionMetadataRow = { id: DatabaseValue; action_key: DatabaseValue; event_kind?: DatabaseValue; name?: DatabaseValue; }; export type MechanicPersistenceInput = { rule: ConditionalEffectRuleRow; statePools: ConditionalEffectStatePoolRow[]; clauses: ConditionalEffectClauseRow[]; outcomes: ConditionalEffectOutcomeRow[]; triggers: TriggerActionMetadataRow[]; }; export type MechanicPersistenceOptions = { name?: string; variantKey?: string; }; export type MechanicPersistenceStatus = 'READY' | 'REQUIRES_ASSUMPTIONS' | 'CAPTURED_ONLY' | 'INVALID'; export type MechanicPersistenceResult = { status: MechanicPersistenceStatus; simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only' | null; program: MechanicProgram | null; errors: string[]; warnings: string[]; }; type ParsedJson = { ok: true; value: unknown } | { ok: false }; const eventKinds = new Set(MECHANIC_EVENT_KINDS); const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const text = (value: DatabaseValue) => typeof value === 'string' ? value.trim() : ''; const nullableText = (value: DatabaseValue) => { const result = text(value); return result || null; }; const number = (value: DatabaseValue) => { if (typeof value === 'bigint') return Number(value); if ( typeof value === 'boolean' || value === null || value === undefined || value === '' ) return null; const result = Number(value); return Number.isFinite(result) ? result : null; }; const enabled = (value: DatabaseValue) => value === true || value === 1 || value === 1n || value === '1'; function parseJson( value: DatabaseValue, path: string, errors: string[], ): ParsedJson { if (typeof value !== 'string') { errors.push(`${path} must contain JSON text.`); return { ok: false }; } try { return { ok: true, value: JSON.parse(value) }; } catch { errors.push(`${path} contains malformed JSON.`); return { ok: false }; } } function requiredText(value: DatabaseValue, path: string, errors: string[]) { const result = text(value); if (!result) errors.push(`${path} is required.`); return result; } function requiredNumber(value: DatabaseValue, path: string, errors: string[]) { const result = number(value); if (result === null) errors.push(`${path} must be a finite number.`); return result ?? 0; } function isEntitySelector(value: unknown) { if (!object(value) || typeof value.kind !== 'string') return false; if ( value.kind === 'agent' || value.kind === 'event_actor' || value.kind === 'event_target' ) { return true; } return ( value.kind === 'event_field' && typeof value.path === 'string' && Boolean(value.path.trim()) ); } function isValueReference(value: unknown) { if (!object(value) || typeof value.source !== 'string') return false; if (value.source === 'event' || value.source === 'equipment') { return typeof value.path === 'string' && Boolean(value.path.trim()); } if (value.source === 'assigned_ability') { return value.path === 'active' || value.path === 'remainingDurationSeconds'; } if (value.source === 'combat') { return ( typeof value.path === 'string' && Boolean(value.path.trim()) && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current') ); } if (value.source === 'attribute') { return ( typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()) ); } if (value.source === 'scenario') { return typeof value.key === 'string' && Boolean(value.key.trim()); } if (value.source === 'reducer') return true; return ( value.source === 'pool' && typeof value.pool === 'string' && Boolean(value.pool.trim()) && (value.entity === undefined || isEntitySelector(value.entity)) ); } function isNumericExpression(value: unknown): value is NumericExpression { if (finite(value)) return true; if (!object(value)) return false; if ('ref' in value) return isValueReference(value.ref); if (value.op === 'floor') return isNumericExpression(value.value); if (value.op === 'clamp') { return ( isNumericExpression(value.value) && isNumericExpression(value.min) && isNumericExpression(value.max) ); } return ( ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes( String(value.op), ) && isNumericExpression(value.left) && isNumericExpression(value.right) ); } function resolveVariantExpression( value: unknown, variantValue: number, ): NumericExpression | null { if (finite(value)) return value; if (!object(value)) return null; if ('ref' in value) { if ( object(value.ref) && value.ref.source === 'variant_value' && Object.keys(value.ref).length === 1 ) { return variantValue; } return isValueReference(value.ref) ? { ref: value.ref as ValueReference } : null; } if (value.op === 'floor') { const nested = resolveVariantExpression(value.value, variantValue); return nested === null ? null : { op: 'floor', value: nested }; } if (value.op === 'clamp') { const nested = resolveVariantExpression(value.value, variantValue); const min = resolveVariantExpression(value.min, variantValue); const max = resolveVariantExpression(value.max, variantValue); return nested === null || min === null || max === null ? null : { op: 'clamp', value: nested, min, max }; } if ( !['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes( String(value.op), ) ) { return null; } const left = resolveVariantExpression(value.left, variantValue); const right = resolveVariantExpression(value.right, variantValue); return left === null || right === null ? null : { op: value.op as 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max', left, right, }; } function isPredicate(value: unknown): value is Predicate { if (!object(value) || typeof value.kind !== 'string') return false; if (value.kind === 'true') return true; if (value.kind === 'exists') return object(value.value) && typeof value.value.source === 'string'; if (value.kind === 'not') return isPredicate(value.predicate); if (value.kind === 'all' || value.kind === 'any') { return ( Array.isArray(value.predicates) && value.predicates.every(isPredicate) ); } return ( value.kind === 'compare' && ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) && 'left' in value && 'right' in value ); } function isReducer(value: unknown): value is Reducer { if (!object(value) || typeof value.kind !== 'string') return false; if (value.kind === 'none') return true; if (value.kind === 'count') return finite(value.threshold); if (value.kind === 'modulo') return finite(value.every); if (value.kind === 'distinct_count') { return ( finite(value.threshold) && finite(value.windowSeconds) && object(value.distinctBy) && typeof value.distinctBy.source === 'string' ); } if (value.kind === 'consecutive_count') return finite(value.threshold); if (value.kind === 'sliding_count') return finite(value.threshold) && finite(value.windowSeconds); if (value.kind === 'duration') return finite(value.seconds); if (value.kind === 'periodic') return isNumericExpression(value.intervalSeconds); return value.kind === 'derived_scalar' && isNumericExpression(value.value); } function isMutation(value: unknown): value is ClauseMutation { if (!object(value) || typeof value.kind !== 'string') return false; if (value.kind === 'none') return true; if (value.kind === 'clear') { return ( typeof value.pool === 'string' && (value.entity === undefined || isEntitySelector(value.entity)) ); } if (value.kind === 'transfer') { return ( typeof value.pool === 'string' && isEntitySelector(value.from) && isEntitySelector(value.to) && (value.value === undefined || value.value === 'all' || isNumericExpression(value.value)) ); } if (value.kind === 'extend_duration') { return ( value.target === 'assigned_ability' && isNumericExpression(value.seconds) ); } if (value.kind === 'start_cooldown_on_max') { return ( typeof value.stackPool === 'string' && Boolean(value.stackPool.trim()) && typeof value.cooldownPool === 'string' && Boolean(value.cooldownPool.trim()) ); } return ( ['add', 'set', 'consume', 'derive'].includes(value.kind) && typeof value.pool === 'string' && isNumericExpression(value.value) && (value.entity === undefined || isEntitySelector(value.entity)) ); } function parseScenarioInputs( raw: DatabaseValue, errors: string[], ): ScenarioInputDefinition[] { const parsed = parseJson(raw, 'Rule scenario inputs', errors); if (!parsed.ok) return []; if (!Array.isArray(parsed.value)) { errors.push('Rule scenario inputs must be a JSON array.'); return []; } const inputs: ScenarioInputDefinition[] = []; for (const [index, candidate] of parsed.value.entries()) { const path = `Scenario input ${index + 1}`; if (!object(candidate)) { errors.push(`${path} must be an object.`); continue; } if ( typeof candidate.key !== 'string' || typeof candidate.label !== 'string' || !['toggle', 'slider', 'number'].includes(String(candidate.kind)) ) { errors.push(`${path} requires a key, label, and supported input kind.`); continue; } inputs.push(candidate as ScenarioInputDefinition); } return inputs; } type LegacyMapping = { when: MechanicEventKind; predicate?: Predicate; reducer?: Reducer; }; function legacyActionMapping( action: string, params: Record, path: string, errors: string[], ): LegacyMapping | null { const eventValue = (field: string) => ({ source: 'event' as const, path: `values.${field}`, }); const targetExists: Predicate = { kind: 'exists', value: { source: 'event', path: 'targetId' }, }; switch (action) { case 'same_target_hits_within_window': { const hits = params.hitsRequired; const window = params.windowSeconds; if ( !Number.isInteger(hits) || Number(hits) <= 0 || !finite(window) || window <= 0 ) { errors.push(`${path} has invalid same-target hit parameters.`); return null; } return { when: 'weapon_hit', predicate: targetExists, reducer: { kind: 'sliding_count', threshold: Number(hits), windowSeconds: window, reset: 'zero', groupBy: { source: 'event', path: 'targetId' }, }, }; } case 'headshot': return { when: 'weapon_hit', predicate: { kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true, }, }; case 'headshot_kill': return { when: 'enemy_killed', predicate: { kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true, }, }; case 'weapon_kill': return { when: 'enemy_killed' }; case 'target_hit_within_distance': { const meters = params.meters; if (!finite(meters) || meters <= 0) { errors.push(`${path} has an invalid distance parameter.`); return null; } return { when: 'weapon_hit', predicate: { kind: 'all', predicates: [ targetExists, { kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: meters, }, ], }, }; } case 'damage_taken': return { when: 'health_damage_taken' }; case 'weapon_swap': return { when: 'weapon_swapped' }; case 'reload': return { when: 'reload_started' }; default: errors.push( `${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.`, ); return null; } } function combinePredicates( left: Predicate | undefined, right: Predicate | undefined, ) { if (!left || left.kind === 'true') return right; if (!right || right.kind === 'true') return left; return { kind: 'all' as const, predicates: [left, right] }; } function resolveClauseMapping( trigger: TriggerActionMetadataRow | null, eventKind: DatabaseValue, parameters: Record, explicitPredicate: Predicate, explicitReducer: Reducer, path: string, errors: string[], ) { const directEvent = nullableText(eventKind) ?? nullableText(trigger?.event_kind) ?? (eventKinds.has(text(trigger?.action_key)) ? text(trigger?.action_key) : null); if (directEvent) { return { when: directEvent as MechanicEventKind, predicate: explicitPredicate, reducer: explicitReducer, }; } if (!trigger) { errors.push( `${path} requires a direct event or known Action Family recipe.`, ); return null; } const legacy = legacyActionMapping( text(trigger.action_key), parameters, path, errors, ); if (!legacy) return null; return { when: legacy.when, predicate: combinePredicates(legacy.predicate, explicitPredicate), reducer: explicitReducer.kind === 'none' ? (legacy.reducer ?? explicitReducer) : explicitReducer, }; } function parsePool( row: ConditionalEffectStatePoolRow, ruleId: string, index: number, selectedVariant: string, errors: string[], ): { id: string; definition: PoolDefinition } | null { const path = `State pool ${index + 1}`; if (text(row.rule_id) !== ruleId) { errors.push(`${path} belongs to a different rule.`); return null; } const id = requiredText(row.id, `${path} ID`, errors); const key = requiredText(row.state_key, `${path} key`, errors); const label = requiredText(row.label, `${path} label`, errors); const scope = text(row.state_scope); if (scope !== 'agent' && scope !== 'target') errors.push(`${path} has an invalid scope.`); const initial = requiredNumber( row.initial_value, `${path} initial value`, errors, ); const minimum = number(row.minimum_value); const maximum = requiredNumber( row.maximum_value, `${path} maximum value`, errors, ); const bestCase = number(row.best_case_value); const duration = number(row.duration_seconds); const parameters = parseJson( row.parameters_json, `${path} parameters`, errors, ); if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`); const durationVariants = parameters.ok && object(parameters.value) && object(parameters.value.expiresAfterSecondsVariants) ? parameters.value.expiresAfterSecondsVariants : null; const variantDuration = durationVariants ? number(durationVariants[selectedVariant] as DatabaseValue) : null; if (durationVariants && variantDuration === null) { errors.push( `${path} has no valid expiration duration for variant "${selectedVariant}".`, ); } return { id, definition: { key, label, scope: scope === 'target' ? 'target' : 'agent', initial, ...(minimum === null ? {} : { min: minimum }), max: maximum, ...(bestCase === null ? {} : { bestCaseValue: bestCase }), ...(variantDuration !== null ? { expiresAfterSeconds: variantDuration } : duration === null ? {} : { expiresAfterSeconds: duration }), }, }; } function outcomeKind(value: string): MechanicOutcome['kind'] | null { if (value === 'active_modifier') return 'attribute_modifier'; if (value === 'direct_damage') return 'direct_damage'; if ( value === 'restoration' || [ 'restore_health', 'restore_armor', 'restore_ammo', 'restore_signature_ability_charge', ].includes(value) ) { return 'restoration'; } if (value === 'resource_cost') return 'resource_cost'; if (value === 'weapon_reload') return 'weapon_reload'; if (value === 'marker') return 'marker'; return null; } function outcomeScope(value: string): MechanicOutcome['scope'] | null { if (value === 'self') return 'self'; if (value === 'equipped_weapon') return 'equipped_weapon'; if (value === 'inactive_weapon') return 'inactive_weapon'; if (value === 'target') return 'target'; if (value === 'allies') return 'allies'; if (value === 'self_and_allies') return 'self_and_allies'; if (value === 'assigned_ability') return 'assigned_ability'; if (value === 'all_skills') return 'all_skills'; return null; } function parseOutcome( row: ConditionalEffectOutcomeRow, ruleId: string, poolKeys: Map, index: number, errors: string[], ): { clauseId: string | null; outcome: MechanicOutcome } | null { const path = `Outcome ${index + 1}`; if (text(row.rule_id) !== ruleId) { errors.push(`${path} belongs to a different rule.`); return null; } const id = requiredText(row.id, `${path} ID`, errors); const label = requiredText(row.effect_key, `${path} effect key`, errors); const kind = outcomeKind(text(row.application_kind)); if (!kind) errors.push(`${path} has an unsupported application kind.`); const scope = outcomeScope(text(row.effect_scope)); if (!scope) { errors.push( `${path} has an effect scope that cannot be represented without losing meaning.`, ); } const basis = text(row.value_basis); if ( ![ 'flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent', ].includes(basis) ) { errors.push(`${path} has an unsupported value basis.`); } const phase = text(row.application_phase); if ( ![ 'while_active', 'on_event', 'after_event', 'on_enter', 'on_exit', ].includes(phase) ) { errors.push(`${path} has an unsupported application phase.`); } const variantValue = requiredNumber(row.value, `${path} value`, errors); let value: NumericExpression = variantValue; if ( row.value_expression_json !== null && row.value_expression_json !== undefined && row.value_expression_json !== '' ) { const parsed = parseJson( row.value_expression_json, `${path} value expression`, errors, ); if (parsed.ok) { const resolved = resolveVariantExpression(parsed.value, variantValue); if (resolved !== null) value = resolved; else errors.push( `${path} value expression is not a valid numeric expression.`, ); } } const statePoolId = nullableText(row.state_pool_id); const statePool = statePoolId ? poolKeys.get(statePoolId) : undefined; if (statePoolId && !statePool) errors.push(`${path} references an unknown state pool.`); const scaling = text(row.value_scaling); if (scaling !== 'flat' && scaling !== 'per_state_unit') { errors.push(`${path} has an unsupported value-scaling mode.`); } if (!kind || !scope) return null; const legacyResource = text(row.application_kind) === 'restore_health' ? 'health' : text(row.application_kind) === 'restore_armor' ? 'armor' : text(row.application_kind) === 'restore_ammo' ? 'ammo' : text(row.application_kind) === 'restore_signature_ability_charge' ? 'signature_ability_charge' : text(row.application_kind) === 'resource_cost' ? 'health' : null; const resource = nullableText(row.resource_kind) ?? legacyResource; if ( (kind === 'restoration' || kind === 'resource_cost') && !['health', 'armor', 'ammo', 'signature_ability_charge'].includes( String(resource), ) ) { errors.push(`${path} requires an explicit supported resource.`); } const combatTiming = nullableText(row.combat_timing); if ( combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current' ) { errors.push(`${path} has invalid combat timing.`); } const delivery = nullableText(row.delivery_mode) ?? 'instant'; if (delivery !== 'instant' && delivery !== 'continuous') { errors.push(`${path} has an unsupported delivery mode.`); } const durationSeconds = number(row.delivery_duration_seconds); if ( delivery === 'continuous' && (durationSeconds === null || durationSeconds <= 0) ) { errors.push(`${path} continuous delivery requires a positive duration.`); } if (delivery === 'instant' && durationSeconds !== null) { errors.push(`${path} instant delivery cannot set a duration.`); } const probabilityPercent = number(row.probability_percent); if ( row.probability_percent !== undefined && row.probability_percent !== null && row.probability_percent !== '' && (probabilityPercent === null || probabilityPercent < 0 || probabilityPercent > 100) ) { errors.push(`${path} probability must be from 0 through 100 percent.`); } let targeting: MechanicOutcome['targeting'] | undefined; if ( row.targeting_json !== null && row.targeting_json !== undefined && row.targeting_json !== '' ) { const parsed = parseJson(row.targeting_json, `${path} targeting`, errors); if ( parsed.ok && typeof parsed.value === 'object' && parsed.value !== null ) { targeting = parsed.value as MechanicOutcome['targeting']; } } return { clauseId: nullableText(row.clause_id), outcome: { id, label, phase: phase as OutcomePhase, kind, scope, value, basis: basis as OutcomeBasis, ...(resource && ['health', 'armor', 'ammo'].includes(resource) ? { resource: resource as 'health' | 'armor' | 'ammo' } : {}), ...(combatTiming === 'pre_event' || combatTiming === 'current' ? { combatTiming } : {}), ...(delivery === 'continuous' && durationSeconds !== null ? { delivery, durationSeconds } : {}), ...(targeting ? { targeting } : {}), ...(probabilityPercent === null ? {} : { probabilityPercent }), ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) ? { attributeId: text(row.stat_attribute_id) } : {}), ...(statePool ? { statePool } : {}), ...(scaling === 'per_state_unit' ? { perStack: true } : {}), ...(number(row.min_state_value) === null ? {} : { minStacks: number(row.min_state_value) as number }), ...(number(row.max_state_value) === null ? {} : { maxStacks: number(row.max_state_value) as number }), }, }; } /** * Converts normalized conditional-effect rows into the runtime's typed mechanic * program. Invalid persisted data is reported as `INVALID`; this function never * throws for row or JSON validation failures. */ export function adaptConditionalEffectProgram( input: MechanicPersistenceInput, options: MechanicPersistenceOptions = {}, ): MechanicPersistenceResult { const errors: string[] = []; const warnings: string[] = []; const readiness = text(input.rule.simulation_readiness); const simulationReadiness = [ 'ready', 'requires_assumptions', 'captured_only', ].includes(readiness) ? (readiness as MechanicPersistenceResult['simulationReadiness']) : null; if (!simulationReadiness) errors.push('Rule simulation readiness is invalid.'); const ruleId = requiredText(input.rule.id, 'Rule ID', errors); const sourceType = requiredText( input.rule.source_type, 'Rule source type', errors, ); const sourceId = requiredText(input.rule.source_id, 'Rule source ID', errors); const scriptVersion = requiredNumber( input.rule.script_version, 'Rule script version', errors, ); if (!Number.isInteger(scriptVersion) || scriptVersion <= 0) { errors.push('Rule script version must be a positive integer.'); } const scenarioInputs = parseScenarioInputs( input.rule.scenario_inputs_json, errors, ); const variantKeys = [ ...new Set( input.outcomes.map((row) => text(row.variant_key)).filter(Boolean), ), ]; let selectedVariant = options.variantKey?.trim() || ''; if (!selectedVariant) { if (variantKeys.length > 1) { errors.push( `A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`, ); } else { selectedVariant = variantKeys[0] ?? 'base'; } } else if (variantKeys.length && !variantKeys.includes(selectedVariant)) { errors.push(`Variant "${selectedVariant}" does not exist for this rule.`); } const parsedPools = input.statePools .slice() .sort( (left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0), ) .map((row, index) => parsePool(row, ruleId, index, selectedVariant, errors)) .filter((value): value is NonNullable => value !== null); const poolKeys = new Map( parsedPools.map((pool) => [pool.id, pool.definition.key]), ); const triggerMap = new Map( input.triggers.map((trigger) => [text(trigger.id), trigger]), ); const selectedOutcomes = input.outcomes .filter((row) => text(row.variant_key) === selectedVariant) .slice() .sort( (left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0), ) .map((row, index) => parseOutcome(row, ruleId, poolKeys, index, errors)) .filter((value): value is NonNullable => value !== null); const outcomesByClause = new Map(); const unassignedOutcomes: MechanicOutcome[] = []; for (const parsed of selectedOutcomes) { if (!parsed.clauseId) { unassignedOutcomes.push(parsed.outcome); continue; } const current = outcomesByClause.get(parsed.clauseId) ?? []; current.push(parsed.outcome); outcomesByClause.set(parsed.clauseId, current); } const activeRows = input.clauses .filter((row) => enabled(row.active)) .slice() .sort( (left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0), ); if (unassignedOutcomes.length && activeRows.length !== 1) { errors.push( 'Outcomes without a clause can only be adapted when the rule has exactly one active clause.', ); } const clauses: MechanicClause[] = []; for (const [index, row] of activeRows.entries()) { const path = `Clause ${index + 1}`; if (text(row.rule_id) !== ruleId) { errors.push(`${path} belongs to a different rule.`); continue; } const id = requiredText(row.id, `${path} ID`, errors); const triggerId = nullableText(row.trigger_id); const directEvent = nullableText(row.event_kind); const trigger = triggerId ? (triggerMap.get(triggerId) ?? null) : null; if (!directEvent && !trigger) { errors.push(`${path} references unknown trigger "${triggerId}".`); continue; } const parametersJson = parseJson( row.recipe_parameters_json ?? row.parameters_json, `${path} parameters`, errors, ); const conditionsJson = parseJson( row.conditions_json, `${path} conditions`, errors, ); const reducerJson = parseJson(row.reducer_json, `${path} reducer`, errors); const mutationsJson = parseJson( row.mutations_json, `${path} mutations`, errors, ); if ( !parametersJson.ok || !conditionsJson.ok || !reducerJson.ok || !mutationsJson.ok ) continue; if (!object(parametersJson.value)) { errors.push(`${path} parameters must be a JSON object.`); continue; } if (!isPredicate(conditionsJson.value)) { errors.push(`${path} conditions are not a valid predicate.`); continue; } if (!isReducer(reducerJson.value)) { errors.push(`${path} reducer is invalid.`); continue; } if ( !Array.isArray(mutationsJson.value) || !mutationsJson.value.every(isMutation) ) { errors.push( `${path} mutations must be an array of valid state mutations.`, ); continue; } const mapping = resolveClauseMapping( trigger, row.event_kind, parametersJson.value, conditionsJson.value, reducerJson.value, path, errors, ); if (!mapping) continue; const clauseOutcomes = outcomesByClause.get(id) ?? []; if (activeRows.length === 1) clauseOutcomes.push(...unassignedOutcomes); let weight: NumericExpression | undefined; if ( row.weight_expression_json !== null && row.weight_expression_json !== undefined && row.weight_expression_json !== '' ) { const parsedWeight = parseJson( row.weight_expression_json, `${path} weight`, errors, ); if (parsedWeight.ok) { if (isNumericExpression(parsedWeight.value)) weight = parsedWeight.value; else errors.push(`${path} weight is not a valid numeric expression.`); } } const recipeKey = nullableText(row.recipe_key); const procChancePercent = parametersJson.value.procChancePercent; if ( procChancePercent !== undefined && (!finite(procChancePercent) || procChancePercent < 0 || procChancePercent > 100) ) { errors.push(`${path} proc chance must be from 0 through 100 percent.`); } const requiresAssignedAbilityActive = parametersJson.value.requiresAssignedAbilityActive; if ( requiresAssignedAbilityActive !== undefined && typeof requiresAssignedAbilityActive !== 'boolean' ) { errors.push( `${path} assigned-ability activity requirement must be a boolean.`, ); } clauses.push({ id, label: text(row.label) || text(row.clause_key) || undefined, when: mapping.when, ...(recipeKey ? { recipeKey } : {}), ...(mapping.predicate && mapping.predicate.kind !== 'true' ? { if: mapping.predicate } : {}), reducer: mapping.reducer, ...(number(row.cooldown_seconds) === null ? {} : { cooldownSeconds: number(row.cooldown_seconds) as number }), ...(weight === undefined ? {} : { weight }), ...(typeof procChancePercent === 'number' ? { procChancePercent } : {}), ...(typeof requiresAssignedAbilityActive === 'boolean' ? { requiresAssignedAbilityActive } : {}), mutations: mutationsJson.value, ...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}), }); } for (const clauseId of outcomesByClause.keys()) { if (!activeRows.some((row) => text(row.id) === clauseId)) { errors.push( `Outcomes reference missing or inactive clause "${clauseId}".`, ); } } const program: MechanicProgram = { id: ruleId, name: options.name?.trim() || `${sourceType}:${sourceId}`, ...(scenarioInputs.length ? { scenarioInputs } : {}), ...(parsedPools.length ? { pools: parsedPools.map((pool) => pool.definition) } : {}), clauses, }; if (errors.length) { return { status: 'INVALID', simulationReadiness, program: null, errors, warnings, }; } try { const validation = validateMechanicProgram(program); if (!validation.ok) { return { status: 'INVALID', simulationReadiness, program: null, errors: validation.errors, warnings, }; } } catch { return { status: 'INVALID', simulationReadiness, program: null, errors: [ 'The persisted mechanic contains a structure that the runtime cannot validate.', ], warnings, }; } if ( !enabled(input.rule.configured) || simulationReadiness === 'captured_only' ) { warnings.push( 'The mechanic is retained for configuration and is not ready for simulation.', ); return { status: 'CAPTURED_ONLY', simulationReadiness, program, errors: [], warnings, }; } const requiresAssumptions = simulationReadiness === 'requires_assumptions' || scenarioInputs.some((item) => item.readiness === 'requires_assumption'); return { status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY', simulationReadiness, program, errors: [], warnings, }; }