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

676 lines
23 KiB
TypeScript

import {
MECHANIC_EVENT_KINDS,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicOutcomeScope,
type MechanicProgram,
type MechanicResource,
type NumericExpression,
type OutcomeBasis,
type OutcomePhase,
type PoolDefinition,
type Predicate,
type Reducer,
type ScenarioInputDefinition,
type StateScope,
type ValueReference
} from './mechanic.js';
export const CONDITIONAL_MECHANIC_DRAFT_VERSION = 1 as const;
export type MechanicStateKind =
| 'flag'
| 'count'
| 'stack'
| 'duration'
| 'scalar'
| 'target_mark'
| 'resource';
export type ConditionalMechanicPoolDraft = PoolDefinition & {
stateKind: MechanicStateKind;
};
export type ConditionalMechanicVariantValue = {
value: number;
expression?: NumericExpression;
};
export type ConditionalMechanicOutcomeDraft = Omit<MechanicOutcome, 'id' | 'value'> & {
key: string;
descriptionSlot?: number;
variants: Record<string, ConditionalMechanicVariantValue>;
};
export type ConditionalMechanicClauseDraft = Omit<MechanicClause, 'id' | 'when' | 'outcomes'> & {
key: string;
event: MechanicEventKind;
recipeKey?: string;
outcomes: ConditionalMechanicOutcomeDraft[];
};
export type ConditionalMechanicDraftV1 = {
version: typeof CONDITIONAL_MECHANIC_DRAFT_VERSION;
simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only';
scenarioInputs: ScenarioInputDefinition[];
pools: ConditionalMechanicPoolDraft[];
clauses: ConditionalMechanicClauseDraft[];
};
export type ParseConditionalMechanicDraftOptions = {
variantKeys: readonly string[];
programId?: string;
programName?: string;
allowedAttributeIds?: ReadonlySet<string>;
allowedRecipeKeys?: ReadonlySet<string>;
};
export type ParsedConditionalMechanicDraft = {
draft: ConditionalMechanicDraftV1;
programs: Record<string, MechanicProgram>;
};
export type ConditionalMechanicDraftResult =
| { ok: true; value: ParsedConditionalMechanicDraft }
| { ok: false; errors: string[] };
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 scalar = (value: unknown) =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);
const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
const events = new Set<string>(MECHANIC_EVENT_KINDS);
export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'],
optional: [
'resource',
'delivery',
'durationSeconds',
'combatTiming',
'attributeId',
'statePool',
'perStack',
'minStacks',
'maxStacks',
'descriptionSlot'
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'],
scope: [
'self',
'equipped_weapon',
'target',
'allies',
'self_and_allies',
'assigned_ability',
'all_skills'
],
basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
resource: ['health', 'armor', 'ammo'],
delivery: ['instant', 'continuous'],
deliverySemantics: {
instant: 'The full resolved value applies at activation.',
continuous:
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.'
}
} as const;
const phases = new Set<OutcomePhase>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase);
const outcomeKinds = new Set<MechanicOutcome['kind']>(
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.kind
);
const scopes = new Set<MechanicOutcomeScope>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.scope);
const bases = new Set<OutcomeBasis>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.basis);
const resources = new Set<MechanicResource>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.resource);
const deliveryModes = new Set<string>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.delivery);
const outcomeFields = new Set<string>([
...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.required,
...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.optional
]);
const stateKinds = new Set<MechanicStateKind>([
'flag',
'count',
'stack',
'duration',
'scalar',
'target_mark',
'resource'
]);
function parseRaw(raw: unknown, errors: string[]) {
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch {
errors.push('The conditional mechanic contains malformed JSON.');
return null;
}
}
function key(value: unknown, path: string, errors: string[]) {
if (typeof value !== 'string' || !identifier.test(value)) {
errors.push(`${path} must use a stable lower-case key.`);
return '';
}
return value;
}
function isEntitySelector(value: unknown) {
if (!object(value) || typeof value.kind !== 'string') return false;
if (['agent', 'event_actor', 'event_target'].includes(value.kind)) return true;
return value.kind === 'event_field' && typeof value.path === 'string' && Boolean(value.path.trim());
}
function isReference(value: unknown): value is ValueReference {
if (!object(value) || typeof value.source !== 'string') return false;
switch (value.source) {
case 'event':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'scenario':
return typeof value.key === 'string' && Boolean(value.key.trim());
case 'combat':
return typeof value.path === 'string'
&& Boolean(value.path.trim())
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'attribute':
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
case 'reducer':
return true;
case 'pool':
return typeof value.pool === 'string'
&& Boolean(value.pool.trim())
&& (value.entity === undefined || isEntitySelector(value.entity));
default:
return false;
}
}
function validateAttributeReferences(
value: unknown,
allowedAttributeIds: ReadonlySet<string> | undefined,
errors: string[],
path = 'Conditional mechanic'
) {
if (Array.isArray(value)) {
value.forEach((item, index) =>
validateAttributeReferences(item, allowedAttributeIds, errors, `${path} item ${index + 1}`));
return;
}
if (!object(value)) return;
if (
value.source === 'attribute'
&& typeof value.attributeId === 'string'
&& value.attributeId.trim()
&& allowedAttributeIds
&& !allowedAttributeIds.has(value.attributeId)
) {
errors.push(`${path} references unknown Attribute "${value.attributeId}".`);
return;
}
for (const [key, nested] of Object.entries(value)) {
validateAttributeReferences(nested, allowedAttributeIds, errors, `${path} ${key}`);
}
}
function isExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) return isReference(value.ref);
if (value.op === 'clamp') {
return isExpression(value.value) && isExpression(value.min) && isExpression(value.max);
}
return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op))
&& isExpression(value.left)
&& isExpression(value.right);
}
function isOperand(value: unknown) {
return scalar(value) || isReference(value);
}
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 isReference(value.value);
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))
&& isOperand(value.left)
&& isOperand(value.right);
}
function optionalGroupBy(value: Record<string, unknown>) {
return value.groupBy === undefined || isReference(value.groupBy);
}
function isReducer(value: unknown): value is Reducer {
if (!object(value) || typeof value.kind !== 'string') return false;
switch (value.kind) {
case 'none':
return true;
case 'count':
return finite(value.threshold) && optionalGroupBy(value);
case 'modulo':
return finite(value.every) && optionalGroupBy(value);
case 'distinct_count':
return finite(value.threshold)
&& finite(value.windowSeconds)
&& isReference(value.distinctBy);
case 'consecutive_count':
return finite(value.threshold) && optionalGroupBy(value);
case 'sliding_count':
return finite(value.threshold) && finite(value.windowSeconds) && optionalGroupBy(value);
case 'duration':
return finite(value.seconds) && optionalGroupBy(value);
case 'periodic':
return isExpression(value.intervalSeconds) && optionalGroupBy(value);
case 'derived_scalar':
return isExpression(value.value);
default:
return false;
}
}
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' || isExpression(value.value));
}
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
&& typeof value.pool === 'string'
&& isExpression(value.value)
&& (value.entity === undefined || isEntitySelector(value.entity));
}
function parseScenarioInputs(value: unknown, errors: string[]) {
if (!Array.isArray(value)) {
errors.push('Scenario inputs must be an array.');
return [] as ScenarioInputDefinition[];
}
const inputs: ScenarioInputDefinition[] = [];
for (const [index, candidate] of value.entries()) {
const path = `Scenario input ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
if (
!key(candidate.key, `${path} key`, errors)
|| typeof candidate.label !== 'string'
|| !candidate.label.trim()
|| !['toggle', 'slider', 'number'].includes(String(candidate.kind))
) {
errors.push(`${path} requires a label and supported input kind.`);
continue;
}
if (
candidate.readiness !== undefined
&& candidate.readiness !== 'ready'
&& candidate.readiness !== 'requires_assumption'
) {
errors.push(`${path} has an invalid readiness value.`);
continue;
}
inputs.push(candidate as ScenarioInputDefinition);
}
return inputs;
}
function parsePools(value: unknown, errors: string[]) {
if (!Array.isArray(value)) {
errors.push('State pools must be an array.');
return [] as ConditionalMechanicPoolDraft[];
}
const pools: ConditionalMechanicPoolDraft[] = [];
for (const [index, candidate] of value.entries()) {
const path = `State pool ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
const poolKey = key(candidate.key, `${path} key`, errors);
if (
!poolKey
|| typeof candidate.label !== 'string'
|| !candidate.label.trim()
|| !stateKinds.has(candidate.stateKind as MechanicStateKind)
|| !['agent', 'target'].includes(String(candidate.scope))
|| !finite(candidate.initial)
) {
errors.push(`${path} requires a label, state kind, scope, and finite initial value.`);
continue;
}
for (const property of ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'] as const) {
if (candidate[property] !== undefined && !finite(candidate[property])) {
errors.push(`${path} ${property} must be a finite number.`);
}
}
pools.push(candidate as ConditionalMechanicPoolDraft);
}
return pools;
}
function parseOutcome(
value: unknown,
path: string,
variantKeys: readonly string[],
allowedAttributes: ReadonlySet<string> | undefined,
errors: string[]
): ConditionalMechanicOutcomeDraft | null {
if (!object(value)) {
errors.push(`${path} must be an object.`);
return null;
}
for (const property of Object.keys(value)) {
if (!outcomeFields.has(property)) {
errors.push(`${path} contains unsupported field "${property}".`);
}
}
const outcomeKey = key(value.key, `${path} key`, errors);
if (
!outcomeKey
|| typeof value.label !== 'string'
|| !value.label.trim()
|| !phases.has(value.phase as OutcomePhase)
|| !outcomeKinds.has(value.kind as MechanicOutcome['kind'])
|| !scopes.has(value.scope as MechanicOutcomeScope)
|| !bases.has(value.basis as OutcomeBasis)
) {
errors.push(`${path} requires a label, phase, kind, scope, and value basis.`);
return null;
}
const kind = value.kind as MechanicOutcome['kind'];
if (kind === 'attribute_modifier') {
if (typeof value.attributeId !== 'string' || !value.attributeId.trim()) {
errors.push(`${path} attribute modifiers require an Attribute ID.`);
} else if (allowedAttributes && !allowedAttributes.has(value.attributeId)) {
errors.push(`${path} references unknown Attribute "${value.attributeId}".`);
}
} else if (value.attributeId !== undefined) {
errors.push(`${path} may only use an Attribute ID for an attribute modifier.`);
}
if (kind === 'restoration' || kind === 'resource_cost') {
if (!resources.has(value.resource as MechanicResource)) {
errors.push(`${path} ${kind.replace('_', ' ')} requires a resource.`);
}
} else if (value.resource !== undefined) {
errors.push(`${path} may only use a resource for restoration or resource cost.`);
}
const delivery = value.delivery ?? 'instant';
if (!deliveryModes.has(String(delivery))) {
errors.push(`${path} has an invalid delivery mode.`);
} else if (delivery === 'continuous') {
if (kind !== 'restoration') {
errors.push(`${path} may only use continuous delivery for restoration.`);
}
if (!finite(value.durationSeconds) || value.durationSeconds <= 0) {
errors.push(`${path} continuous delivery requires a positive duration.`);
}
} else if (value.durationSeconds !== undefined) {
errors.push(`${path} may only set a duration for continuous delivery.`);
}
if (
value.combatTiming !== undefined
&& value.combatTiming !== 'pre_event'
&& value.combatTiming !== 'current'
) {
errors.push(`${path} has invalid combat timing.`);
}
if (
value.descriptionSlot !== undefined
&& (!Number.isInteger(value.descriptionSlot) || Number(value.descriptionSlot) < 1)
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
}
const actualKeys = Object.keys(value.variants).sort();
const expectedKeys = [...variantKeys].sort();
if (
actualKeys.length !== expectedKeys.length
|| actualKeys.some((variant, index) => variant !== expectedKeys[index])
) {
errors.push(`${path} must define exactly these variants: ${variantKeys.join(', ')}.`);
}
const variants: Record<string, ConditionalMechanicVariantValue> = {};
for (const variant of variantKeys) {
const configured = value.variants[variant];
if (!object(configured) || !finite(configured.value)) {
errors.push(`${path} variant "${variant}" requires a finite display value.`);
continue;
}
if (configured.expression !== undefined && !isExpression(configured.expression)) {
errors.push(`${path} variant "${variant}" has an invalid value expression.`);
continue;
}
variants[variant] = configured as ConditionalMechanicVariantValue;
}
return {
...(value as unknown as ConditionalMechanicOutcomeDraft),
key: outcomeKey,
variants
};
}
function parseClauses(
value: unknown,
variantKeys: readonly string[],
options: ParseConditionalMechanicDraftOptions,
errors: string[]
) {
if (!Array.isArray(value)) {
errors.push('Mechanic clauses must be an array.');
return [] as ConditionalMechanicClauseDraft[];
}
const clauses: ConditionalMechanicClauseDraft[] = [];
for (const [index, candidate] of value.entries()) {
const path = `Clause ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
const clauseKey = key(candidate.key, `${path} key`, errors);
if (!clauseKey || !events.has(String(candidate.event))) {
errors.push(`${path} requires a supported direct event.`);
continue;
}
if (
candidate.recipeKey !== undefined
&& (
typeof candidate.recipeKey !== 'string'
|| !candidate.recipeKey.trim()
|| (options.allowedRecipeKeys && !options.allowedRecipeKeys.has(candidate.recipeKey))
)
) {
errors.push(`${path} has an unknown recipe key.`);
}
if (!isPredicate(candidate.if ?? { kind: 'true' })) {
errors.push(`${path} has an invalid predicate.`);
}
if (!isReducer(candidate.reducer)) errors.push(`${path} has an invalid reducer.`);
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
errors.push(`${path} has an invalid weight expression.`);
}
if (
candidate.cooldownSeconds !== undefined
&& (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
) {
errors.push(`${path} cooldown must be greater than zero.`);
}
if (!Array.isArray(candidate.mutations) || !candidate.mutations.every(isMutation)) {
errors.push(`${path} mutations must be a valid array.`);
}
if (!Array.isArray(candidate.outcomes)) {
errors.push(`${path} outcomes must be an array.`);
continue;
}
const outcomes = candidate.outcomes
.map((outcome, outcomeIndex) => parseOutcome(
outcome,
`${path} outcome ${outcomeIndex + 1}`,
variantKeys,
options.allowedAttributeIds,
errors
))
.filter((outcome): outcome is ConditionalMechanicOutcomeDraft => outcome !== null);
clauses.push({
...(candidate as unknown as ConditionalMechanicClauseDraft),
key: clauseKey,
event: candidate.event as MechanicEventKind,
if: (candidate.if ?? { kind: 'true' }) as Predicate,
reducer: candidate.reducer as Reducer,
mutations: Array.isArray(candidate.mutations) ? candidate.mutations as ClauseMutation[] : [],
outcomes
});
}
return clauses;
}
function programsForDraft(
draft: ConditionalMechanicDraftV1,
options: ParseConditionalMechanicDraftOptions
) {
const programs: Record<string, MechanicProgram> = {};
for (const variant of options.variantKeys) {
programs[variant] = {
id: `${options.programId ?? 'mechanic'}:${variant}`,
name: options.programName?.trim() || 'Conditional Mechanic',
scenarioInputs: draft.scenarioInputs,
pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool),
clauses: draft.clauses.map((clause): MechanicClause => ({
id: clause.key,
...(clause.label ? { label: clause.label } : {}),
when: clause.event,
...(clause.recipeKey ? { recipeKey: clause.recipeKey } : {}),
...(clause.if ? { if: clause.if } : {}),
reducer: clause.reducer,
...(clause.cooldownSeconds === undefined
? {}
: { cooldownSeconds: clause.cooldownSeconds }),
...(clause.weight === undefined ? {} : { weight: clause.weight }),
mutations: clause.mutations,
outcomes: clause.outcomes.map(({ key: outcomeKey, variants, descriptionSlot: _slot, ...outcome }) => ({
...outcome,
id: outcomeKey,
value: variants[variant].expression ?? variants[variant].value
}))
}))
};
}
return programs;
}
/**
* Parses the browser-authored mechanic payload without evaluating text as code.
* Every branch is converted to each source variant and validated by the runtime.
* Malformed payloads return errors and never escape into page rendering as throws.
*/
export function parseConditionalMechanicDraft(
raw: unknown,
options: ParseConditionalMechanicDraftOptions
): ConditionalMechanicDraftResult {
const errors: string[] = [];
try {
if (
!Array.isArray(options.variantKeys)
|| !options.variantKeys.length
|| options.variantKeys.some((variant) => !variant.trim())
|| new Set(options.variantKeys).size !== options.variantKeys.length
) {
return { ok: false, errors: ['Variant keys must be unique non-empty strings.'] };
}
const parsed = parseRaw(raw, errors);
if (!object(parsed)) return { ok: false, errors: errors.length ? errors : ['The conditional mechanic must be an object.'] };
if (parsed.version !== CONDITIONAL_MECHANIC_DRAFT_VERSION) {
errors.push(`Conditional mechanic version ${CONDITIONAL_MECHANIC_DRAFT_VERSION} is required.`);
}
if (!['ready', 'requires_assumptions', 'captured_only'].includes(String(parsed.simulationReadiness))) {
errors.push('The conditional mechanic has invalid simulation readiness.');
}
const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors);
const pools = parsePools(parsed.pools, errors);
const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors);
const draft: ConditionalMechanicDraftV1 = {
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
simulationReadiness: parsed.simulationReadiness as ConditionalMechanicDraftV1['simulationReadiness'],
scenarioInputs,
pools,
clauses
};
validateAttributeReferences(draft, options.allowedAttributeIds, errors);
const inputRequiresAssumption = scenarioInputs.some(
(input) => input.readiness === 'requires_assumption'
);
if (draft.simulationReadiness === 'ready' && inputRequiresAssumption) {
errors.push('Simulation readiness must require assumptions while an input requires an assumption.');
}
if (draft.simulationReadiness !== 'captured_only' && !clauses.length) {
errors.push('A simulation-ready mechanic requires at least one clause.');
}
const duplicate = (values: string[], label: string) => {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) errors.push(`${label} "${value}" is duplicated.`);
seen.add(value);
}
};
duplicate(scenarioInputs.map((input) => input.key), 'Scenario input');
duplicate(pools.map((pool) => pool.key), 'State pool');
duplicate(clauses.map((clause) => clause.key), 'Clause');
for (const clause of clauses) {
duplicate(clause.outcomes.map((outcome) => outcome.key), `Clause "${clause.key}" outcome`);
}
if (errors.length) return { ok: false, errors };
const programs = programsForDraft(draft, options);
for (const [variant, program] of Object.entries(programs)) {
let validation;
try {
validation = validateMechanicProgram(program);
} catch {
errors.push(`Variant "${variant}" contains a structure the mechanic runtime cannot validate.`);
continue;
}
if (!validation.ok) {
errors.push(...validation.errors.map((error) => `Variant "${variant}": ${error}`));
}
}
return errors.length
? { ok: false, errors }
: { ok: true, value: { draft, programs } };
} catch {
return {
ok: false,
errors: ['The conditional mechanic could not be parsed safely.']
};
}
}
export function blankConditionalMechanicDraft(): ConditionalMechanicDraftV1 {
return {
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
};
}