676 lines
23 KiB
TypeScript
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: []
|
|
};
|
|
}
|