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