feat: support extra health state mechanics
Validate and deploy production / validate (push) Successful in 21s
Validate and deploy staging / validate (push) Successful in 23s
Validate and deploy production / deploy (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 30s

This commit is contained in:
jb
2026-07-31 23:55:49 -04:00
parent 9ac2fe47cf
commit 1998e87309
16 changed files with 575 additions and 47 deletions
+112
View File
@@ -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.');
}