572 lines
15 KiB
TypeScript
572 lines
15 KiB
TypeScript
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> = {},
|
|
): 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);
|
|
});
|
|
});
|