feat: support extra health state mechanics
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
registeredEventFields,
|
||||
readStatePoolThreshold,
|
||||
statePoolThresholdPredicate,
|
||||
type ConditionalMechanicDraftV1
|
||||
@@ -126,6 +127,54 @@ describe('conditional mechanic authoring payload', () => {
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes typed Extra Health fields and parses their manual editor state', () => {
|
||||
expect(registeredEventFields('extra_health_changed')).toEqual([
|
||||
{ path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true },
|
||||
{ path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0 }
|
||||
]);
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'extra_health_changed';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].reducer = { kind: 'none' };
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.hasExtraHealth',
|
||||
operator: 'eq',
|
||||
value: true
|
||||
});
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
when: 'extra_health_changed',
|
||||
if: value.clauses[0].if
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unregistered Extra Health event field deterministically', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'extra_health_changed';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].reducer = { kind: 'none' };
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.extraHealthActive',
|
||||
operator: 'eq',
|
||||
value: true
|
||||
});
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('is not defined for Extra Health changed')
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => {
|
||||
const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 };
|
||||
const predicate = statePoolThresholdPredicate(comparison);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
validateMechanicProgram,
|
||||
type ClauseMutation,
|
||||
type MechanicClause,
|
||||
@@ -174,6 +175,10 @@ export function readEventFieldComparison(
|
||||
};
|
||||
}
|
||||
|
||||
export function registeredEventFields(event: MechanicEventKind) {
|
||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||
}
|
||||
|
||||
export type StatePoolThresholdComparison = {
|
||||
pool: string;
|
||||
operator: PredicateComparisonOperator;
|
||||
|
||||
@@ -643,4 +643,33 @@ describe('conditional-effect persistence adapter', () => {
|
||||
expect(result.status).toBe('READY');
|
||||
expect(result.program?.clauses[0].if).toEqual(condition);
|
||||
});
|
||||
|
||||
it('round-trips the direct Extra Health event and its required-state predicate', () => {
|
||||
const condition = {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.hasExtraHealth' },
|
||||
operator: 'eq',
|
||||
right: true
|
||||
};
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'extra_health_changed',
|
||||
parameters_json: '{}',
|
||||
conditions_json: JSON.stringify(condition),
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
outcomes: [],
|
||||
triggers: []
|
||||
}, { name: 'Extra Health State', variantKey: 'I' });
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
when: 'extra_health_changed',
|
||||
if: condition
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,8 +178,9 @@ describe('mechanic recipe registry', () => {
|
||||
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
|
||||
'enter-extra-health-active', 'exit-extra-health-active'
|
||||
]);
|
||||
expect(extraHealth.clauses.every((clause) => clause.when === 'extra_health_changed')).toBe(true);
|
||||
expect(extraHealth.clauses[0].if).toEqual({
|
||||
kind: 'compare', left: { source: 'event', path: 'values.extraHealthActive' }, operator: 'eq', right: true
|
||||
kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from './conditional.js';
|
||||
import type {
|
||||
MechanicClause,
|
||||
MechanicEventKind,
|
||||
MechanicProgram,
|
||||
PoolDefinition,
|
||||
Predicate,
|
||||
@@ -168,8 +169,9 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
return { pools, clauses };
|
||||
}
|
||||
|
||||
function proximityLifecycleSeed(
|
||||
function eventLifecycleSeed(
|
||||
pool: PoolDefinition,
|
||||
event: MechanicEventKind,
|
||||
activePredicate: Predicate,
|
||||
label: string
|
||||
): MechanicRecipeSeed {
|
||||
@@ -179,7 +181,7 @@ function proximityLifecycleSeed(
|
||||
{
|
||||
id: `enter-${pool.key}`,
|
||||
label: `Enter ${label}`,
|
||||
when: 'proximity_snapshot',
|
||||
when: event,
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
|
||||
@@ -187,7 +189,7 @@ function proximityLifecycleSeed(
|
||||
{
|
||||
id: `exit-${pool.key}`,
|
||||
label: `Exit ${label}`,
|
||||
when: 'proximity_snapshot',
|
||||
when: event,
|
||||
if: { kind: 'not', predicate: activePredicate },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: pool.key }]
|
||||
@@ -312,8 +314,9 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
key: 'within_distance',
|
||||
createSeed: (params) => {
|
||||
const value = params as DistanceParams;
|
||||
return supported(proximityLifecycleSeed(
|
||||
return supported(eventLifecycleSeed(
|
||||
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
'proximity_snapshot',
|
||||
{ kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
|
||||
`target within ${value.meters}m`
|
||||
));
|
||||
@@ -321,9 +324,10 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
},
|
||||
extra_health_active: {
|
||||
key: 'extra_health_active',
|
||||
createSeed: (_params) => supported(proximityLifecycleSeed(
|
||||
createSeed: (_params) => supported(eventLifecycleSeed(
|
||||
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
{ kind: 'compare', left: eventValue('extraHealthActive'), operator: 'eq', right: true },
|
||||
'extra_health_changed',
|
||||
{ kind: 'compare', left: eventValue('hasExtraHealth'), operator: 'eq', right: true },
|
||||
'Extra Health active'
|
||||
))
|
||||
}
|
||||
|
||||
@@ -62,6 +62,138 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the authoritative Extra Health state contract', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.extra_health_changed).toEqual({
|
||||
key: 'extra_health_changed',
|
||||
label: 'Extra Health changed',
|
||||
description: expect.stringContaining('transition to zero'),
|
||||
strictFields: true,
|
||||
fields: [
|
||||
{
|
||||
path: 'values.hasExtraHealth',
|
||||
label: 'Has Extra Health',
|
||||
type: 'boolean',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
path: 'values.currentExtraHealth',
|
||||
label: 'Current Extra Health',
|
||||
type: 'number',
|
||||
minimum: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('gains one capped stack per second with Extra Health and clears all stacks on depletion', () => {
|
||||
const skillIntensityId = 'skill-intensity';
|
||||
const program: MechanicProgram = {
|
||||
id: 'electrical-protection',
|
||||
name: 'Electrical Protection',
|
||||
pools: [
|
||||
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
{ key: 'stacks', label: 'Electrical Protection stacks', scope: 'agent', initial: 0, min: 0, max: 10, bestCaseValue: 10 }
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'extra-health-present',
|
||||
when: 'extra_health_changed',
|
||||
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'extra-health-active', value: 1 }]
|
||||
},
|
||||
{
|
||||
id: 'extra-health-depleted',
|
||||
when: 'extra_health_changed',
|
||||
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: false },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'clear', pool: 'extra-health-active' },
|
||||
{ kind: 'clear', pool: 'stacks' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gain-stack',
|
||||
when: 'tick',
|
||||
if: { kind: 'compare', left: { source: 'pool', pool: 'extra-health-active' }, operator: 'eq', right: 1 },
|
||||
reducer: { kind: 'periodic', intervalSeconds: 1 },
|
||||
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
|
||||
outcomes: [{
|
||||
id: 'skill-intensity',
|
||||
label: 'Skill Intensity',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'all_skills',
|
||||
basis: 'flat',
|
||||
attributeId: skillIntensityId,
|
||||
value: 1.8,
|
||||
statePool: 'stacks',
|
||||
perStack: true
|
||||
}]
|
||||
}
|
||||
]
|
||||
};
|
||||
const ticks = Array.from({ length: 13 }, (_, index): MechanicEvent => ({ kind: 'tick', at: index }));
|
||||
const capped = evaluateMechanicProgram(program, [
|
||||
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: 500 } },
|
||||
...ticks
|
||||
], custom(12), { currentHealth: 1000, maxHealth: 1000, attributes: { [skillIntensityId]: 0 } });
|
||||
expect(capped.status).toBe('OK');
|
||||
expect(capped.finalState).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ pool: 'stacks', value: 10 })
|
||||
]));
|
||||
expect(capped.outcomes.at(-1)).toMatchObject({ value: 18, stateValue: 10 });
|
||||
|
||||
const depleted = evaluateMechanicProgram(program, [
|
||||
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: 500 } },
|
||||
{ kind: 'tick', at: 0 },
|
||||
{ kind: 'tick', at: 1 },
|
||||
{ kind: 'extra_health_changed', at: 1.5, values: { hasExtraHealth: true, currentExtraHealth: 100 } },
|
||||
{ kind: 'tick', at: 2 },
|
||||
{ kind: 'extra_health_changed', at: 2.5, values: { hasExtraHealth: false, currentExtraHealth: 0 } },
|
||||
{ kind: 'tick', at: 3 }
|
||||
], custom(3), { currentHealth: 1000, maxHealth: 1000, attributes: { [skillIntensityId]: 0 } });
|
||||
expect(depleted.status).toBe('OK');
|
||||
expect(depleted.finalState).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ pool: 'extra-health-active', value: 0 }),
|
||||
expect.objectContaining({ pool: 'stacks', value: 0 })
|
||||
]));
|
||||
expect(depleted.emissions.filter((emission) => emission.clauseId === 'gain-stack')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects malformed Extra Health state fields and inconsistent amounts', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'extra-health-state',
|
||||
name: 'Extra Health state',
|
||||
clauses: [{
|
||||
id: 'state-change',
|
||||
when: 'extra_health_changed',
|
||||
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }]
|
||||
}]
|
||||
};
|
||||
for (const event of [
|
||||
{ kind: 'extra_health_changed', at: 0, values: {} },
|
||||
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: -1 } },
|
||||
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: false, currentExtraHealth: 1 } }
|
||||
] as MechanicEvent[]) {
|
||||
expect(evaluateMechanicProgram(program, [event], custom(1)).status).toBe('INVALID');
|
||||
}
|
||||
|
||||
const unknownField = structuredClone(program);
|
||||
unknownField.clauses[0].if = {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.extraHealthActive' },
|
||||
operator: 'eq',
|
||||
right: true
|
||||
};
|
||||
expect(validateMechanicProgram(unknownField)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [expect.stringContaining('is not defined for Extra Health changed')]
|
||||
});
|
||||
});
|
||||
|
||||
it('executes imported talent trigger families as first-class events', () => {
|
||||
const eventKinds: MechanicEventKind[] = [
|
||||
'ally_downed',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const MECHANIC_EVENT_KINDS = [
|
||||
'medical_kit_used',
|
||||
'healing_applied',
|
||||
'extra_health_granted',
|
||||
'extra_health_changed',
|
||||
'movement_snapshot',
|
||||
'proximity_snapshot',
|
||||
'firing_started',
|
||||
@@ -36,7 +37,10 @@ export type MechanicEventDefinition = {
|
||||
path: string;
|
||||
label: string;
|
||||
type: 'boolean' | 'number' | 'string';
|
||||
required?: boolean;
|
||||
minimum?: number;
|
||||
}[];
|
||||
strictFields?: boolean;
|
||||
};
|
||||
|
||||
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = {
|
||||
@@ -171,6 +175,16 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
label: 'Extra Health granted',
|
||||
description: 'The Agent grants Extra Health to a recipient.'
|
||||
},
|
||||
extra_health_changed: {
|
||||
key: 'extra_health_changed',
|
||||
label: 'Extra Health changed',
|
||||
description: 'The Agent’s remaining Extra Health changes, including grants, reductions, and the transition to zero.',
|
||||
strictFields: true,
|
||||
fields: [
|
||||
{ path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true },
|
||||
{ path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0 }
|
||||
]
|
||||
},
|
||||
movement_snapshot: {
|
||||
key: 'movement_snapshot',
|
||||
label: 'Movement snapshot',
|
||||
@@ -1378,6 +1392,97 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
|
||||
if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`);
|
||||
}
|
||||
|
||||
function validateStrictEventReferences(
|
||||
value: unknown,
|
||||
eventKind: MechanicEventKind,
|
||||
errors: string[],
|
||||
path: string
|
||||
) {
|
||||
const definition = MECHANIC_EVENT_REGISTRY[eventKind];
|
||||
if (!definition?.strictFields || value === null || typeof value !== 'object') return;
|
||||
if (
|
||||
'source' in value
|
||||
&& value.source === 'event'
|
||||
&& 'path' in value
|
||||
&& typeof value.path === 'string'
|
||||
&& !definition.fields?.some((field) => field.path === value.path)
|
||||
) {
|
||||
errors.push(`${path} references event field "${value.path}" that is not defined for ${definition.label}.`);
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
validateStrictEventReferences(child, eventKind, errors, `${path} ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStrictEventPredicate(
|
||||
predicate: Predicate | undefined,
|
||||
eventKind: MechanicEventKind,
|
||||
errors: string[],
|
||||
path: string
|
||||
) {
|
||||
const definition = MECHANIC_EVENT_REGISTRY[eventKind];
|
||||
if (!definition?.strictFields || !predicate) return;
|
||||
if (predicate.kind === 'not') {
|
||||
validateStrictEventPredicate(predicate.predicate, eventKind, errors, `${path} NOT`);
|
||||
return;
|
||||
}
|
||||
if (predicate.kind === 'all' || predicate.kind === 'any') {
|
||||
predicate.predicates.forEach((item, index) =>
|
||||
validateStrictEventPredicate(item, eventKind, errors, `${path} ${index + 1}`));
|
||||
return;
|
||||
}
|
||||
if (predicate.kind !== 'compare') return;
|
||||
const eventReference = typeof predicate.left === 'object' && predicate.left?.source === 'event'
|
||||
? predicate.left
|
||||
: typeof predicate.right === 'object' && predicate.right?.source === 'event'
|
||||
? predicate.right
|
||||
: null;
|
||||
const scalar = eventReference === predicate.left ? predicate.right : predicate.left;
|
||||
if (!eventReference || typeof scalar === 'object') return;
|
||||
const field = definition.fields?.find((item) => item.path === eventReference.path);
|
||||
if (!field || scalar === null) return;
|
||||
if (typeof scalar !== field.type) {
|
||||
errors.push(`${path} must compare ${field.label} with a ${field.type} value.`);
|
||||
}
|
||||
if (field.type !== 'number' && !['eq', 'ne'].includes(predicate.operator)) {
|
||||
errors.push(`${path} must use equality comparison for ${field.label}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateMechanicEvents(events: MechanicEvent[]) {
|
||||
const errors: string[] = [];
|
||||
for (const [index, event] of events.entries()) {
|
||||
const definition = MECHANIC_EVENT_REGISTRY[event.kind];
|
||||
if (!definition?.strictFields) continue;
|
||||
for (const field of definition.fields ?? []) {
|
||||
const value = getPath(event, field.path);
|
||||
const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`;
|
||||
if (value === undefined || value === null) {
|
||||
if (field.required) errors.push(`${fieldPath} is required.`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== field.type || (field.type === 'number' && !finiteNumber(value))) {
|
||||
errors.push(`${fieldPath} must be a ${field.type}.`);
|
||||
continue;
|
||||
}
|
||||
if (field.type === 'number' && field.minimum !== undefined && (value as number) < field.minimum) {
|
||||
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
||||
}
|
||||
}
|
||||
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
||||
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
||||
if (
|
||||
event.kind === 'extra_health_changed'
|
||||
&& typeof hasExtraHealth === 'boolean'
|
||||
&& finiteNumber(currentExtraHealth)
|
||||
&& hasExtraHealth !== (currentExtraHealth > 0)
|
||||
) {
|
||||
errors.push(`Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function visitExpression(
|
||||
expression: NumericExpression,
|
||||
pools: Set<string>,
|
||||
@@ -1600,7 +1705,10 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
errors.push(`${path} has an unknown event kind.`);
|
||||
}
|
||||
if (clause.if) visitPredicate(clause.if, poolKeys, inputKeys, errors, `${path} predicate`);
|
||||
validateStrictEventReferences(clause.if, clause.when, errors, `${path} predicate`);
|
||||
validateStrictEventPredicate(clause.if, clause.when, errors, `${path} predicate`);
|
||||
if (clause.weight) visitExpression(clause.weight, poolKeys, inputKeys, errors, `${path} weight`);
|
||||
validateStrictEventReferences(clause.weight, clause.when, errors, `${path} weight`);
|
||||
if (
|
||||
clause.cooldownSeconds !== undefined
|
||||
&& (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0)
|
||||
@@ -1658,6 +1766,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
if (reducer.kind === 'derived_scalar') {
|
||||
visitExpression(reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`);
|
||||
}
|
||||
validateStrictEventReferences(reducer, clause.when, errors, `${path} reducer`);
|
||||
for (const [mutationIndex, mutation] of clause.mutations.entries()) {
|
||||
if (mutation.kind === 'none') continue;
|
||||
const mutationPath = `${path} mutation ${mutationIndex + 1}`;
|
||||
@@ -1672,6 +1781,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
}
|
||||
}
|
||||
}
|
||||
validateStrictEventReferences(clause.mutations, clause.when, errors, `${path} mutations`);
|
||||
for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
|
||||
const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
|
||||
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`);
|
||||
@@ -1776,6 +1886,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
}
|
||||
visitExpression(outcome.value, poolKeys, inputKeys, errors, `${outcomePath} value`);
|
||||
}
|
||||
validateStrictEventReferences(clause.outcomes, clause.when, errors, `${path} outcomes`);
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: program };
|
||||
}
|
||||
@@ -1789,6 +1900,7 @@ export function evaluateMechanicProgram(
|
||||
): MechanicEvaluation {
|
||||
const validation = validateMechanicProgram(program);
|
||||
const scenarioErrors: string[] = [];
|
||||
scenarioErrors.push(...validateMechanicEvents(events));
|
||||
if (!finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0) {
|
||||
scenarioErrors.push('Encounter seconds must be greater than 0.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user