186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
TRIGGER_ACTION_KEYS,
|
|
validateTriggerConfiguration,
|
|
type TriggerActionKey,
|
|
type TriggerConfiguration
|
|
} from './conditional.js';
|
|
import {
|
|
createMechanicRecipeForTrigger,
|
|
MECHANIC_RECIPE_REGISTRY,
|
|
type MechanicRecipeSeed
|
|
} from './mechanic-recipes.js';
|
|
import { validateMechanicProgram } from './mechanic.js';
|
|
|
|
const paramsByAction: Record<TriggerActionKey, unknown> = {
|
|
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
|
|
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
|
|
consecutive_same_target_hits: { hitsRequired: 4 },
|
|
continuous_firing_duration: { seconds: 2.5 },
|
|
while_firing: {},
|
|
health_threshold_bands: {
|
|
bands: [
|
|
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
|
{ thresholdPercent: 50, outcomeKey: 'damaged' }
|
|
]
|
|
},
|
|
headshot: {},
|
|
body_shot: {},
|
|
headshot_kill: {},
|
|
weapon_kill: {},
|
|
target_hit_within_distance: { meters: 15 },
|
|
damage_taken: {},
|
|
weapon_swap: {},
|
|
reload: {},
|
|
within_distance: { meters: 10 },
|
|
extra_health_active: {}
|
|
};
|
|
|
|
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
|
|
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
|
|
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
|
|
return trigger.value;
|
|
}
|
|
|
|
function seedFor(action: TriggerActionKey): MechanicRecipeSeed {
|
|
const trigger = triggerFor(action);
|
|
const result = createMechanicRecipeForTrigger(trigger);
|
|
if (result.status !== 'supported') throw new Error(result.reason);
|
|
return result.seed;
|
|
}
|
|
|
|
describe('mechanic recipe registry', () => {
|
|
it('covers every trigger action with a supported seed or an explicit reason', () => {
|
|
expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual([...TRIGGER_ACTION_KEYS].sort());
|
|
for (const action of TRIGGER_ACTION_KEYS) {
|
|
const result = createMechanicRecipeForTrigger(triggerFor(action));
|
|
expect(['supported', 'unsupported']).toContain(result.status);
|
|
if (result.status === 'unsupported') expect(result.reason.trim()).not.toBe('');
|
|
else expect(result.seed.clauses.every((clause) => clause.recipeKey === action)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('produces a valid editable program for every supported recipe without hidden placeholders', () => {
|
|
for (const action of TRIGGER_ACTION_KEYS) {
|
|
const seed = seedFor(action);
|
|
const validation = validateMechanicProgram({
|
|
id: `recipe-${action}`,
|
|
name: `Recipe ${action}`,
|
|
...seed
|
|
});
|
|
expect(validation, action).toMatchObject({ ok: true });
|
|
}
|
|
});
|
|
|
|
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
|
|
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
|
|
when: 'weapon_hit',
|
|
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
|
reducer: {
|
|
kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero',
|
|
groupBy: { source: 'event', path: 'targetId' }
|
|
}
|
|
});
|
|
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
|
|
when: 'weapon_hit',
|
|
reducer: {
|
|
kind: 'distinct_count', threshold: 3, windowSeconds: 4,
|
|
distinctBy: { source: 'event', path: 'targetId' }
|
|
}
|
|
});
|
|
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
|
|
when: 'weapon_hit',
|
|
reducer: {
|
|
kind: 'consecutive_count', threshold: 4,
|
|
groupBy: { source: 'event', path: 'targetId' }
|
|
}
|
|
});
|
|
});
|
|
|
|
it('maps head and body shots to authoritative weapon-hit predicates', () => {
|
|
for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) {
|
|
expect(seedFor(action).clauses[0]).toMatchObject({
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'all',
|
|
predicates: [
|
|
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
|
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
|
|
{
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.headshot' },
|
|
operator: 'eq',
|
|
right: headshot
|
|
}
|
|
]
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
it('models firing triggers as explicit lifecycle branches', () => {
|
|
const continuous = seedFor('continuous_firing_duration');
|
|
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
|
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
|
'firing_started', 'firing_stopped', 'reload_started', 'tick'
|
|
]);
|
|
expect(continuous.clauses.at(-1)).toMatchObject({
|
|
reducer: { kind: 'duration', seconds: 2.5 },
|
|
if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 }
|
|
});
|
|
expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([
|
|
'firing_started', 'firing_stopped', 'reload_started'
|
|
]);
|
|
});
|
|
|
|
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
|
|
const health = seedFor('health_threshold_bands');
|
|
expect(health.pools?.map((pool) => pool.key)).toEqual(['health-band-1', 'health-band-2']);
|
|
expect(health.clauses.map((clause) => clause.id)).toEqual([
|
|
'select-health-band-1', 'select-health-band-2', 'clear-health-bands'
|
|
]);
|
|
expect(health.clauses[1].if).toMatchObject({
|
|
kind: 'all',
|
|
predicates: expect.arrayContaining([
|
|
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
|
|
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 }
|
|
])
|
|
});
|
|
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
|
|
expect(health.clauses[2].if).toEqual({
|
|
kind: 'compare',
|
|
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
|
operator: 'gte',
|
|
right: 50
|
|
});
|
|
|
|
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'all',
|
|
predicates: expect.arrayContaining([
|
|
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
|
{ kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 }
|
|
])
|
|
}
|
|
});
|
|
|
|
const withinDistance = seedFor('within_distance');
|
|
expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([
|
|
'enter-target-within-distance', 'exit-target-within-distance'
|
|
]);
|
|
expect(withinDistance.clauses[0].if).toEqual({
|
|
kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 10
|
|
});
|
|
expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' });
|
|
|
|
const extraHealth = seedFor('extra_health_active');
|
|
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
|
|
'enter-extra-health-active', 'exit-extra-health-active'
|
|
]);
|
|
expect(extraHealth.clauses[0].if).toEqual({
|
|
kind: 'compare', left: { source: 'event', path: 'values.extraHealthActive' }, operator: 'eq', right: true
|
|
});
|
|
});
|
|
});
|