feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
@@ -0,0 +1,757 @@
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
} 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;
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;
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<string>(MECHANIC_EVENT_KINDS);
const object = (value: unknown): value is Record<string, unknown> =>
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 isNumericExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) {
if (!object(value.ref) || typeof value.ref.source !== 'string') return false;
const source = value.ref.source;
if (source === 'event' || source === 'combat' || source === 'equipment') {
return typeof value.ref.path === 'string';
}
if (source === 'scenario') return typeof value.ref.key === 'string';
if (source === 'reducer') return true;
if (source === 'pool') return typeof value.ref.pool === 'string';
return false;
}
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 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';
if (value.kind === 'transfer') {
return typeof value.pool === 'string' && object(value.from) && object(value.to)
&& (value.value === undefined || value.value === 'all' || isNumericExpression(value.value));
}
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
&& typeof value.pool === 'string'
&& isNumericExpression(value.value);
}
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<string, unknown>,
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<string, unknown>,
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,
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.`);
return {
id,
definition: {
key,
label,
scope: scope === 'target' ? 'target' : 'agent',
initial,
...(minimum === null ? {} : { min: minimum }),
max: maximum,
...(bestCase === null ? {} : { bestCaseValue: bestCase }),
...(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'].includes(value)) {
return 'restoration';
}
if (value === 'resource_cost') return 'resource_cost';
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 === '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<string, string>,
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.`);
}
let value: NumericExpression = requiredNumber(row.value, `${path} value`, errors);
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) {
if (isNumericExpression(parsed.value)) value = parsed.value;
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) === 'resource_cost'
? 'health'
: null;
const resource = nullableText(row.resource_kind) ?? legacyResource;
if (
(kind === 'restoration' || kind === 'resource_cost')
&& !['health', 'armor', 'ammo'].includes(String(resource))
) {
errors.push(`${path} requires an explicit health, armor, or ammo resource.`);
}
const combatTiming = nullableText(row.combat_timing);
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
errors.push(`${path} has invalid combat timing.`);
}
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 }
: {}),
...(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 parsedPools = input.statePools
.slice()
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
.map((row, index) => parsePool(row, ruleId, index, errors))
.filter((value): value is NonNullable<typeof value> => 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 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 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<typeof value> => value !== null);
const outcomesByClause = new Map<string, MechanicOutcome[]>();
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.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);
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 }),
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
};
}