diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index cf10163..5b7e9ef 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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 diff --git a/docs/TALENT_MECHANIC_RULES.md b/docs/TALENT_MECHANIC_RULES.md index 3b0a92e..6e27ebf 100644 --- a/docs/TALENT_MECHANIC_RULES.md +++ b/docs/TALENT_MECHANIC_RULES.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 6989c19..ead085a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/src/lib/builder/loadout-combat.test.ts b/src/lib/builder/loadout-combat.test.ts index ccb6793..cfab985 100644 --- a/src/lib/builder/loadout-combat.test.ts +++ b/src/lib/builder/loadout-combat.test.ts @@ -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: { diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index e772e99..68244f6 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -613,9 +613,10 @@ const TIMELINE_EVENT_ORDER: Readonly> = { 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; diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index 9a21d77..e2b12f5 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -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); diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts index f02edf6..776475a 100644 --- a/src/lib/calculations/mechanic-editor.ts +++ b/src/lib/calculations/mechanic-editor.ts @@ -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; diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts index 4eace95..68cee22 100644 --- a/src/lib/calculations/mechanic-persistence.test.ts +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -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 + }); + }); }); diff --git a/src/lib/calculations/mechanic-recipes.test.ts b/src/lib/calculations/mechanic-recipes.test.ts index 5fb947c..24d7a01 100644 --- a/src/lib/calculations/mechanic-recipes.test.ts +++ b/src/lib/calculations/mechanic-recipes.test.ts @@ -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 }); }); }); diff --git a/src/lib/calculations/mechanic-recipes.ts b/src/lib/calculations/mechanic-recipes.ts index ca7be4a..d1735c4 100644 --- a/src/lib/calculations/mechanic-recipes.ts +++ b/src/lib/calculations/mechanic-recipes.ts @@ -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 = { 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 = { }, 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' )) } diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 802e49b..98bb229 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -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', diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index 7a6589a..f620f56 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -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> = { @@ -171,6 +175,16 @@ export const MECHANIC_EVENT_REGISTRY: Readonly, 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, @@ -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.'); } diff --git a/src/lib/components/isac/MechanicProgramEditor.svelte b/src/lib/components/isac/MechanicProgramEditor.svelte index d423fab..1fe067b 100644 --- a/src/lib/components/isac/MechanicProgramEditor.svelte +++ b/src/lib/components/isac/MechanicProgramEditor.svelte @@ -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 @@ -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)}
@@ -846,7 +897,7 @@
-
DIRECT EVENT
+
DIRECT EVENT setClauseEvent(clauseIndex,value)}/>
MATCH POLICY{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}
@@ -865,22 +916,34 @@ {:else if eventFieldCondition}
- + {#if registeredPredicateFields.length && registeredPredicateField} + ({value:field.path,label:field.label.toUpperCase()}))} + value={registeredPredicateField.path} + onchange={(values) => setRegisteredEventField(clauseIndex,values[0])} + /> + {:else} + + {/if} updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})} /> - setEventFieldScalarType(clauseIndex,values[0])} - /> + {#if !registeredPredicateField} + setEventFieldScalarType(clauseIndex,values[0])} + /> + {/if} {#if typeof eventFieldCondition.value === 'number'} {:else if typeof eventFieldCondition.value === 'boolean'} diff --git a/src/lib/server/ai/mechanic-authoring-prompt.test.ts b/src/lib/server/ai/mechanic-authoring-prompt.test.ts index b529fbc..a8b31d1 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.test.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.test.ts @@ -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, diff --git a/src/lib/server/ai/mechanic-authoring-prompt.ts b/src/lib/server/ai/mechanic-authoring-prompt.ts index b0e99ca..c681071 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.ts @@ -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, diff --git a/src/lib/server/conditional-effects.test.ts b/src/lib/server/conditional-effects.test.ts index 2fbe44e..9dd2b0a 100644 --- a/src/lib/server/conditional-effects.test.ts +++ b/src/lib/server/conditional-effects.test.ts @@ -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 + } + }); }); });