feat: support extra health state mechanics
This commit is contained in:
@@ -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