import { describe, expect, it } from 'vitest'; import { TRIGGER_ACTION_REGISTRY, evaluateConditionalProgram, summarizeTrigger, validateConditionalProgram, validateTriggerConfiguration, type ConditionalEvent, type ConditionalProgram, type TriggerConfiguration, } from './conditional.js'; const modifier = { id: 'weapon-damage', scope: 'equipped_weapon' as const, application: 'active_modifier' as const, attributeId: 'weapon-damage', value: 3, }; const program = ( trigger: TriggerConfiguration, overrides: Partial = {}, ): ConditionalProgram => ({ id: 'test-effect', trigger, lifecycle: { cooldownSeconds: 0, durationSeconds: 10, maxStacks: 10, stacksPerActivation: 1, }, outcomes: [modifier], ...overrides, }); const shot = ( at: number, targetId: string | null, hit = true, headshot = false, ): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot }); describe('trigger action registry', () => { it('owns every action and validates discriminated parameters', () => { expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16); expect( validateTriggerConfiguration('same_target_hits_within_window', { hitsRequired: 9, windowSeconds: 3, }), ).toMatchObject({ ok: true }); expect( validateTriggerConfiguration('same_target_hits_within_window', { hitsRequired: 0, windowSeconds: -1, }), ).toEqual({ ok: false, errors: [ 'Hits required must be a positive integer.', 'Window seconds must be greater than 0.', ], }); expect( validateTriggerConfiguration('while_firing', { unexpected: true }), ).toMatchObject({ ok: false }); expect( validateTriggerConfiguration('target_hit_within_distance', { meters: 0 }), ).toEqual({ ok: false, errors: ['Distance must be greater than 0.'], }); }); it('generates user-facing summaries from typed values', () => { expect( summarizeTrigger({ action: 'distinct_enemies_within_window', params: { enemiesRequired: 4, windowSeconds: 2.5 }, }), ).toBe('Hit 4 distinct enemies within 2.5 seconds.'); expect( summarizeTrigger({ action: 'continuous_firing_duration', params: { seconds: 1 }, }), ).toBe('Fire continuously for 1 second.'); expect( summarizeTrigger({ action: 'target_hit_within_distance', params: { meters: 20 }, }), ).toBe('Hit a target within 20 meters.'); }); it('validates unique health bands and their outcome relationships', () => { const result = validateConditionalProgram( program( { action: 'health_threshold_bands', params: { bands: [ { thresholdPercent: 50, outcomeKey: 'low' }, { thresholdPercent: 20, outcomeKey: 'critical' }, ], }, }, { outcomes: [ { ...modifier, id: 'low', outcomeKey: 'low' }, { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }, ], }, ), ); expect(result).toMatchObject({ ok: true }); }); it('rejects negative instant outcomes but permits negative Attribute modifiers', () => { expect( validateConditionalProgram( program( { action: 'headshot', params: {}, }, { outcomes: [ { id: 'damage', scope: 'target', application: 'direct_damage', basis: 'flat', value: -10, }, ], }, ), ), ).toEqual({ ok: false, errors: ['Instant outcome "damage" cannot use a negative value.'], }); expect( validateConditionalProgram( program( { action: 'headshot', params: {}, }, { outcomes: [{ ...modifier, value: -10 }], }, ), ), ).toMatchObject({ ok: true }); }); }); describe('evaluateConditionalProgram', () => { it('counts repeated hits against the same target inside the window', () => { const result = evaluateConditionalProgram( program({ action: 'same_target_hits_within_window', params: { hitsRequired: 3, windowSeconds: 1 }, }), [ shot(0, 'alpha'), shot(0.2, 'bravo'), shot(0.4, 'alpha'), shot(0.8, 'alpha'), ], { mode: 'encounter', encounterSeconds: 5 }, ); expect(result).toMatchObject({ status: 'OK', activationCount: 1, maxStacksReached: 1, }); expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1, }); }); it('counts each distinct enemy only once per window', () => { const result = evaluateConditionalProgram( program({ action: 'distinct_enemies_within_window', params: { enemiesRequired: 3, windowSeconds: 2 }, }), [ shot(0, 'alpha'), shot(0.2, 'alpha'), shot(0.4, 'bravo'), shot(0.6, 'charlie'), ], { mode: 'encounter', encounterSeconds: 5 }, ); expect(result.activationCount).toBe(1); expect(result.activations[0].at).toBe(0.6); }); it('resets consecutive hits after a miss or target change', () => { const result = evaluateConditionalProgram( program({ action: 'consecutive_same_target_hits', params: { hitsRequired: 3 }, }), [ shot(0, 'alpha'), shot(0.1, 'alpha'), shot(0.2, null, false), shot(0.3, 'alpha'), shot(0.4, 'bravo'), shot(0.5, 'bravo'), shot(0.6, 'bravo'), ], { mode: 'encounter', encounterSeconds: 5 }, ); expect(result.activationCount).toBe(1); expect(result.activations[0].at).toBe(0.6); }); it('starts cooldown on activation and applies the activation after its triggering event', () => { const result = evaluateConditionalProgram( program( { action: 'headshot', params: {}, }, { lifecycle: { cooldownSeconds: 3, durationSeconds: 5, maxStacks: 3, stacksPerActivation: 1, }, }, ), [ shot(1, 'alpha', true, true), shot(2, 'alpha', true, true), shot(4, 'alpha', true, true), ], { mode: 'encounter', encounterSeconds: 10 }, ); expect(result.activations).toHaveLength(2); expect(result.activations.map((activation) => activation.at)).toEqual([ 1, 4, ]); expect(result.activations[0].effectiveAfter).toBeGreaterThan(1); expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 9, cooldownUntil: 7, }); }); it('treats an explicit non-headshot hit as a body shot', () => { const result = evaluateConditionalProgram( program({ action: 'body_shot', params: {}, }), [ shot(1, 'alpha', true, false), shot(2, 'alpha', true, true), shot(3, 'alpha', false, false), ], { mode: 'encounter', encounterSeconds: 5 }, ); expect(result.activations.map((activation) => activation.at)).toEqual([1]); }); it('refreshes one shared duration and clears every stack on expiry', () => { const result = evaluateConditionalProgram( program( { action: 'damage_taken', params: {}, }, { lifecycle: { cooldownSeconds: 0, durationSeconds: 3, maxStacks: 3, stacksPerActivation: 1, }, }, ), [ { kind: 'damage_taken', at: 0 }, { kind: 'damage_taken', at: 1 }, { kind: 'damage_taken', at: 5 }, ], { mode: 'encounter', encounterSeconds: 10 }, ); expect( result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter, })), ).toEqual([ { stacksBefore: 0, stacksAfter: 1 }, { stacksBefore: 1, stacksAfter: 2 }, { stacksBefore: 0, stacksAfter: 1 }, ]); expect(result.intervals).toEqual([ { start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null }, { start: 1.000001, end: 4, stacks: 2, outcomeKey: null }, { start: 5.000001, end: 8, stacks: 1, outcomeKey: null }, ]); }); it('resets firing progress on reload without removing an active buff', () => { const result = evaluateConditionalProgram( program( { action: 'continuous_firing_duration', params: { seconds: 2 }, }, { lifecycle: { cooldownSeconds: 0, durationSeconds: 10, maxStacks: 4, stacksPerActivation: 1, }, }, ), [ { kind: 'firing_started', at: 0 }, shot(0.5, 'alpha'), { kind: 'reload_started', at: 3 }, { kind: 'reload_completed', at: 4 }, { kind: 'firing_started', at: 4 }, { kind: 'firing_stopped', at: 7 }, ], { mode: 'encounter', encounterSeconds: 12 }, ); expect(result.activations.map((activation) => activation.at)).toEqual([ 2, 6, ]); expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16, }); expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100); }); it('activates continuous firing only once in each uninterrupted segment', () => { const result = evaluateConditionalProgram( program({ action: 'continuous_firing_duration', params: { seconds: 2 }, }), [ { kind: 'firing_started', at: 0 }, { kind: 'firing_stopped', at: 7 }, ], { mode: 'encounter', encounterSeconds: 10 }, ); expect(result.activations.map((activation) => activation.at)).toEqual([2]); }); it('uses only the strongest matching health band', () => { const result = evaluateConditionalProgram( program( { action: 'health_threshold_bands', params: { bands: [ { thresholdPercent: 50, outcomeKey: 'low' }, { thresholdPercent: 20, outcomeKey: 'critical' }, ], }, }, { lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1, }, outcomes: [ { ...modifier, id: 'low', outcomeKey: 'low', value: 5 }, { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }, ], }, ), [], { mode: 'encounter', encounterSeconds: 20, healthPercent: 15, }, ); expect(result.selectedOutcomeKey).toBe('critical'); expect(result.intervals).toEqual([ { start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }, ]); }); it('allows a direct proc-rate override and reports per-stack instant outcomes', () => { const result = evaluateConditionalProgram( program( { action: 'weapon_kill', params: {}, }, { lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1, }, outcomes: [ { id: 'blast', scope: 'target', application: 'direct_damage', basis: 'weapon_base_percent', value: 25, }, ], }, ), [], { mode: 'encounter', encounterSeconds: 30, directProcsPerMinute: 6, }, ); expect(result.activations.map((activation) => activation.at)).toEqual([ 10, 20, 30, ]); expect(result.outcomeEvents.map((event) => event.value)).toEqual([ 25, 50, 75, ]); }); it('uses real trigger events when the whole-program activation override is zero', () => { const result = evaluateConditionalProgram( program({ action: 'weapon_kill', params: {}, }), [{ kind: 'enemy_killed', at: 4 }], { mode: 'encounter', encounterSeconds: 30, directProcsPerMinute: 0, }, ); expect(result.activations.map((activation) => activation.at)).toEqual([4]); }); it('models headshot kills from enemy-killed timeline events', () => { const result = evaluateConditionalProgram( program({ action: 'headshot_kill', params: {}, }), [ { kind: 'enemy_killed', at: 2, headshot: false }, { kind: 'enemy_killed', at: 7, headshot: true }, ], { mode: 'encounter', encounterSeconds: 15 }, ); expect(result.activations.map((activation) => activation.at)).toEqual([7]); }); it('activates on successful target hits while the target is within the configured distance', () => { const inRange = evaluateConditionalProgram( program({ action: 'target_hit_within_distance', params: { meters: 20 }, }), [ shot(1, 'alpha', false), shot(2, null), shot(3, 'alpha'), shot(4, 'bravo'), ], { mode: 'encounter', encounterSeconds: 10, distanceMeters: 20, }, ); expect(inRange.activations.map((activation) => activation.at)).toEqual([ 3, 4, ]); const outOfRange = evaluateConditionalProgram( program({ action: 'target_hit_within_distance', params: { meters: 20 }, }), [shot(3, 'alpha')], { mode: 'encounter', encounterSeconds: 10, distanceMeters: 21, }, ); expect(outOfRange.activationCount).toBe(0); }); it('requires a target-distance assumption for distance-limited hit events', () => { const result = evaluateConditionalProgram( program({ action: 'target_hit_within_distance', params: { meters: 20 }, }), [shot(3, 'alpha')], { mode: 'encounter', encounterSeconds: 10, }, ); expect(result.activationCount).toBe(0); }); it('keeps target proximity as a persistent condition rather than a hit event', () => { const result = evaluateConditionalProgram( program({ action: 'within_distance', params: { meters: 20 }, }), [], { mode: 'encounter', encounterSeconds: 10, distanceMeters: 20, }, ); expect(result.activationCount).toBe(1); expect(result.intervals).toEqual([ { start: 0, end: 10, stacks: 10, outcomeKey: null }, ]); }); it('keeps while-firing effects active only inside firing segments', () => { const result = evaluateConditionalProgram( program({ action: 'while_firing', params: {}, }), [ { kind: 'firing_started', at: 1 }, { kind: 'firing_stopped', at: 3 }, { kind: 'firing_started', at: 5 }, { kind: 'reload_started', at: 8 }, ], { mode: 'encounter', encounterSeconds: 10 }, ); expect(result.intervals).toEqual([ { start: 1, end: 3, stacks: 10, outcomeKey: null }, { start: 5, end: 8, stacks: 10, outcomeKey: null }, ]); expect(result.uptimePercent).toBe(50); }); });