Files
shd.build/src/lib/calculations/mechanic-recipes.test.ts
T

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
});
});
});