feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user