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 in text. Its native disclosure semantics preserve keyboard and assistive
technology support; mobile targets remain at least 44 pixels tall. 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 event registry. Use searchable single-assignment because the vocabulary exceeds
the compact segmented-choice limit. Each branch presents its event, match the compact segmented-choice limit. Each branch presents its event, match
policy and parameters, optional cooldown, condition, state mutations, and policy and parameters, optional cooldown, condition, state mutations, and
outcomes. The branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`, outcomes. The branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`,
and `STATE THRESHOLD` for manual authoring. Event-field conditions expose the and `STATE THRESHOLD` for manual authoring. When the selected event publishes
field path, comparison operator, scalar type, and typed value. State thresholds 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 compare an agent-scoped pool with a numeric value. Health bands expose an
optional inclusive lower Health percentage and a required exclusive upper optional inclusive lower Health percentage and a required exclusive upper
percentage; helper text states how exact thresholds resolve. Predicates outside percentage; helper text states how exact thresholds resolve. Predicates outside
@@ -2363,7 +2366,7 @@ definition.
Present bounded syntax, state, phase, outcome, and calculation-stage Present bounded syntax, state, phase, outcome, and calculation-stage
vocabularies as connected reference matrices with shared borders and aligned 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. code-defined metadata so the reference matches the available editor controls.
Explain that events are runtime contracts and recipes only seed editable source Explain that events are runtime contracts and recipes only seed editable source
branches. Pair complex mechanics with a worked `PseudoScript` example. Keep 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 - **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event. tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant - **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 - **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 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. 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 - `captured_only`: ISAC preserves the authored mechanic, but the runtime cannot
calculate it. 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`, normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
`skill_hit`, `agent_hit`, `health_damage_taken`, `damage_dealt`, `signature_ability_deployed`, `skill_hit`, `skill_multishot_triggered`,
`enemy_killed`, `assist`, `healing_applied`, `extra_health_granted`, `agent_hit`, `health_damage_taken`, `damage_dealt`, `enemy_killed`, `assist`,
`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`, `ally_downed`, `agent_downed`, `ally_rescued`, `grenade_thrown`,
`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event `medical_kit_used`, `healing_applied`, `extra_health_granted`,
details such as target, distance, critical state, source, movement, and nearby `extra_health_changed`, `movement_snapshot`, `proximity_snapshot`,
counts travel as typed event values. The event registry publishes authoritative `firing_started`, `firing_stopped`, `reload_started`, `reload_completed`,
field metadata where branches need a bounded contract. For example, `weapon_swapped`, and `tick`. Event details such as target, distance, critical
`weapon_hit.values.headshot` is a boolean that distinguishes headshots from state, source, movement, and nearby counts travel as typed event values. The
body shots. 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 The optional Action Family catalog provides authoring recipes for common
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and 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, - `conditional_effect_state_pools` defines agent- or target-scoped flags,
counts, stacks, durations, scalar values, marks, and resources. Each pool counts, stacks, durations, scalar values, marks, and resources. Each pool
owns its bounds, expiry policy, refresh policy, and best-case value. owns its bounds, expiry policy, refresh policy, and best-case value.
- `conditional_effect_clauses` listens for one of the 19 events. A clause owns - `conditional_effect_clauses` listens for one event from the registry. A
a typed predicate, reducer, optional cooldown, optional weight expression, clause owns a typed predicate, reducer, optional cooldown, optional weight
state mutations, and optional recipe provenance. Reducers convert event expression, state mutations, and optional recipe provenance. Reducers convert
streams into counts, distinct or consecutive matches, sliding windows, event streams into counts, distinct or consecutive matches, sliding windows,
durations, intervals, or derived values. Mutations can set, add, consume, durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state. A mutation may bind a target-scoped pool to 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 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); 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', () => { it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
const persistedRule = { const persistedRule = {
rule: { rule: {
+13 -4
View File
@@ -613,9 +613,10 @@ const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
health_damage_taken: 20, health_damage_taken: 20,
healing_applied: 21, healing_applied: 21,
extra_health_granted: 22, extra_health_granted: 22,
tick: 23, extra_health_changed: 23,
proximity_snapshot: 24, tick: 24,
movement_snapshot: 25 proximity_snapshot: 25,
movement_snapshot: 26
}; };
function intervalAppliesAt( function intervalAppliesAt(
@@ -709,7 +710,15 @@ export function buildWeaponMechanicTimeline(
60 / Number(profile.distinctTargetsPerMinute) 60 / Number(profile.distinctTargetsPerMinute)
) )
: Math.max(0.1, Number(profile.sameTargetSeconds)); : 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 time = 0;
let magazineRemaining = magazine; let magazineRemaining = magazine;
let burstRemaining = shotsPerBurst; let burstRemaining = shotsPerBurst;
@@ -5,6 +5,7 @@ import {
parseConditionalMechanicDraft, parseConditionalMechanicDraft,
readCurrentHealthPercentBand, readCurrentHealthPercentBand,
readEventFieldComparison, readEventFieldComparison,
registeredEventFields,
readStatePoolThreshold, readStatePoolThreshold,
statePoolThresholdPredicate, statePoolThresholdPredicate,
type ConditionalMechanicDraftV1 type ConditionalMechanicDraftV1
@@ -126,6 +127,54 @@ describe('conditional mechanic authoring payload', () => {
})).toBeNull(); })).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', () => { it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => {
const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 }; const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 };
const predicate = statePoolThresholdPredicate(comparison); const predicate = statePoolThresholdPredicate(comparison);
+5
View File
@@ -1,5 +1,6 @@
import { import {
MECHANIC_EVENT_KINDS, MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
validateMechanicProgram, validateMechanicProgram,
type ClauseMutation, type ClauseMutation,
type MechanicClause, type MechanicClause,
@@ -174,6 +175,10 @@ export function readEventFieldComparison(
}; };
} }
export function registeredEventFields(event: MechanicEventKind) {
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
}
export type StatePoolThresholdComparison = { export type StatePoolThresholdComparison = {
pool: string; pool: string;
operator: PredicateComparisonOperator; operator: PredicateComparisonOperator;
@@ -643,4 +643,33 @@ describe('conditional-effect persistence adapter', () => {
expect(result.status).toBe('READY'); expect(result.status).toBe('READY');
expect(result.program?.clauses[0].if).toEqual(condition); 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([ expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
'enter-extra-health-active', 'exit-extra-health-active' '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({ 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'; } from './conditional.js';
import type { import type {
MechanicClause, MechanicClause,
MechanicEventKind,
MechanicProgram, MechanicProgram,
PoolDefinition, PoolDefinition,
Predicate, Predicate,
@@ -168,8 +169,9 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
return { pools, clauses }; return { pools, clauses };
} }
function proximityLifecycleSeed( function eventLifecycleSeed(
pool: PoolDefinition, pool: PoolDefinition,
event: MechanicEventKind,
activePredicate: Predicate, activePredicate: Predicate,
label: string label: string
): MechanicRecipeSeed { ): MechanicRecipeSeed {
@@ -179,7 +181,7 @@ function proximityLifecycleSeed(
{ {
id: `enter-${pool.key}`, id: `enter-${pool.key}`,
label: `Enter ${label}`, label: `Enter ${label}`,
when: 'proximity_snapshot', when: event,
if: activePredicate, if: activePredicate,
reducer: { kind: 'none' }, reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: pool.key, value: 1 }] mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
@@ -187,7 +189,7 @@ function proximityLifecycleSeed(
{ {
id: `exit-${pool.key}`, id: `exit-${pool.key}`,
label: `Exit ${label}`, label: `Exit ${label}`,
when: 'proximity_snapshot', when: event,
if: { kind: 'not', predicate: activePredicate }, if: { kind: 'not', predicate: activePredicate },
reducer: { kind: 'none' }, reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: pool.key }] mutations: [{ kind: 'clear', pool: pool.key }]
@@ -312,8 +314,9 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
key: 'within_distance', key: 'within_distance',
createSeed: (params) => { createSeed: (params) => {
const value = params as DistanceParams; 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 }, { 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 }, { kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
`target within ${value.meters}m` `target within ${value.meters}m`
)); ));
@@ -321,9 +324,10 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
}, },
extra_health_active: { extra_health_active: {
key: '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 }, { 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' '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', () => { it('executes imported talent trigger families as first-class events', () => {
const eventKinds: MechanicEventKind[] = [ const eventKinds: MechanicEventKind[] = [
'ally_downed', 'ally_downed',
+112
View File
@@ -17,6 +17,7 @@ export const MECHANIC_EVENT_KINDS = [
'medical_kit_used', 'medical_kit_used',
'healing_applied', 'healing_applied',
'extra_health_granted', 'extra_health_granted',
'extra_health_changed',
'movement_snapshot', 'movement_snapshot',
'proximity_snapshot', 'proximity_snapshot',
'firing_started', 'firing_started',
@@ -36,7 +37,10 @@ export type MechanicEventDefinition = {
path: string; path: string;
label: string; label: string;
type: 'boolean' | 'number' | 'string'; type: 'boolean' | 'number' | 'string';
required?: boolean;
minimum?: number;
}[]; }[];
strictFields?: boolean;
}; };
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = { 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', label: 'Extra Health granted',
description: 'The Agent grants Extra Health to a recipient.' 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: { movement_snapshot: {
key: 'movement_snapshot', key: 'movement_snapshot',
label: '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.`); 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( function visitExpression(
expression: NumericExpression, expression: NumericExpression,
pools: Set<string>, pools: Set<string>,
@@ -1600,7 +1705,10 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
errors.push(`${path} has an unknown event kind.`); errors.push(`${path} has an unknown event kind.`);
} }
if (clause.if) visitPredicate(clause.if, poolKeys, inputKeys, errors, `${path} predicate`); 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`); if (clause.weight) visitExpression(clause.weight, poolKeys, inputKeys, errors, `${path} weight`);
validateStrictEventReferences(clause.weight, clause.when, errors, `${path} weight`);
if ( if (
clause.cooldownSeconds !== undefined clause.cooldownSeconds !== undefined
&& (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0) && (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0)
@@ -1658,6 +1766,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
if (reducer.kind === 'derived_scalar') { if (reducer.kind === 'derived_scalar') {
visitExpression(reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`); visitExpression(reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`);
} }
validateStrictEventReferences(reducer, clause.when, errors, `${path} reducer`);
for (const [mutationIndex, mutation] of clause.mutations.entries()) { for (const [mutationIndex, mutation] of clause.mutations.entries()) {
if (mutation.kind === 'none') continue; if (mutation.kind === 'none') continue;
const mutationPath = `${path} mutation ${mutationIndex + 1}`; 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()) { for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
const outcomePath = `${path} outcome ${outcomeIndex + 1}`; const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); 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`); 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 }; return errors.length ? { ok: false, errors } : { ok: true, value: program };
} }
@@ -1789,6 +1900,7 @@ export function evaluateMechanicProgram(
): MechanicEvaluation { ): MechanicEvaluation {
const validation = validateMechanicProgram(program); const validation = validateMechanicProgram(program);
const scenarioErrors: string[] = []; const scenarioErrors: string[] = [];
scenarioErrors.push(...validateMechanicEvents(events));
if (!finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0) { if (!finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0) {
scenarioErrors.push('Encounter seconds must be greater than 0.'); scenarioErrors.push('Encounter seconds must be greater than 0.');
} }
@@ -12,6 +12,7 @@
parseConditionalMechanicDraft, parseConditionalMechanicDraft,
readCurrentHealthPercentBand, readCurrentHealthPercentBand,
readEventFieldComparison, readEventFieldComparison,
registeredEventFields,
readStatePoolThreshold, readStatePoolThreshold,
statePoolThresholdPredicate, statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft, type ConditionalMechanicClauseDraft,
@@ -171,6 +172,41 @@
: options; : 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) { function setConditionModel(clauseIndex: number, model: string) {
if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } }); if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } });
if (model === 'current_health_band') { if (model === 'current_health_band') {
@@ -179,11 +215,12 @@
}); });
} }
if (model === 'event_field') { if (model === 'event_field') {
const field = eventPredicateFields(rule.clauses[clauseIndex])[0];
updateClause(clauseIndex, { updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({ if: eventFieldComparisonPredicate({
path: 'values.state', path: field?.path ?? 'values.state',
operator: 'eq', operator: 'eq',
value: 'moving' value: field?.type === 'boolean' ? true : field?.type === 'number' ? 0 : 'moving'
}) })
}); });
} }
@@ -243,6 +280,18 @@
updateEventFieldComparison(clauseIndex, { value }); 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( function updateStatePoolThreshold(
clauseIndex: number, clauseIndex: number,
patch: Partial<StatePoolThresholdComparison> patch: Partial<StatePoolThresholdComparison>
@@ -839,6 +888,8 @@
{#each rule.clauses as clause, clauseIndex} {#each rule.clauses as clause, clauseIndex}
{@const conditionBand = readCurrentHealthPercentBand(clause.if)} {@const conditionBand = readCurrentHealthPercentBand(clause.if)}
{@const eventFieldCondition = readEventFieldComparison(clause.if)} {@const eventFieldCondition = readEventFieldComparison(clause.if)}
{@const registeredPredicateField = selectedEventPredicateField(clause)}
{@const registeredPredicateFields = eventPredicateFields(clause)}
{@const statePoolCondition = readStatePoolThreshold(clause.if)} {@const statePoolCondition = readStatePoolThreshold(clause.if)}
<section class="branch"> <section class="branch">
<header> <header>
@@ -846,7 +897,7 @@
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button> <button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
</header> </header>
<div class="branch-grid"> <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> <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> <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> </div>
@@ -865,15 +916,26 @@
</div> </div>
{:else if eventFieldCondition} {:else if eventFieldCondition}
<div class="predicate-fields event-predicate-fields"> <div class="predicate-fields event-predicate-fields">
{#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> <label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
{/if}
<SegmentedChoice <SegmentedChoice
legend="COMPARISON" legend="COMPARISON"
name={`${idPrefix}-event-operator-${clauseIndex}`} name={`${idPrefix}-event-operator-${clauseIndex}`}
options={predicateOperatorOptions} options={eventPredicateOperatorOptions(clause)}
value={eventFieldCondition.operator} value={eventFieldCondition.operator}
columns={6} columns={eventPredicateOperatorOptions(clause).length}
onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})} onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/> />
{#if !registeredPredicateField}
<SegmentedChoice <SegmentedChoice
legend="VALUE TYPE" legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`} name={`${idPrefix}-event-value-type-${clauseIndex}`}
@@ -881,6 +943,7 @@
value={predicateScalarType(eventFieldCondition.value)} value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])} onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/> />
{/if}
{#if typeof eventFieldCondition.value === 'number'} {#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> <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'} {:else if typeof eventFieldCondition.value === 'boolean'}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor'; import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic';
import { import {
AI_MECHANIC_AUTHORING_INSTRUCTIONS, AI_MECHANIC_AUTHORING_INSTRUCTIONS,
AI_MECHANIC_PROMPT_VERSION, AI_MECHANIC_PROMPT_VERSION,
@@ -78,7 +79,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: [] recipeKeys: []
})); }));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(6); expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual( expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']) 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.stringContaining('Do not replace continuous delivery with a periodic reducer')
])); ]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain( expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 6' 'Prompt contract version: 7'
); );
const advertised = structuredClone( const advertised = structuredClone(
@@ -157,6 +158,39 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true); 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', () => { it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({ const input = JSON.parse(buildAiMechanicAuthoringInput({
request, request,
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic'; import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context'; import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 6; export const AI_MECHANIC_PROMPT_VERSION = 7;
const mechanicsGrammar = { const mechanicsGrammar = {
version: 1, version: 1,
+23 -1
View File
@@ -149,7 +149,7 @@ describe('conditional mechanic persistence adapter', () => {
)).toThrow(/inconsistent delivery_duration_seconds metadata/); )).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:'); const database = await connect(':memory:');
await database.exec(`CREATE TABLE conditional_effect_rules ( await database.exec(`CREATE TABLE conditional_effect_rules (
id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER, 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 } 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
}
});
}); });
}); });