1170 lines
35 KiB
TypeScript
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,
|
|
};
|
|
}
|