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
@@ -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);
+5
View File
@@ -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
});
});
});
+10 -6
View File
@@ -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'
))
}
+132
View File
@@ -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',
+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.');
}