feat: support extra health state mechanics
Validate and deploy production / validate (push) Successful in 21s
Validate and deploy staging / validate (push) Successful in 23s
Validate and deploy production / deploy (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 30s

This commit is contained in:
jb
2026-07-31 23:55:49 -04:00
parent 9ac2fe47cf
commit 1998e87309
16 changed files with 575 additions and 47 deletions
+47
View File
@@ -164,6 +164,53 @@ describe('loadout mechanic timeline', () => {
expect(sprinting.every((event) => event.values?.state === 'sprinting')).toBe(true);
});
it('seeds Extra Health state before periodic stack evaluation', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 2;
profile.extraHealthActive = true;
const timeline = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile
);
expect(timeline.filter((event) => event.kind === 'extra_health_changed')).toEqual([{
kind: 'extra_health_changed',
at: 0,
actorId: 'agent',
values: { hasExtraHealth: true }
}]);
const program: MechanicProgram = {
id: 'extra-health-periodic-stacks',
name: 'Extra Health periodic stacks',
pools: [
{ key: 'active', label: 'Active', scope: 'agent', initial: 0, min: 0, max: 1 },
{ key: 'stacks', label: 'Stacks', scope: 'agent', initial: 0, min: 0, max: 10 }
],
clauses: [{
id: 'set-active',
when: 'extra_health_changed',
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }]
}, {
id: 'gain-stack',
when: 'tick',
if: { kind: 'compare', left: { source: 'pool', pool: 'active' }, operator: 'eq', right: 1 },
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }]
}]
};
const result = evaluateMechanicProgram(program, timeline, {
mode: 'custom',
encounterSeconds: 2
});
expect(result.finalState).toEqual(expect.arrayContaining([
expect.objectContaining({ pool: 'stacks', value: 2 })
]));
expect(result.emissions.filter((emission) => emission.clauseId === 'gain-stack')
.map((emission) => emission.at)).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
const persistedRule = {
rule: {
+13 -4
View File
@@ -613,9 +613,10 @@ const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
health_damage_taken: 20,
healing_applied: 21,
extra_health_granted: 22,
tick: 23,
proximity_snapshot: 24,
movement_snapshot: 25
extra_health_changed: 23,
tick: 24,
proximity_snapshot: 25,
movement_snapshot: 26
};
function intervalAppliesAt(
@@ -709,7 +710,15 @@ export function buildWeaponMechanicTimeline(
60 / Number(profile.distinctTargetsPerMinute)
)
: Math.max(0.1, Number(profile.sameTargetSeconds));
const events: MechanicEvent[] = [{ kind: 'firing_started', at: 0 }];
const events: MechanicEvent[] = [
{ kind: 'firing_started', at: 0 },
{
kind: 'extra_health_changed',
at: 0,
actorId: 'agent',
values: { hasExtraHealth: Boolean(profile.extraHealthActive) }
}
];
let time = 0;
let magazineRemaining = magazine;
let burstRemaining = shotsPerBurst;
@@ -5,6 +5,7 @@ import {
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicDraftV1
@@ -126,6 +127,54 @@ describe('conditional mechanic authoring payload', () => {
})).toBeNull();
});
it('exposes typed Extra Health fields and parses their manual editor state', () => {
expect(registeredEventFields('extra_health_changed')).toEqual([
{ path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true },
{ path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0 }
]);
const value = draft();
value.clauses[0].event = 'extra_health_changed';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = { kind: 'none' };
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.hasExtraHealth',
operator: 'eq',
value: true
});
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
when: 'extra_health_changed',
if: value.clauses[0].if
});
});
it('rejects an unregistered Extra Health event field deterministically', () => {
const value = draft();
value.clauses[0].event = 'extra_health_changed';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = { kind: 'none' };
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.extraHealthActive',
operator: 'eq',
value: true
});
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('is not defined for Extra Health changed')
]));
}
});
it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => {
const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 };
const predicate = statePoolThresholdPredicate(comparison);
+5
View File
@@ -1,5 +1,6 @@
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
@@ -174,6 +175,10 @@ export function readEventFieldComparison(
};
}
export function registeredEventFields(event: MechanicEventKind) {
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
}
export type StatePoolThresholdComparison = {
pool: string;
operator: PredicateComparisonOperator;
@@ -643,4 +643,33 @@ describe('conditional-effect persistence adapter', () => {
expect(result.status).toBe('READY');
expect(result.program?.clauses[0].if).toEqual(condition);
});
it('round-trips the direct Extra Health event and its required-state predicate', () => {
const condition = {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
};
const result = adaptConditionalEffectProgram({
rule: rule(),
statePools: [],
clauses: [clause({
trigger_id: null,
event_kind: 'extra_health_changed',
parameters_json: '{}',
conditions_json: JSON.stringify(condition),
state_pool_id: null,
mutations_json: '[]'
})],
outcomes: [],
triggers: []
}, { name: 'Extra Health State', variantKey: 'I' });
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0]).toMatchObject({
when: 'extra_health_changed',
if: condition
});
});
});
@@ -178,8 +178,9 @@ describe('mechanic recipe registry', () => {
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
'enter-extra-health-active', 'exit-extra-health-active'
]);
expect(extraHealth.clauses.every((clause) => clause.when === 'extra_health_changed')).toBe(true);
expect(extraHealth.clauses[0].if).toEqual({
kind: 'compare', left: { source: 'event', path: 'values.extraHealthActive' }, operator: 'eq', right: true
kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true
});
});
});
+10 -6
View File
@@ -11,6 +11,7 @@ import type {
} from './conditional.js';
import type {
MechanicClause,
MechanicEventKind,
MechanicProgram,
PoolDefinition,
Predicate,
@@ -168,8 +169,9 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
return { pools, clauses };
}
function proximityLifecycleSeed(
function eventLifecycleSeed(
pool: PoolDefinition,
event: MechanicEventKind,
activePredicate: Predicate,
label: string
): MechanicRecipeSeed {
@@ -179,7 +181,7 @@ function proximityLifecycleSeed(
{
id: `enter-${pool.key}`,
label: `Enter ${label}`,
when: 'proximity_snapshot',
when: event,
if: activePredicate,
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
@@ -187,7 +189,7 @@ function proximityLifecycleSeed(
{
id: `exit-${pool.key}`,
label: `Exit ${label}`,
when: 'proximity_snapshot',
when: event,
if: { kind: 'not', predicate: activePredicate },
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: pool.key }]
@@ -312,8 +314,9 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
key: 'within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported(proximityLifecycleSeed(
return supported(eventLifecycleSeed(
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
'proximity_snapshot',
{ kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
`target within ${value.meters}m`
));
@@ -321,9 +324,10 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
},
extra_health_active: {
key: 'extra_health_active',
createSeed: (_params) => supported(proximityLifecycleSeed(
createSeed: (_params) => supported(eventLifecycleSeed(
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
{ kind: 'compare', left: eventValue('extraHealthActive'), operator: 'eq', right: true },
'extra_health_changed',
{ kind: 'compare', left: eventValue('hasExtraHealth'), operator: 'eq', right: true },
'Extra Health active'
))
}
+132
View File
@@ -62,6 +62,138 @@ describe('structured mechanic runtime', () => {
});
});
it('advertises the authoritative Extra Health state contract', () => {
expect(MECHANIC_EVENT_REGISTRY.extra_health_changed).toEqual({
key: 'extra_health_changed',
label: 'Extra Health changed',
description: expect.stringContaining('transition to zero'),
strictFields: true,
fields: [
{
path: 'values.hasExtraHealth',
label: 'Has Extra Health',
type: 'boolean',
required: true
},
{
path: 'values.currentExtraHealth',
label: 'Current Extra Health',
type: 'number',
minimum: 0
}
]
});
});
it('gains one capped stack per second with Extra Health and clears all stacks on depletion', () => {
const skillIntensityId = 'skill-intensity';
const program: MechanicProgram = {
id: 'electrical-protection',
name: 'Electrical Protection',
pools: [
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
{ key: 'stacks', label: 'Electrical Protection stacks', scope: 'agent', initial: 0, min: 0, max: 10, bestCaseValue: 10 }
],
clauses: [
{
id: 'extra-health-present',
when: 'extra_health_changed',
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'extra-health-active', value: 1 }]
},
{
id: 'extra-health-depleted',
when: 'extra_health_changed',
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: false },
reducer: { kind: 'none' },
mutations: [
{ kind: 'clear', pool: 'extra-health-active' },
{ kind: 'clear', pool: 'stacks' }
]
},
{
id: 'gain-stack',
when: 'tick',
if: { kind: 'compare', left: { source: 'pool', pool: 'extra-health-active' }, operator: 'eq', right: 1 },
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
outcomes: [{
id: 'skill-intensity',
label: 'Skill Intensity',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'all_skills',
basis: 'flat',
attributeId: skillIntensityId,
value: 1.8,
statePool: 'stacks',
perStack: true
}]
}
]
};
const ticks = Array.from({ length: 13 }, (_, index): MechanicEvent => ({ kind: 'tick', at: index }));
const capped = evaluateMechanicProgram(program, [
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: 500 } },
...ticks
], custom(12), { currentHealth: 1000, maxHealth: 1000, attributes: { [skillIntensityId]: 0 } });
expect(capped.status).toBe('OK');
expect(capped.finalState).toEqual(expect.arrayContaining([
expect.objectContaining({ pool: 'stacks', value: 10 })
]));
expect(capped.outcomes.at(-1)).toMatchObject({ value: 18, stateValue: 10 });
const depleted = evaluateMechanicProgram(program, [
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: 500 } },
{ kind: 'tick', at: 0 },
{ kind: 'tick', at: 1 },
{ kind: 'extra_health_changed', at: 1.5, values: { hasExtraHealth: true, currentExtraHealth: 100 } },
{ kind: 'tick', at: 2 },
{ kind: 'extra_health_changed', at: 2.5, values: { hasExtraHealth: false, currentExtraHealth: 0 } },
{ kind: 'tick', at: 3 }
], custom(3), { currentHealth: 1000, maxHealth: 1000, attributes: { [skillIntensityId]: 0 } });
expect(depleted.status).toBe('OK');
expect(depleted.finalState).toEqual(expect.arrayContaining([
expect.objectContaining({ pool: 'extra-health-active', value: 0 }),
expect.objectContaining({ pool: 'stacks', value: 0 })
]));
expect(depleted.emissions.filter((emission) => emission.clauseId === 'gain-stack')).toHaveLength(2);
});
it('rejects malformed Extra Health state fields and inconsistent amounts', () => {
const program: MechanicProgram = {
id: 'extra-health-state',
name: 'Extra Health state',
clauses: [{
id: 'state-change',
when: 'extra_health_changed',
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }]
}]
};
for (const event of [
{ kind: 'extra_health_changed', at: 0, values: {} },
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: -1 } },
{ kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: false, currentExtraHealth: 1 } }
] as MechanicEvent[]) {
expect(evaluateMechanicProgram(program, [event], custom(1)).status).toBe('INVALID');
}
const unknownField = structuredClone(program);
unknownField.clauses[0].if = {
kind: 'compare',
left: { source: 'event', path: 'values.extraHealthActive' },
operator: 'eq',
right: true
};
expect(validateMechanicProgram(unknownField)).toMatchObject({
ok: false,
errors: [expect.stringContaining('is not defined for Extra Health changed')]
});
});
it('executes imported talent trigger families as first-class events', () => {
const eventKinds: MechanicEventKind[] = [
'ally_downed',
+112
View File
@@ -17,6 +17,7 @@ export const MECHANIC_EVENT_KINDS = [
'medical_kit_used',
'healing_applied',
'extra_health_granted',
'extra_health_changed',
'movement_snapshot',
'proximity_snapshot',
'firing_started',
@@ -36,7 +37,10 @@ export type MechanicEventDefinition = {
path: string;
label: string;
type: 'boolean' | 'number' | 'string';
required?: boolean;
minimum?: number;
}[];
strictFields?: boolean;
};
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = {
@@ -171,6 +175,16 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
label: 'Extra Health granted',
description: 'The Agent grants Extra Health to a recipient.'
},
extra_health_changed: {
key: 'extra_health_changed',
label: 'Extra Health changed',
description: 'The Agent’s remaining Extra Health changes, including grants, reductions, and the transition to zero.',
strictFields: true,
fields: [
{ path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true },
{ path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0 }
]
},
movement_snapshot: {
key: 'movement_snapshot',
label: 'Movement snapshot',
@@ -1378,6 +1392,97 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`);
}
function validateStrictEventReferences(
value: unknown,
eventKind: MechanicEventKind,
errors: string[],
path: string
) {
const definition = MECHANIC_EVENT_REGISTRY[eventKind];
if (!definition?.strictFields || value === null || typeof value !== 'object') return;
if (
'source' in value
&& value.source === 'event'
&& 'path' in value
&& typeof value.path === 'string'
&& !definition.fields?.some((field) => field.path === value.path)
) {
errors.push(`${path} references event field "${value.path}" that is not defined for ${definition.label}.`);
}
for (const [key, child] of Object.entries(value)) {
validateStrictEventReferences(child, eventKind, errors, `${path} ${key}`);
}
}
function validateStrictEventPredicate(
predicate: Predicate | undefined,
eventKind: MechanicEventKind,
errors: string[],
path: string
) {
const definition = MECHANIC_EVENT_REGISTRY[eventKind];
if (!definition?.strictFields || !predicate) return;
if (predicate.kind === 'not') {
validateStrictEventPredicate(predicate.predicate, eventKind, errors, `${path} NOT`);
return;
}
if (predicate.kind === 'all' || predicate.kind === 'any') {
predicate.predicates.forEach((item, index) =>
validateStrictEventPredicate(item, eventKind, errors, `${path} ${index + 1}`));
return;
}
if (predicate.kind !== 'compare') return;
const eventReference = typeof predicate.left === 'object' && predicate.left?.source === 'event'
? predicate.left
: typeof predicate.right === 'object' && predicate.right?.source === 'event'
? predicate.right
: null;
const scalar = eventReference === predicate.left ? predicate.right : predicate.left;
if (!eventReference || typeof scalar === 'object') return;
const field = definition.fields?.find((item) => item.path === eventReference.path);
if (!field || scalar === null) return;
if (typeof scalar !== field.type) {
errors.push(`${path} must compare ${field.label} with a ${field.type} value.`);
}
if (field.type !== 'number' && !['eq', 'ne'].includes(predicate.operator)) {
errors.push(`${path} must use equality comparison for ${field.label}.`);
}
}
function validateMechanicEvents(events: MechanicEvent[]) {
const errors: string[] = [];
for (const [index, event] of events.entries()) {
const definition = MECHANIC_EVENT_REGISTRY[event.kind];
if (!definition?.strictFields) continue;
for (const field of definition.fields ?? []) {
const value = getPath(event, field.path);
const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`;
if (value === undefined || value === null) {
if (field.required) errors.push(`${fieldPath} is required.`);
continue;
}
if (typeof value !== field.type || (field.type === 'number' && !finiteNumber(value))) {
errors.push(`${fieldPath} must be a ${field.type}.`);
continue;
}
if (field.type === 'number' && field.minimum !== undefined && (value as number) < field.minimum) {
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
}
}
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
if (
event.kind === 'extra_health_changed'
&& typeof hasExtraHealth === 'boolean'
&& finiteNumber(currentExtraHealth)
&& hasExtraHealth !== (currentExtraHealth > 0)
) {
errors.push(`Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`);
}
}
return errors;
}
function visitExpression(
expression: NumericExpression,
pools: Set<string>,
@@ -1600,7 +1705,10 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
errors.push(`${path} has an unknown event kind.`);
}
if (clause.if) visitPredicate(clause.if, poolKeys, inputKeys, errors, `${path} predicate`);
validateStrictEventReferences(clause.if, clause.when, errors, `${path} predicate`);
validateStrictEventPredicate(clause.if, clause.when, errors, `${path} predicate`);
if (clause.weight) visitExpression(clause.weight, poolKeys, inputKeys, errors, `${path} weight`);
validateStrictEventReferences(clause.weight, clause.when, errors, `${path} weight`);
if (
clause.cooldownSeconds !== undefined
&& (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0)
@@ -1658,6 +1766,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
if (reducer.kind === 'derived_scalar') {
visitExpression(reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`);
}
validateStrictEventReferences(reducer, clause.when, errors, `${path} reducer`);
for (const [mutationIndex, mutation] of clause.mutations.entries()) {
if (mutation.kind === 'none') continue;
const mutationPath = `${path} mutation ${mutationIndex + 1}`;
@@ -1672,6 +1781,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
}
}
}
validateStrictEventReferences(clause.mutations, clause.when, errors, `${path} mutations`);
for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`);
@@ -1776,6 +1886,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
}
visitExpression(outcome.value, poolKeys, inputKeys, errors, `${outcomePath} value`);
}
validateStrictEventReferences(clause.outcomes, clause.when, errors, `${path} outcomes`);
}
return errors.length ? { ok: false, errors } : { ok: true, value: program };
}
@@ -1789,6 +1900,7 @@ export function evaluateMechanicProgram(
): MechanicEvaluation {
const validation = validateMechanicProgram(program);
const scenarioErrors: string[] = [];
scenarioErrors.push(...validateMechanicEvents(events));
if (!finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0) {
scenarioErrors.push('Encounter seconds must be greater than 0.');
}
@@ -12,6 +12,7 @@
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft,
@@ -171,6 +172,41 @@
: options;
}
function eventPredicateFields(clause: ConditionalMechanicClauseDraft) {
return registeredEventFields(clause.event);
}
function selectedEventPredicateField(clause: ConditionalMechanicClauseDraft) {
const comparison = readEventFieldComparison(clause.if);
return eventPredicateFields(clause).find((field) => field.path === comparison?.path);
}
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
return selectedEventPredicateField(clause)?.type === 'number'
? predicateOperatorOptions
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
}
function setClauseEvent(clauseIndex: number, event: string) {
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
const clause = rule.clauses[clauseIndex];
const fields = registeredEventFields(nextEvent);
const comparison = readEventFieldComparison(clause.if);
if (!comparison || !fields.length) {
updateClause(clauseIndex, { event: nextEvent });
return;
}
const field = fields[0];
updateClause(clauseIndex, {
event: nextEvent,
if: eventFieldComparisonPredicate({
path: field.path,
operator: 'eq',
value: field.type === 'boolean' ? true : field.type === 'number' ? 0 : ''
})
});
}
function setConditionModel(clauseIndex: number, model: string) {
if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } });
if (model === 'current_health_band') {
@@ -179,11 +215,12 @@
});
}
if (model === 'event_field') {
const field = eventPredicateFields(rule.clauses[clauseIndex])[0];
updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({
path: 'values.state',
path: field?.path ?? 'values.state',
operator: 'eq',
value: 'moving'
value: field?.type === 'boolean' ? true : field?.type === 'number' ? 0 : 'moving'
})
});
}
@@ -243,6 +280,18 @@
updateEventFieldComparison(clauseIndex, { value });
}
function setRegisteredEventField(clauseIndex: number, path: string) {
const clause = rule.clauses[clauseIndex];
const field = eventPredicateFields(clause).find((item) => item.path === path);
if (!field) return;
const current = readEventFieldComparison(clause.if);
updateEventFieldComparison(clauseIndex, {
path,
operator: field.type === 'number' ? current?.operator ?? 'eq' : 'eq',
value: field.type === 'boolean' ? true : field.type === 'number' ? 0 : ''
});
}
function updateStatePoolThreshold(
clauseIndex: number,
patch: Partial<StatePoolThresholdComparison>
@@ -839,6 +888,8 @@
{#each rule.clauses as clause, clauseIndex}
{@const conditionBand = readCurrentHealthPercentBand(clause.if)}
{@const eventFieldCondition = readEventFieldComparison(clause.if)}
{@const registeredPredicateField = selectedEventPredicateField(clause)}
{@const registeredPredicateFields = eventPredicateFields(clause)}
{@const statePoolCondition = readStatePoolThreshold(clause.if)}
<section class="branch">
<header>
@@ -846,7 +897,7 @@
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
</header>
<div class="branch-grid">
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" bind:value={clause.event}/></div>
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" value={clause.event} onvaluechange={(value) => setClauseEvent(clauseIndex,value)}/></div>
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
@@ -865,22 +916,34 @@
</div>
{:else if eventFieldCondition}
<div class="predicate-fields event-predicate-fields">
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
{#if registeredPredicateFields.length && registeredPredicateField}
<SegmentedChoice
legend="EVENT FIELD"
name={`${idPrefix}-event-field-${clauseIndex}`}
options={registeredPredicateFields.map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
value={registeredPredicateField.path}
onchange={(values) => setRegisteredEventField(clauseIndex,values[0])}
/>
{:else}
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
{/if}
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-event-operator-${clauseIndex}`}
options={predicateOperatorOptions}
options={eventPredicateOperatorOptions(clause)}
value={eventFieldCondition.operator}
columns={6}
columns={eventPredicateOperatorOptions(clause).length}
onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{#if !registeredPredicateField}
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{/if}
{#if typeof eventFieldCondition.value === 'number'}
<label class="field">COMPARE VALUE<input type="number" step="any" value={eventFieldCondition.value} oninput={(event) => updateEventFieldComparison(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
{:else if typeof eventFieldCondition.value === 'boolean'}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic';
import {
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
AI_MECHANIC_PROMPT_VERSION,
@@ -78,7 +79,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(6);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds'])
);
@@ -115,7 +116,7 @@ describe('AI mechanic authoring prompt', () => {
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 6'
'Prompt contract version: 7'
);
const advertised = structuredClone(
@@ -157,6 +158,39 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.extra_health_changed],
actions: [],
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Prompt contract version: 7');
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
label: 'Extra Health changed',
description: expect.any(String),
strictFields: true,
fields: [
{
path: 'values.hasExtraHealth',
label: 'Has Extra Health',
type: 'boolean',
required: true
},
{
path: 'values.currentExtraHealth',
label: 'Current Extra Health',
type: 'number',
minimum: 0
}
]
});
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 6;
export const AI_MECHANIC_PROMPT_VERSION = 7;
const mechanicsGrammar = {
version: 1,
+23 -1
View File
@@ -149,7 +149,7 @@ describe('conditional mechanic persistence adapter', () => {
)).toThrow(/inconsistent delivery_duration_seconds metadata/);
});
it('round-trips continuous delivery through normalized rows', async () => {
it('round-trips continuous delivery and direct event predicates through normalized rows', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE conditional_effect_rules (
id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER,
@@ -276,6 +276,19 @@ describe('conditional mechanic persistence adapter', () => {
}
}
}]
}, {
key: 'extra-health-state',
label: 'Extra Health state',
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
},
reducer: { kind: 'none' },
mutations: [],
outcomes: []
}]
});
@@ -358,5 +371,14 @@ describe('conditional mechanic persistence adapter', () => {
II: { value: 336 }
}
});
expect(loaded?.clauses[1]).toMatchObject({
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
}
});
});
});