Files
shd.build/src/lib/calculations/mechanic-persistence.ts
T

1170 lines
35 KiB
TypeScript

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<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 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<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,
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<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.`);
}
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<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 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.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,
};
}