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
+7 -4
View File
@@ -2178,13 +2178,16 @@ The summary reports `CAPTURE ONLY · MODEL LATER` or the configured branch count
in text. Its native disclosure semantics preserve keyboard and assistive
technology support; mobile targets remain at least 44 pixels tall.
The direct-event picker reads the 19 normalized events from the code-defined
The direct-event picker reads the 28 normalized events from the code-defined
event registry. Use searchable single-assignment because the vocabulary exceeds
the compact segmented-choice limit. Each branch presents its event, match
policy and parameters, optional cooldown, condition, state mutations, and
outcomes. The branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`,
and `STATE THRESHOLD` for manual authoring. Event-field conditions expose the
field path, comparison operator, scalar type, and typed value. State thresholds
and `STATE THRESHOLD` for manual authoring. When the selected event publishes
field metadata, event-field conditions derive their available paths and scalar
types from that metadata. Events without a bounded field contract retain the
explicit field-path and value-type controls. Event-field conditions expose the
comparison operator and typed value. State thresholds
compare an agent-scoped pool with a numeric value. Health bands expose an
optional inclusive lower Health percentage and a required exclusive upper
percentage; helper text states how exact thresholds resolve. Predicates outside
@@ -2363,7 +2366,7 @@ definition.
Present bounded syntax, state, phase, outcome, and calculation-stage
vocabularies as connected reference matrices with shared borders and aligned
columns. Generate the 19-event registry and Action Family recipe catalog from
columns. Generate the event registry and Action Family recipe catalog from
code-defined metadata so the reference matches the available editor controls.
Explain that events are runtime contracts and recipes only seed editable source
branches. Pair complex mechanics with a worked `PseudoScript` example. Keep
+8 -1
View File
@@ -26,7 +26,14 @@ Use these event boundaries:
- **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant
received while the Agent already has Extra Health.
received while the Agent already has Extra Health. Use
`extra_health_granted` for the grant action.
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
it when the Agent receives a grant, when the remaining amount changes, and
when it is depleted to zero. Every event requires the boolean
`values.hasExtraHealth`. It may include the non-negative
`values.currentExtraHealth`, which must agree with that status. This state
event is distinct from the `extra_health_granted` grant action.
- **Reloading:** Trigger on reload completion when the weapon had at least one
round to load. A canceled reload does not count. A weapon that loads one
round at a time produces one completed-reload event for each round.
+25 -14
View File
@@ -431,17 +431,28 @@ configuration state, and one of three simulation-readiness states:
- `captured_only`: ISAC preserves the authored mechanic, but the runtime cannot
calculate it.
The code-defined `MECHANIC_EVENT_REGISTRY` is the event authority. Its 19
The code-defined `MECHANIC_EVENT_REGISTRY` is the event authority. Its 28
normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
`skill_hit`, `agent_hit`, `health_damage_taken`, `damage_dealt`,
`enemy_killed`, `assist`, `healing_applied`, `extra_health_granted`,
`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`,
`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event
details such as target, distance, critical state, source, movement, and nearby
counts travel as typed event values. The event registry publishes authoritative
field metadata where branches need a bounded contract. For example,
`weapon_hit.values.headshot` is a boolean that distinguishes headshots from
body shots.
`signature_ability_deployed`, `skill_hit`, `skill_multishot_triggered`,
`agent_hit`, `health_damage_taken`, `damage_dealt`, `enemy_killed`, `assist`,
`ally_downed`, `agent_downed`, `ally_rescued`, `grenade_thrown`,
`medical_kit_used`, `healing_applied`, `extra_health_granted`,
`extra_health_changed`, `movement_snapshot`, `proximity_snapshot`,
`firing_started`, `firing_stopped`, `reload_started`, `reload_completed`,
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
state, source, movement, and nearby counts travel as typed event values. The
event registry publishes authoritative field metadata where branches need a
bounded contract. For those events, the mechanic editor derives its event-field
controls from that metadata. For example, `weapon_hit.values.headshot` is a
boolean that distinguishes headshots from body shots.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount
changes, and when it is depleted to zero. Every event requires the boolean
`values.hasExtraHealth`. An event may include the non-negative
`values.currentExtraHealth`; when present, the amount must agree with the
boolean status. `extra_health_granted` represents a grant action and remains a
separate event from changes to the Agent's current Extra Health state.
The optional Action Family catalog provides authoring recipes for common
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and
@@ -462,10 +473,10 @@ The program graph uses three child tables:
- `conditional_effect_state_pools` defines agent- or target-scoped flags,
counts, stacks, durations, scalar values, marks, and resources. Each pool
owns its bounds, expiry policy, refresh policy, and best-case value.
- `conditional_effect_clauses` listens for one of the 19 events. A clause owns
a typed predicate, reducer, optional cooldown, optional weight expression,
state mutations, and optional recipe provenance. Reducers convert event
streams into counts, distinct or consecutive matches, sliding windows,
- `conditional_effect_clauses` listens for one event from the registry. A
clause owns a typed predicate, reducer, optional cooldown, optional weight
expression, state mutations, and optional recipe provenance. Reducers convert
event streams into counts, distinct or consecutive matches, sliding windows,
durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
the current event target, event actor, or another typed entity selector. Each
+47
View File
@@ -164,6 +164,53 @@ describe('loadout mechanic timeline', () => {
expect(sprinting.every((event) => event.values?.state === 'sprinting')).toBe(true);
});
it('seeds Extra Health state before periodic stack evaluation', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 2;
profile.extraHealthActive = true;
const timeline = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile
);
expect(timeline.filter((event) => event.kind === 'extra_health_changed')).toEqual([{
kind: 'extra_health_changed',
at: 0,
actorId: 'agent',
values: { hasExtraHealth: true }
}]);
const program: MechanicProgram = {
id: 'extra-health-periodic-stacks',
name: 'Extra Health periodic stacks',
pools: [
{ key: 'active', label: 'Active', scope: 'agent', initial: 0, min: 0, max: 1 },
{ key: 'stacks', label: 'Stacks', scope: 'agent', initial: 0, min: 0, max: 10 }
],
clauses: [{
id: 'set-active',
when: 'extra_health_changed',
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }]
}, {
id: 'gain-stack',
when: 'tick',
if: { kind: 'compare', left: { source: 'pool', pool: 'active' }, operator: 'eq', right: 1 },
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }]
}]
};
const result = evaluateMechanicProgram(program, timeline, {
mode: 'custom',
encounterSeconds: 2
});
expect(result.finalState).toEqual(expect.arrayContaining([
expect.objectContaining({ pool: 'stacks', value: 2 })
]));
expect(result.emissions.filter((emission) => emission.clauseId === 'gain-stack')
.map((emission) => emission.at)).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
const persistedRule = {
rule: {
+13 -4
View File
@@ -613,9 +613,10 @@ const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
health_damage_taken: 20,
healing_applied: 21,
extra_health_granted: 22,
tick: 23,
proximity_snapshot: 24,
movement_snapshot: 25
extra_health_changed: 23,
tick: 24,
proximity_snapshot: 25,
movement_snapshot: 26
};
function intervalAppliesAt(
@@ -709,7 +710,15 @@ export function buildWeaponMechanicTimeline(
60 / Number(profile.distinctTargetsPerMinute)
)
: Math.max(0.1, Number(profile.sameTargetSeconds));
const events: MechanicEvent[] = [{ kind: 'firing_started', at: 0 }];
const events: MechanicEvent[] = [
{ kind: 'firing_started', at: 0 },
{
kind: 'extra_health_changed',
at: 0,
actorId: 'agent',
values: { hasExtraHealth: Boolean(profile.extraHealthActive) }
}
];
let time = 0;
let magazineRemaining = magazine;
let burstRemaining = shotsPerBurst;
@@ -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.');
}
@@ -12,6 +12,7 @@
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft,
@@ -171,6 +172,41 @@
: options;
}
function eventPredicateFields(clause: ConditionalMechanicClauseDraft) {
return registeredEventFields(clause.event);
}
function selectedEventPredicateField(clause: ConditionalMechanicClauseDraft) {
const comparison = readEventFieldComparison(clause.if);
return eventPredicateFields(clause).find((field) => field.path === comparison?.path);
}
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
return selectedEventPredicateField(clause)?.type === 'number'
? predicateOperatorOptions
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
}
function setClauseEvent(clauseIndex: number, event: string) {
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
const clause = rule.clauses[clauseIndex];
const fields = registeredEventFields(nextEvent);
const comparison = readEventFieldComparison(clause.if);
if (!comparison || !fields.length) {
updateClause(clauseIndex, { event: nextEvent });
return;
}
const field = fields[0];
updateClause(clauseIndex, {
event: nextEvent,
if: eventFieldComparisonPredicate({
path: field.path,
operator: 'eq',
value: field.type === 'boolean' ? true : field.type === 'number' ? 0 : ''
})
});
}
function setConditionModel(clauseIndex: number, model: string) {
if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } });
if (model === 'current_health_band') {
@@ -179,11 +215,12 @@
});
}
if (model === 'event_field') {
const field = eventPredicateFields(rule.clauses[clauseIndex])[0];
updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({
path: 'values.state',
path: field?.path ?? 'values.state',
operator: 'eq',
value: 'moving'
value: field?.type === 'boolean' ? true : field?.type === 'number' ? 0 : 'moving'
})
});
}
@@ -243,6 +280,18 @@
updateEventFieldComparison(clauseIndex, { value });
}
function setRegisteredEventField(clauseIndex: number, path: string) {
const clause = rule.clauses[clauseIndex];
const field = eventPredicateFields(clause).find((item) => item.path === path);
if (!field) return;
const current = readEventFieldComparison(clause.if);
updateEventFieldComparison(clauseIndex, {
path,
operator: field.type === 'number' ? current?.operator ?? 'eq' : 'eq',
value: field.type === 'boolean' ? true : field.type === 'number' ? 0 : ''
});
}
function updateStatePoolThreshold(
clauseIndex: number,
patch: Partial<StatePoolThresholdComparison>
@@ -839,6 +888,8 @@
{#each rule.clauses as clause, clauseIndex}
{@const conditionBand = readCurrentHealthPercentBand(clause.if)}
{@const eventFieldCondition = readEventFieldComparison(clause.if)}
{@const registeredPredicateField = selectedEventPredicateField(clause)}
{@const registeredPredicateFields = eventPredicateFields(clause)}
{@const statePoolCondition = readStatePoolThreshold(clause.if)}
<section class="branch">
<header>
@@ -846,7 +897,7 @@
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
</header>
<div class="branch-grid">
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" bind:value={clause.event}/></div>
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" value={clause.event} onvaluechange={(value) => setClauseEvent(clauseIndex,value)}/></div>
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
@@ -865,22 +916,34 @@
</div>
{:else if eventFieldCondition}
<div class="predicate-fields event-predicate-fields">
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
{#if registeredPredicateFields.length && registeredPredicateField}
<SegmentedChoice
legend="EVENT FIELD"
name={`${idPrefix}-event-field-${clauseIndex}`}
options={registeredPredicateFields.map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
value={registeredPredicateField.path}
onchange={(values) => setRegisteredEventField(clauseIndex,values[0])}
/>
{:else}
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
{/if}
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-event-operator-${clauseIndex}`}
options={predicateOperatorOptions}
options={eventPredicateOperatorOptions(clause)}
value={eventFieldCondition.operator}
columns={6}
columns={eventPredicateOperatorOptions(clause).length}
onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{#if !registeredPredicateField}
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{/if}
{#if typeof eventFieldCondition.value === 'number'}
<label class="field">COMPARE VALUE<input type="number" step="any" value={eventFieldCondition.value} oninput={(event) => updateEventFieldComparison(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
{:else if typeof eventFieldCondition.value === 'boolean'}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic';
import {
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
AI_MECHANIC_PROMPT_VERSION,
@@ -78,7 +79,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(6);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds'])
);
@@ -115,7 +116,7 @@ describe('AI mechanic authoring prompt', () => {
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 6'
'Prompt contract version: 7'
);
const advertised = structuredClone(
@@ -157,6 +158,39 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.extra_health_changed],
actions: [],
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Prompt contract version: 7');
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
label: 'Extra Health changed',
description: expect.any(String),
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('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 6;
export const AI_MECHANIC_PROMPT_VERSION = 7;
const mechanicsGrammar = {
version: 1,
+23 -1
View File
@@ -149,7 +149,7 @@ describe('conditional mechanic persistence adapter', () => {
)).toThrow(/inconsistent delivery_duration_seconds metadata/);
});
it('round-trips continuous delivery through normalized rows', async () => {
it('round-trips continuous delivery and direct event predicates through normalized rows', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE conditional_effect_rules (
id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER,
@@ -276,6 +276,19 @@ describe('conditional mechanic persistence adapter', () => {
}
}
}]
}, {
key: 'extra-health-state',
label: 'Extra Health state',
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
},
reducer: { kind: 'none' },
mutations: [],
outcomes: []
}]
});
@@ -358,5 +371,14 @@ describe('conditional mechanic persistence adapter', () => {
II: { value: 336 }
}
});
expect(loaded?.clauses[1]).toMatchObject({
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
}
});
});
});