feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
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);
|
||||
const phases = new Set<OutcomePhase>(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']);
|
||||
const outcomeKinds = new Set<MechanicOutcome['kind']>([
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost',
|
||||
'marker'
|
||||
]);
|
||||
const scopes = new Set<MechanicOutcomeScope>([
|
||||
'self',
|
||||
'equipped_weapon',
|
||||
'target',
|
||||
'allies',
|
||||
'self_and_allies',
|
||||
'assigned_ability',
|
||||
'all_skills'
|
||||
]);
|
||||
const bases = new Set<OutcomeBasis>([
|
||||
'flat',
|
||||
'max_health_percent',
|
||||
'current_health_percent',
|
||||
'weapon_base_percent'
|
||||
]);
|
||||
const resources = new Set<MechanicResource>(['health', 'armor', 'ammo']);
|
||||
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 'reducer':
|
||||
return true;
|
||||
case 'pool':
|
||||
return typeof value.pool === 'string'
|
||||
&& Boolean(value.pool.trim())
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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.`);
|
||||
}
|
||||
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
|
||||
};
|
||||
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: []
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user