feat: support extra health state mechanics
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user