feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import {
formatItemDamageRangeScript,
formatWeaponCalculationScript
} from './calculation-script';
describe('formatWeaponCalculationScript', () => {
it('starts from the selected equipped damage and explains ordered typed stages', () => {
const lines = formatWeaponCalculationScript({
revision: { version: 4, name: 'Resurgence model' },
stages: [
{ stage_key: 'base', label: 'Base damage', operator: 'base_damage', display_order: 1 },
{ stage_key: 'flat', label: 'Flat damage', operator: 'flat_add', display_order: 2 },
{ stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 }
],
profiles: [{
stat_attribute_id: 'weapon-flat',
stage_key: 'flat',
weight: 1.5,
soft_cap: null,
hard_cap: 100,
curve_type: 'none',
curve_parameter: null,
weapon_category: 'SMG'
}],
attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }]
});
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
expect(lines).toContain('ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage');
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
});
it('keeps level scaling in item configuration instead of applying it twice', () => {
const lines = formatItemDamageRangeScript();
expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level');
expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation');
expect(lines).not.toContain('SCALE equipped_weapon.damage TO equipped_weapon.level');
});
});
+131
View File
@@ -0,0 +1,131 @@
export type CalculationScriptStage = {
stage_key: string;
label: string;
operator: string;
display_order: number;
};
export type CalculationScriptProfile = {
stat_attribute_id: string;
stage_key: string;
weight?: number | null;
soft_cap?: number | null;
hard_cap?: number | null;
curve_type?: string | null;
curve_parameter?: number | null;
weapon_category?: string | null;
};
export type CalculationScriptAttribute = {
id: string;
name: string;
unit?: string | null;
};
export type CalculationScriptRevision = {
version?: number | null;
name?: string | null;
};
const number = (value: unknown) =>
Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3)));
const identifier = (value: unknown) => String(value ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unnamed';
function profileExpression(
profile: CalculationScriptProfile,
attributes: Map<string, CalculationScriptAttribute>
) {
const attribute = attributes.get(profile.stat_attribute_id);
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
const weight = Number(profile.weight ?? 1);
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
if (weight !== 1) expression += ` × ${number(weight)}`;
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
const curve = profile.curve_type && profile.curve_type !== 'none'
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
: '';
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
}
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
}
if (profile.weapon_category && profile.weapon_category !== '*') {
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
}
return expression;
}
const operatorStatement = (operator: string, label: string, sources: string[]) => {
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
switch (operator) {
case 'base_damage':
return ['TAKE equipped_weapon.damage AS base_damage'];
case 'flat_add':
return [`ADD ${source} TO damage`];
case 'additive_percent':
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
case 'multiplicative_percent':
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
case 'expected_critical':
return [
`WEIGHT critical_damage BY critical_chance`,
...(sources.length ? [`SOURCE ${source}`] : [])
];
case 'expected_headshot':
return [
`WEIGHT headshot_damage BY headshot_chance`,
...(sources.length ? [`SOURCE ${source}`] : [])
];
case 'rate_of_fire':
return [`ADJUST equipped_weapon.rpm USING ${source}`];
case 'magazine_size':
return [`ADJUST equipped_weapon.magazine USING ${source}`];
case 'reload_speed':
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
case 'firing_cycle':
return [
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
'RETURN expected_damage_per_second'
];
default:
return [`APPLY ${identifier(label)} USING ${source}`];
}
};
export function formatWeaponCalculationScript(input: {
revision?: CalculationScriptRevision | null;
stages: CalculationScriptStage[];
profiles: CalculationScriptProfile[];
attributes: CalculationScriptAttribute[];
}) {
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
const lines = [
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`
];
for (const stage of [...input.stages].sort(
(left, right) => Number(left.display_order) - Number(right.display_order)
)) {
const sources = input.profiles
.filter((profile) => profile.stage_key === stage.stage_key)
.map((profile) => profileExpression(profile, attributes));
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources));
}
return lines;
}
export function formatItemDamageRangeScript() {
return [
'CONFIGURE equipped_weapon',
'READ configured_damage_range AT level_1',
'SCALE configured_damage_range TO equipped_weapon.level',
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
'PASS equipped_weapon.damage TO combat_calculation'
];
}
+31 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { calculateCombat, type CombatCalculationInput } from './combat.js';
import {
calculateCombat,
expectedWeaponShotDamage,
type CombatCalculationInput
} from './combat.js';
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
@@ -24,6 +28,18 @@ describe('calculateCombat', () => {
expect(result.sustainedDps).toBeCloseTo(495);
});
it('calculates expected body and headshot damage for timeline events', () => {
const input = base({
attributes: { critChance: 50, critDamage: 100 },
bindings: [
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }
]
});
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150);
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300);
});
it('applies weights, hard caps, and a configured curve safely', () => {
const result = calculateCombat(base({
attributes: { weapon: 100 },
@@ -41,6 +57,20 @@ describe('calculateCombat', () => {
expect(result.hitValues?.normal).toBe(180);
});
it('applies firing-cycle attributes to burst and sustained DPS', () => {
const result = calculateCombat(base({
attributes: { rate: 20, magazine: 100, reload: 100 },
bindings: [
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' }
]
}));
expect(result.status).toBe('OK');
expect(result.burstDps).toBe(1200);
expect(result.sustainedDps).toBeCloseTo(750);
});
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
const stackingEffects = [{
id: 'relentless',
+62 -2
View File
@@ -5,6 +5,9 @@ export type CalculationStage =
| 'critical_damage'
| 'headshot_chance'
| 'headshot_damage'
| 'rate_of_fire'
| 'magazine_size'
| 'reload_speed'
| 'final_damage';
export type CalculationOperator = 'add' | 'multiply';
export type CurveType = 'none' | 'power' | 'smoothstep';
@@ -26,6 +29,11 @@ export type WeaponCombatInput = {
reloadSeconds?: number;
headshotMultiplier?: number;
};
export type WeaponFiringCycle = {
rpm: number;
magazine: number;
reloadSeconds: number;
};
export type StackingMode = 'off' | 'peak' | 'encounter';
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number };
@@ -244,11 +252,63 @@ function dpsFor(
+ values.critical * boundCriticalChance * (1 - headshotChance)
+ values.headshot * (1 - boundCriticalChance) * headshotChance
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
const shotsPerSecond = weapon.rpm / 60;
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
weapon,
attributes,
bindings
);
const shotsPerSecond = rpm / 60;
return {
values,
burst: average * shotsPerSecond,
sustained: average * weapon.magazine / (weapon.magazine / shotsPerSecond + weapon.reloadSeconds)
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
};
}
export function expectedWeaponShotDamage(
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
headshot = false,
stageOrder?: CalculationStage[]
) {
const resolvedWeapon: Required<WeaponCombatInput> = {
baseDamage: Number(weapon.baseDamage),
rpm: Number(weapon.rpm),
magazine: Number(weapon.magazine),
reloadSeconds: Number(weapon.reloadSeconds),
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
};
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const criticalChance = clamp(critical.additive / 100, 0, 1);
return headshot
? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
}
export function resolveWeaponFiringCycle(
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[]
): WeaponFiringCycle {
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
return {
rpm: Number(weapon.rpm)
* (1 + rateOfFire.additive / 100)
* rateOfFire.multiplicative,
magazine: Math.max(
1,
Number(weapon.magazine)
* (1 + magazineSize.additive / 100)
* magazineSize.multiplicative
),
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
EPSILON,
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
)
};
}
+401
View File
@@ -0,0 +1,401 @@
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(15);
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('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);
});
});
+964
View File
@@ -0,0 +1,964 @@
export const TRIGGER_ACTION_KEYS = [
'same_target_hits_within_window',
'distinct_enemies_within_window',
'consecutive_same_target_hits',
'continuous_firing_duration',
'while_firing',
'health_threshold_bands',
'headshot',
'headshot_kill',
'weapon_kill',
'target_hit_within_distance',
'damage_taken',
'weapon_swap',
'reload',
'within_distance',
'extra_health_active'
] as const;
export type TriggerActionKey = (typeof TRIGGER_ACTION_KEYS)[number];
export type SameTargetHitsParams = {
hitsRequired: number;
windowSeconds: number;
};
export type DistinctEnemiesParams = {
enemiesRequired: number;
windowSeconds: number;
};
export type ConsecutiveHitsParams = {
hitsRequired: number;
};
export type ContinuousFiringParams = {
seconds: number;
};
export type HealthThresholdBand = {
thresholdPercent: number;
outcomeKey: string;
};
export type HealthThresholdParams = {
bands: HealthThresholdBand[];
};
export type DistanceParams = {
meters: number;
};
export type EmptyTriggerParams = Record<string, never>;
export type TriggerParamsByAction = {
same_target_hits_within_window: SameTargetHitsParams;
distinct_enemies_within_window: DistinctEnemiesParams;
consecutive_same_target_hits: ConsecutiveHitsParams;
continuous_firing_duration: ContinuousFiringParams;
while_firing: EmptyTriggerParams;
health_threshold_bands: HealthThresholdParams;
headshot: EmptyTriggerParams;
headshot_kill: EmptyTriggerParams;
weapon_kill: EmptyTriggerParams;
target_hit_within_distance: DistanceParams;
damage_taken: EmptyTriggerParams;
weapon_swap: EmptyTriggerParams;
reload: EmptyTriggerParams;
within_distance: DistanceParams;
extra_health_active: EmptyTriggerParams;
};
export type TriggerConfiguration = {
[K in TriggerActionKey]: {
action: K;
params: TriggerParamsByAction[K];
}
}[TriggerActionKey];
export type EffectScope =
| 'self'
| 'equipped_weapon'
| 'target'
| 'allies'
| 'self_and_allies'
| 'assigned_ability'
| 'all_skills';
export type ModifierOutcome = {
id: string;
outcomeKey?: string;
scope: EffectScope;
application: 'active_modifier';
attributeId: string;
value: number;
};
export type DirectDamageOutcome = {
id: string;
outcomeKey?: string;
scope: EffectScope;
application: 'direct_damage';
basis: 'flat' | 'weapon_base_percent';
value: number;
};
export type RestorationOutcome = {
id: string;
outcomeKey?: string;
scope: EffectScope;
application: 'restore_health' | 'restore_armor' | 'restore_ammo';
value: number;
};
export type ConditionalOutcome = ModifierOutcome | DirectDamageOutcome | RestorationOutcome;
export type ConditionalLifecycle = {
cooldownSeconds: number;
durationSeconds: number;
maxStacks: number;
stacksPerActivation: number;
};
export type ConditionalProgram = {
id: string;
trigger: TriggerConfiguration;
lifecycle: ConditionalLifecycle;
outcomes: ConditionalOutcome[];
};
export type ShotEvent = {
kind: 'shot';
at: number;
targetId?: string | null;
hit: boolean;
headshot?: boolean;
};
export type EnemyKilledEvent = {
kind: 'enemy_killed';
at: number;
targetId?: string | null;
headshot?: boolean;
};
export type ConditionalEvent =
| ShotEvent
| EnemyKilledEvent
| { kind: 'firing_started'; at: number }
| { kind: 'firing_stopped'; at: number }
| { kind: 'reload_started'; at: number }
| { kind: 'reload_completed'; at: number }
| { kind: 'damage_taken'; at: number }
| { kind: 'weapon_swapped'; at: number };
export type ConditionalMode = 'off' | 'peak' | 'encounter';
export type ConditionalAssumptions = {
mode: ConditionalMode;
encounterSeconds: number;
healthPercent?: number;
distanceMeters?: number;
extraHealthActive?: boolean;
directProcsPerMinute?: number;
};
export type ValidationResult<T> =
| { ok: true; value: T }
| { ok: false; errors: string[] };
export type TriggerActionDefinition = {
key: TriggerActionKey;
label: string;
validate: (params: unknown) => ValidationResult<TriggerParamsByAction[TriggerActionKey]>;
summarize: (params: TriggerParamsByAction[TriggerActionKey]) => string;
};
export type ConditionalActivation = {
at: number;
effectiveAfter: number;
stacksBefore: number;
stacksAfter: number;
expiresAt: number;
cooldownUntil: number;
outcomeKey: string | null;
};
export type ConditionalStateInterval = {
start: number;
end: number;
stacks: number;
outcomeKey: string | null;
};
export type ConditionalOutcomeEvent = {
at: number;
effectiveAfter: number;
outcomeId: string;
application: Exclude<ConditionalOutcome['application'], 'active_modifier'>;
scope: EffectScope;
value: number;
basis: DirectDamageOutcome['basis'] | null;
stacks: number;
};
export type ConditionalEvaluation = {
status: 'OK' | 'INVALID';
errors: string[];
programId: string;
mode: ConditionalMode;
activations: ConditionalActivation[];
intervals: ConditionalStateInterval[];
outcomeEvents: ConditionalOutcomeEvent[];
activationCount: number;
maxStacksReached: number;
averageStacks: number;
uptimePercent: number;
selectedOutcomeKey: string | null;
};
const EPSILON = 0.000_001;
const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
const plural = (value: number, singular: string, pluralForm = `${singular}s`) =>
`${formatNumber(value)} ${value === 1 ? singular : pluralForm}`;
const formatNumber = (value: number) => Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
function positiveNumber(value: unknown, name: string, errors: string[]) {
if (!finiteNumber(value) || value <= 0) errors.push(`${name} must be greater than 0.`);
}
function positiveInteger(value: unknown, name: string, errors: string[]) {
if (!Number.isInteger(value) || Number(value) <= 0) errors.push(`${name} must be a positive integer.`);
}
function validateEmpty(params: unknown): ValidationResult<EmptyTriggerParams> {
if (params === undefined || (isObject(params) && Object.keys(params).length === 0)) {
return { ok: true, value: {} };
}
return { ok: false, errors: ['This trigger does not accept parameters.'] };
}
function validateSameTarget(params: unknown): ValidationResult<SameTargetHitsParams> {
const errors: string[] = [];
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
positiveInteger(params.hitsRequired, 'Hits required', errors);
positiveNumber(params.windowSeconds, 'Window seconds', errors);
return errors.length
? { ok: false, errors }
: { ok: true, value: { hitsRequired: Number(params.hitsRequired), windowSeconds: Number(params.windowSeconds) } };
}
function validateDistinctEnemies(params: unknown): ValidationResult<DistinctEnemiesParams> {
const errors: string[] = [];
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
positiveInteger(params.enemiesRequired, 'Enemies required', errors);
positiveNumber(params.windowSeconds, 'Window seconds', errors);
return errors.length
? { ok: false, errors }
: { ok: true, value: { enemiesRequired: Number(params.enemiesRequired), windowSeconds: Number(params.windowSeconds) } };
}
function validateConsecutive(params: unknown): ValidationResult<ConsecutiveHitsParams> {
const errors: string[] = [];
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
positiveInteger(params.hitsRequired, 'Hits required', errors);
return errors.length
? { ok: false, errors }
: { ok: true, value: { hitsRequired: Number(params.hitsRequired) } };
}
function validateContinuousFiring(params: unknown): ValidationResult<ContinuousFiringParams> {
const errors: string[] = [];
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
positiveNumber(params.seconds, 'Firing seconds', errors);
return errors.length
? { ok: false, errors }
: { ok: true, value: { seconds: Number(params.seconds) } };
}
function validateHealthBands(params: unknown): ValidationResult<HealthThresholdParams> {
const errors: string[] = [];
if (!isObject(params) || !Array.isArray(params.bands)) {
return { ok: false, errors: ['Health bands must be an array.'] };
}
if (params.bands.length < 1 || params.bands.length > 5) {
errors.push('Health threshold triggers require between 1 and 5 bands.');
}
const bands: HealthThresholdBand[] = [];
const thresholds = new Set<number>();
const outcomeKeys = new Set<string>();
for (const [index, candidate] of params.bands.entries()) {
if (!isObject(candidate)) {
errors.push(`Health band ${index + 1} must be an object.`);
continue;
}
const threshold = candidate.thresholdPercent;
const outcomeKey = typeof candidate.outcomeKey === 'string' ? candidate.outcomeKey.trim() : '';
if (!finiteNumber(threshold) || threshold < 0 || threshold > 100) {
errors.push(`Health band ${index + 1} threshold must be between 0 and 100.`);
}
if (!outcomeKey) errors.push(`Health band ${index + 1} requires an outcome key.`);
if (finiteNumber(threshold) && thresholds.has(threshold)) {
errors.push(`Health threshold ${formatNumber(threshold)}% is duplicated.`);
}
if (outcomeKey && outcomeKeys.has(outcomeKey)) errors.push(`Outcome key "${outcomeKey}" is duplicated.`);
if (finiteNumber(threshold)) thresholds.add(threshold);
if (outcomeKey) outcomeKeys.add(outcomeKey);
if (finiteNumber(threshold) && outcomeKey) bands.push({ thresholdPercent: threshold, outcomeKey });
}
return errors.length
? { ok: false, errors }
: { ok: true, value: { bands: bands.sort((a, b) => a.thresholdPercent - b.thresholdPercent) } };
}
function validateDistance(params: unknown): ValidationResult<DistanceParams> {
const errors: string[] = [];
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
positiveNumber(params.meters, 'Distance', errors);
return errors.length
? { ok: false, errors }
: { ok: true, value: { meters: Number(params.meters) } };
}
const emptySummary = () => '';
export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerActionDefinition>> = {
same_target_hits_within_window: {
key: 'same_target_hits_within_window',
label: 'Same-target hits within a window',
validate: validateSameTarget,
summarize: (params) => {
const value = params as SameTargetHitsParams;
return `Hit the same enemy ${plural(value.hitsRequired, 'time')} within ${plural(value.windowSeconds, 'second')}.`;
}
},
distinct_enemies_within_window: {
key: 'distinct_enemies_within_window',
label: 'Distinct enemies hit within a window',
validate: validateDistinctEnemies,
summarize: (params) => {
const value = params as DistinctEnemiesParams;
return `Hit ${plural(value.enemiesRequired, 'distinct enemy', 'distinct enemies')} within ${plural(value.windowSeconds, 'second')}.`;
}
},
consecutive_same_target_hits: {
key: 'consecutive_same_target_hits',
label: 'Consecutive same-target hits',
validate: validateConsecutive,
summarize: (params) => {
const value = params as ConsecutiveHitsParams;
return `Hit the same enemy ${plural(value.hitsRequired, 'consecutive time')}.`;
}
},
continuous_firing_duration: {
key: 'continuous_firing_duration',
label: 'Continuous firing duration',
validate: validateContinuousFiring,
summarize: (params) => {
const value = params as ContinuousFiringParams;
return `Fire continuously for ${plural(value.seconds, 'second')}.`;
}
},
while_firing: {
key: 'while_firing',
label: 'While firing',
validate: validateEmpty,
summarize: () => 'While firing.'
},
health_threshold_bands: {
key: 'health_threshold_bands',
label: 'Health threshold bands',
validate: validateHealthBands,
summarize: (params) => {
const value = params as HealthThresholdParams;
const thresholds = [...value.bands]
.sort((a, b) => b.thresholdPercent - a.thresholdPercent)
.map((band) => `${formatNumber(band.thresholdPercent)}% (${band.outcomeKey})`);
return `Use the strongest matching effect while health is at or below ${thresholds.join(', ')}.`;
}
},
headshot: {
key: 'headshot',
label: 'Headshot',
validate: validateEmpty,
summarize: () => 'Land a headshot.'
},
headshot_kill: {
key: 'headshot_kill',
label: 'Headshot kill',
validate: validateEmpty,
summarize: () => 'Kill an enemy with a headshot.'
},
weapon_kill: {
key: 'weapon_kill',
label: 'Weapon kill',
validate: validateEmpty,
summarize: () => 'Kill an enemy with a weapon.'
},
target_hit_within_distance: {
key: 'target_hit_within_distance',
label: 'Target hit within distance',
validate: validateDistance,
summarize: (params) => {
const value = params as DistanceParams;
return `Hit a target within ${plural(value.meters, 'meter')}.`;
}
},
damage_taken: {
key: 'damage_taken',
label: 'Damage taken',
validate: validateEmpty,
summarize: () => 'Take damage.'
},
weapon_swap: {
key: 'weapon_swap',
label: 'Weapon swap',
validate: validateEmpty,
summarize: () => 'Swap weapons.'
},
reload: {
key: 'reload',
label: 'Reload',
validate: validateEmpty,
summarize: () => 'Start reloading.'
},
within_distance: {
key: 'within_distance',
label: 'Target within distance',
validate: validateDistance,
summarize: (params) => {
const value = params as DistanceParams;
return `While the target is within ${plural(value.meters, 'meter')}.`;
}
},
extra_health_active: {
key: 'extra_health_active',
label: 'Extra health active',
validate: validateEmpty,
summarize: () => 'While extra health is active.'
}
};
export function isTriggerActionKey(value: unknown): value is TriggerActionKey {
return typeof value === 'string' && (TRIGGER_ACTION_KEYS as readonly string[]).includes(value);
}
export function validateTriggerConfiguration(
action: TriggerActionKey,
params: unknown
): ValidationResult<TriggerConfiguration> {
const result = TRIGGER_ACTION_REGISTRY[action].validate(params);
if (!result.ok) return result;
return { ok: true, value: { action, params: result.value } as TriggerConfiguration };
}
export function summarizeTrigger(configuration: TriggerConfiguration): string {
return TRIGGER_ACTION_REGISTRY[configuration.action].summarize(configuration.params);
}
export function validateConditionalProgram(program: ConditionalProgram): ValidationResult<ConditionalProgram> {
const errors: string[] = [];
if (!program.id.trim()) errors.push('Program ID is required.');
const trigger = validateTriggerConfiguration(program.trigger.action, program.trigger.params);
if (!trigger.ok) errors.push(...trigger.errors.map((error) => `Trigger: ${error}`));
const lifecycle = program.lifecycle;
if (!finiteNumber(lifecycle.cooldownSeconds) || lifecycle.cooldownSeconds < 0) {
errors.push('Cooldown seconds must be 0 or greater.');
}
if (!finiteNumber(lifecycle.durationSeconds) || lifecycle.durationSeconds < 0) {
errors.push('Duration seconds must be 0 or greater.');
}
positiveInteger(lifecycle.maxStacks, 'Maximum stacks', errors);
positiveInteger(lifecycle.stacksPerActivation, 'Stacks per activation', errors);
if (
Number.isInteger(lifecycle.maxStacks)
&& Number.isInteger(lifecycle.stacksPerActivation)
&& lifecycle.stacksPerActivation > lifecycle.maxStacks
) {
errors.push('Stacks per activation cannot exceed maximum stacks.');
}
const outcomeIds = new Set<string>();
const healthOutcomeKeys = program.trigger.action === 'health_threshold_bands'
? new Set((program.trigger.params as HealthThresholdParams).bands.map((band) => band.outcomeKey))
: null;
for (const [index, outcome] of program.outcomes.entries()) {
if (!outcome.id.trim()) errors.push(`Outcome ${index + 1} requires an ID.`);
if (outcomeIds.has(outcome.id)) errors.push(`Outcome ID "${outcome.id}" is duplicated.`);
outcomeIds.add(outcome.id);
if (!finiteNumber(outcome.value)) errors.push(`Outcome "${outcome.id}" requires a finite value.`);
if (
outcome.application !== 'active_modifier'
&& finiteNumber(outcome.value)
&& outcome.value < 0
) {
errors.push(`Instant outcome "${outcome.id}" cannot use a negative value.`);
}
if (outcome.application === 'active_modifier' && !outcome.attributeId.trim()) {
errors.push(`Modifier outcome "${outcome.id}" requires an Attribute ID.`);
}
if (healthOutcomeKeys && (!outcome.outcomeKey || !healthOutcomeKeys.has(outcome.outcomeKey))) {
errors.push(`Outcome "${outcome.id}" must reference a configured health-band outcome key.`);
}
}
if (healthOutcomeKeys) {
const provided = new Set(program.outcomes.map((outcome) => outcome.outcomeKey).filter(Boolean));
for (const key of healthOutcomeKeys) {
if (!provided.has(key)) errors.push(`Health-band outcome "${key}" has no configured effect.`);
}
}
return errors.length ? { ok: false, errors } : { ok: true, value: program };
}
type CandidateActivation = { at: number; outcomeKey: string | null };
type FiringSegment = { start: number; end: number };
function orderedEvents(events: ConditionalEvent[], encounterSeconds: number) {
return events
.map((event, index) => ({ event, index }))
.filter(({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= encounterSeconds)
.sort((left, right) => left.event.at - right.event.at || left.index - right.index)
.map(({ event }) => event);
}
function firingSegments(events: ConditionalEvent[], encounterSeconds: number): FiringSegment[] {
const segments: FiringSegment[] = [];
let startedAt: number | null = null;
for (const event of events) {
if (event.kind === 'firing_started') {
if (startedAt === null) startedAt = event.at;
continue;
}
if (event.kind === 'shot' && startedAt === null) {
startedAt = event.at;
continue;
}
if ((event.kind === 'firing_stopped' || event.kind === 'reload_started') && startedAt !== null) {
segments.push({ start: startedAt, end: event.at });
startedAt = null;
}
}
if (startedAt !== null) segments.push({ start: startedAt, end: encounterSeconds });
return segments.filter((segment) => segment.end - segment.start > EPSILON);
}
function eventCandidates(
trigger: TriggerConfiguration,
events: ConditionalEvent[],
assumptions: ConditionalAssumptions
): CandidateActivation[] {
switch (trigger.action) {
case 'same_target_hits_within_window': {
const params = trigger.params;
const hits = new Map<string, number[]>();
const candidates: CandidateActivation[] = [];
for (const event of events) {
if (event.kind !== 'shot' || !event.hit || !event.targetId) continue;
const recent = (hits.get(event.targetId) ?? []).filter(
(at) => event.at - at <= params.windowSeconds + EPSILON
);
recent.push(event.at);
if (recent.length >= params.hitsRequired) {
candidates.push({ at: event.at, outcomeKey: null });
hits.set(event.targetId, []);
} else {
hits.set(event.targetId, recent);
}
}
return candidates;
}
case 'distinct_enemies_within_window': {
const params = trigger.params;
let recent = new Map<string, number>();
const candidates: CandidateActivation[] = [];
for (const event of events) {
if (event.kind !== 'shot' || !event.hit || !event.targetId) continue;
recent = new Map(
[...recent].filter(([, at]) => event.at - at <= params.windowSeconds + EPSILON)
);
recent.set(event.targetId, event.at);
if (recent.size >= params.enemiesRequired) {
candidates.push({ at: event.at, outcomeKey: null });
recent.clear();
}
}
return candidates;
}
case 'consecutive_same_target_hits': {
const params = trigger.params;
let targetId: string | null = null;
let count = 0;
const candidates: CandidateActivation[] = [];
for (const event of events) {
if (event.kind !== 'shot') continue;
if (!event.hit || !event.targetId) {
targetId = null;
count = 0;
continue;
}
if (event.targetId !== targetId) {
targetId = event.targetId;
count = 1;
} else {
count += 1;
}
if (count >= params.hitsRequired) {
candidates.push({ at: event.at, outcomeKey: null });
count = 0;
}
}
return candidates;
}
case 'continuous_firing_duration': {
return firingSegments(events, assumptions.encounterSeconds)
.filter((segment) => segment.end - segment.start + EPSILON >= trigger.params.seconds)
.map((segment) => ({
at: segment.start + trigger.params.seconds,
outcomeKey: null
}));
}
case 'headshot':
return events
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'headshot_kill':
return events
.filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true)
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'weapon_kill':
return events
.filter((event) => event.kind === 'enemy_killed')
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'target_hit_within_distance':
if (
!finiteNumber(assumptions.distanceMeters)
|| assumptions.distanceMeters > trigger.params.meters + EPSILON
) return [];
return events
.filter((event): event is ShotEvent =>
event.kind === 'shot' && event.hit && Boolean(event.targetId)
)
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'damage_taken':
return events
.filter((event) => event.kind === 'damage_taken')
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'weapon_swap':
return events
.filter((event) => event.kind === 'weapon_swapped')
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'reload':
return events
.filter((event) => event.kind === 'reload_started')
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'while_firing':
case 'health_threshold_bands':
case 'within_distance':
case 'extra_health_active':
return [];
}
}
function directRateCandidates(procsPerMinute: number, encounterSeconds: number): CandidateActivation[] {
if (!finiteNumber(procsPerMinute) || procsPerMinute <= 0) return [];
const interval = 60 / procsPerMinute;
const candidates: CandidateActivation[] = [];
for (let at = interval; at <= encounterSeconds + EPSILON; at += interval) {
candidates.push({ at, outcomeKey: null });
}
return candidates;
}
function strongestHealthOutcome(params: HealthThresholdParams, healthPercent: number | undefined) {
if (!finiteNumber(healthPercent)) return null;
const current = clamp(healthPercent, 0, 100);
return [...params.bands]
.sort((left, right) => left.thresholdPercent - right.thresholdPercent)
.find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ?? null;
}
function persistentCondition(
trigger: TriggerConfiguration,
assumptions: ConditionalAssumptions
): { active: boolean; outcomeKey: string | null } {
switch (trigger.action) {
case 'health_threshold_bands': {
const outcomeKey = strongestHealthOutcome(trigger.params, assumptions.healthPercent);
return { active: outcomeKey !== null, outcomeKey };
}
case 'within_distance':
return {
active: finiteNumber(assumptions.distanceMeters)
&& assumptions.distanceMeters <= trigger.params.meters + EPSILON,
outcomeKey: null
};
case 'extra_health_active':
return { active: assumptions.extraHealthActive === true, outcomeKey: null };
default:
return { active: false, outcomeKey: null };
}
}
function outcomeEventsAt(
program: ConditionalProgram,
at: number,
stacks: number,
outcomeKey: string | null
): ConditionalOutcomeEvent[] {
return program.outcomes
.filter((outcome) => outcome.application !== 'active_modifier')
.filter((outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey)
.map((outcome) => ({
at,
effectiveAfter: at + EPSILON,
outcomeId: outcome.id,
application: outcome.application,
scope: outcome.scope,
value: outcome.value * stacks,
basis: outcome.application === 'direct_damage' ? outcome.basis : null,
stacks
}));
}
function summarizeIntervals(intervals: ConditionalStateInterval[], encounterSeconds: number) {
if (encounterSeconds <= 0) return { averageStacks: 0, uptimePercent: 0 };
let stackSeconds = 0;
let activeSeconds = 0;
for (const interval of intervals) {
const duration = Math.max(0, interval.end - interval.start);
stackSeconds += duration * interval.stacks;
if (interval.stacks > 0) activeSeconds += duration;
}
return {
averageStacks: stackSeconds / encounterSeconds,
uptimePercent: clamp(activeSeconds / encounterSeconds * 100, 0, 100)
};
}
function stateIntervals(
activations: ConditionalActivation[],
encounterSeconds: number
): ConditionalStateInterval[] {
const boundaries = new Set<number>([0, encounterSeconds]);
for (const activation of activations) {
boundaries.add(clamp(activation.effectiveAfter, 0, encounterSeconds));
boundaries.add(clamp(activation.expiresAt, 0, encounterSeconds));
}
const ordered = [...boundaries].sort((left, right) => left - right);
const intervals: ConditionalStateInterval[] = [];
for (let index = 0; index < ordered.length - 1; index += 1) {
const start = ordered[index];
const end = ordered[index + 1];
if (end - start <= EPSILON) continue;
const active = [...activations]
.reverse()
.find((activation) =>
activation.effectiveAfter <= start + EPSILON
&& activation.expiresAt > start + EPSILON
);
if (!active || active.stacksAfter <= 0) continue;
const prior = intervals.at(-1);
if (
prior
&& Math.abs(prior.end - start) <= EPSILON
&& prior.stacks === active.stacksAfter
&& prior.outcomeKey === active.outcomeKey
) {
prior.end = end;
} else {
intervals.push({ start, end, stacks: active.stacksAfter, outcomeKey: active.outcomeKey });
}
}
return intervals;
}
function evaluateCandidates(
program: ConditionalProgram,
candidates: CandidateActivation[],
encounterSeconds: number
) {
const activations: ConditionalActivation[] = [];
const outcomeEvents: ConditionalOutcomeEvent[] = [];
let stacks = 0;
let expiresAt = 0;
let cooldownUntil = 0;
for (const candidate of candidates.sort((left, right) => left.at - right.at)) {
if (candidate.at < 0 || candidate.at > encounterSeconds + EPSILON) continue;
if (candidate.at + EPSILON < cooldownUntil) continue;
if (candidate.at + EPSILON >= expiresAt) stacks = 0;
const stacksBefore = stacks;
stacks = Math.min(
program.lifecycle.maxStacks,
stacks + program.lifecycle.stacksPerActivation
);
expiresAt = candidate.at + program.lifecycle.durationSeconds;
cooldownUntil = candidate.at + program.lifecycle.cooldownSeconds;
const activation: ConditionalActivation = {
at: candidate.at,
effectiveAfter: candidate.at + EPSILON,
stacksBefore,
stacksAfter: stacks,
expiresAt,
cooldownUntil,
outcomeKey: candidate.outcomeKey
};
activations.push(activation);
outcomeEvents.push(...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey));
}
return { activations, outcomeEvents };
}
function evaluatePersistent(
program: ConditionalProgram,
assumptions: ConditionalAssumptions,
outcomeKey: string | null
) {
const stacks = program.lifecycle.maxStacks;
const activation: ConditionalActivation = {
at: 0,
effectiveAfter: 0,
stacksBefore: 0,
stacksAfter: stacks,
expiresAt: assumptions.encounterSeconds,
cooldownUntil: 0,
outcomeKey
};
const intervals = assumptions.encounterSeconds > 0
? [{ start: 0, end: assumptions.encounterSeconds, stacks, outcomeKey }]
: [];
return {
activations: [activation],
intervals,
outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey)
};
}
function evaluateWhileFiring(
program: ConditionalProgram,
events: ConditionalEvent[],
assumptions: ConditionalAssumptions
) {
const segments = firingSegments(events, assumptions.encounterSeconds);
const stacks = program.lifecycle.maxStacks;
const activations = segments.map<ConditionalActivation>((segment) => ({
at: segment.start,
effectiveAfter: segment.start + EPSILON,
stacksBefore: 0,
stacksAfter: stacks,
expiresAt: segment.end,
cooldownUntil: segment.start,
outcomeKey: null
}));
return {
activations,
intervals: segments.map<ConditionalStateInterval>((segment) => ({
start: segment.start,
end: segment.end,
stacks,
outcomeKey: null
})),
outcomeEvents: activations.flatMap((activation) =>
outcomeEventsAt(program, activation.at, stacks, null)
)
};
}
function invalidEvaluation(programId: string, mode: ConditionalMode, errors: string[]): ConditionalEvaluation {
return {
status: 'INVALID',
errors,
programId,
mode,
activations: [],
intervals: [],
outcomeEvents: [],
activationCount: 0,
maxStacksReached: 0,
averageStacks: 0,
uptimePercent: 0,
selectedOutcomeKey: null
};
}
export function evaluateConditionalProgram(
program: ConditionalProgram,
events: ConditionalEvent[],
assumptions: ConditionalAssumptions
): ConditionalEvaluation {
const validation = validateConditionalProgram(program);
const assumptionErrors: string[] = [];
if (!finiteNumber(assumptions.encounterSeconds) || assumptions.encounterSeconds <= 0) {
assumptionErrors.push('Encounter seconds must be greater than 0.');
}
if (
assumptions.directProcsPerMinute !== undefined
&& (!finiteNumber(assumptions.directProcsPerMinute) || assumptions.directProcsPerMinute < 0)
) {
assumptionErrors.push('Direct procs per minute must be 0 or greater.');
}
if (!validation.ok || assumptionErrors.length) {
return invalidEvaluation(
program.id,
assumptions.mode,
[...(validation.ok ? [] : validation.errors), ...assumptionErrors]
);
}
if (assumptions.mode === 'off') {
return {
...invalidEvaluation(program.id, assumptions.mode, []),
status: 'OK'
};
}
const ordered = orderedEvents(events, assumptions.encounterSeconds);
let activations: ConditionalActivation[] = [];
let intervals: ConditionalStateInterval[] = [];
let outcomeEvents: ConditionalOutcomeEvent[] = [];
let selectedOutcomeKey: string | null = null;
if (assumptions.mode === 'peak') {
selectedOutcomeKey = program.trigger.action === 'health_threshold_bands'
? strongestHealthOutcome(program.trigger.params, assumptions.healthPercent)
?? [...program.trigger.params.bands]
.sort((left, right) => left.thresholdPercent - right.thresholdPercent)[0]?.outcomeKey
?? null
: null;
const persistent = evaluatePersistent(program, assumptions, selectedOutcomeKey);
({ activations, intervals, outcomeEvents } = persistent);
} else if (
finiteNumber(assumptions.directProcsPerMinute)
&& assumptions.directProcsPerMinute > 0
) {
const evaluated = evaluateCandidates(
program,
directRateCandidates(assumptions.directProcsPerMinute, assumptions.encounterSeconds),
assumptions.encounterSeconds
);
activations = evaluated.activations;
outcomeEvents = evaluated.outcomeEvents;
intervals = stateIntervals(activations, assumptions.encounterSeconds);
} else if (program.trigger.action === 'while_firing') {
const evaluated = evaluateWhileFiring(program, ordered, assumptions);
({ activations, intervals, outcomeEvents } = evaluated);
} else {
const condition = persistentCondition(program.trigger, assumptions);
if (condition.active) {
selectedOutcomeKey = condition.outcomeKey;
const persistent = evaluatePersistent(program, assumptions, condition.outcomeKey);
({ activations, intervals, outcomeEvents } = persistent);
} else {
const evaluated = evaluateCandidates(
program,
eventCandidates(program.trigger, ordered, assumptions),
assumptions.encounterSeconds
);
activations = evaluated.activations;
outcomeEvents = evaluated.outcomeEvents;
intervals = stateIntervals(activations, assumptions.encounterSeconds);
}
}
const summary = summarizeIntervals(intervals, assumptions.encounterSeconds);
return {
status: 'OK',
errors: [],
programId: program.id,
mode: assumptions.mode,
activations,
intervals,
outcomeEvents,
activationCount: activations.length,
maxStacksReached: Math.max(0, ...activations.map((activation) => activation.stacksAfter)),
averageStacks: summary.averageStacks,
uptimePercent: summary.uptimePercent,
selectedOutcomeKey
};
}
@@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest';
import {
parseConditionalMechanicDraft,
type ConditionalMechanicDraftV1
} from './mechanic-editor.js';
const draft = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'effect',
label: 'Effect',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 10
}],
clauses: [{
key: 'gain-effect',
label: 'Gain Effect',
event: 'weapon_hit',
recipeKey: 'same_target_hits_within_window',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
groupBy: { source: 'event', path: 'targetId' }
},
cooldownSeconds: 2,
weight: 1,
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
outcomes: [{
key: 'weapon-damage',
label: 'Weapon Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
basis: 'flat',
attributeId: 'weapon-damage',
statePool: 'effect',
perStack: true,
descriptionSlot: 1,
variants: {
I: { value: 3 },
II: { value: 4.2 }
}
}]
}]
});
describe('conditional mechanic authoring payload', () => {
it('builds one validated runtime program per exact source variant', () => {
const result = parseConditionalMechanicDraft(draft(), {
variantKeys: ['I', 'II'],
programId: 'superior-firepower',
programName: 'Superior Firepower',
allowedAttributeIds: new Set(['weapon-damage']),
allowedRecipeKeys: new Set(['same_target_hits_within_window'])
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(Object.keys(result.value.programs)).toEqual(['I', 'II']);
expect(result.value.programs.I.clauses[0]).toMatchObject({
id: 'gain-effect',
when: 'weapon_hit',
recipeKey: 'same_target_hits_within_window',
cooldownSeconds: 2,
weight: 1,
outcomes: [{
id: 'weapon-damage',
scope: 'equipped_weapon',
value: 3
}]
});
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2);
});
it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => {
const happy: ConditionalMechanicDraftV1 = {
version: 1,
simulationReadiness: 'requires_assumptions',
scenarioInputs: [{
key: 'restoration-events-per-minute',
label: 'Restoration events per minute',
kind: 'number',
readiness: 'requires_assumption',
min: 0
}],
pools: [{
key: 'nearby-enemies',
label: 'Nearby Enemies',
stateKind: 'count',
scope: 'agent',
initial: 0,
min: 0,
max: 20,
bestCaseValue: 5
}],
clauses: [{
key: 'shot-health-exchange',
event: 'shot_fired',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
key: 'health-cost',
label: 'Health Cost',
phase: 'on_event',
kind: 'resource_cost',
resource: 'health',
scope: 'self',
basis: 'max_health_percent',
variants: { base: { value: 2 } }
},
{
key: 'bonus-damage',
label: 'Current Health Bonus Damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
basis: 'current_health_percent',
combatTiming: 'current',
variants: {
base: {
value: 0.6,
expression: {
op: 'multiply',
left: 0.6,
right: {
op: 'add',
left: 1,
right: {
op: 'divide',
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades'
}
},
right: 5
}
}
}
}
}
}
]
}]
};
const result = parseConditionalMechanicDraft(happy, {
variantKeys: ['base'],
programName: 'Happy Survival'
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.base.clauses[0].outcomes).toEqual([
expect.objectContaining({ kind: 'resource_cost', resource: 'health', value: 2 }),
expect.objectContaining({
kind: 'direct_damage',
combatTiming: 'current',
value: expect.objectContaining({
right: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades'
}
}
})
})
})
})
]);
});
it('requires the exact configured variants and returns validation errors without throwing', () => {
const invalid = draft();
invalid.clauses[0].outcomes[0].variants = {
I: { value: 3 },
legacy: { value: 99 }
};
const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain(
'Clause 1 outcome 1 must define exactly these variants: I, II.'
);
}
expect(() => parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }))
.not.toThrow();
expect(parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }))
.toEqual({
ok: false,
errors: ['The conditional mechanic contains malformed JSON.']
});
});
});
+605
View File
@@ -0,0 +1,605 @@
import {
MECHANIC_EVENT_KINDS,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicOutcomeScope,
type MechanicProgram,
type MechanicResource,
type NumericExpression,
type OutcomeBasis,
type OutcomePhase,
type PoolDefinition,
type Predicate,
type Reducer,
type ScenarioInputDefinition,
type StateScope,
type ValueReference
} from './mechanic.js';
export const CONDITIONAL_MECHANIC_DRAFT_VERSION = 1 as const;
export type MechanicStateKind =
| 'flag'
| 'count'
| 'stack'
| 'duration'
| 'scalar'
| 'target_mark'
| 'resource';
export type ConditionalMechanicPoolDraft = PoolDefinition & {
stateKind: MechanicStateKind;
};
export type ConditionalMechanicVariantValue = {
value: number;
expression?: NumericExpression;
};
export type ConditionalMechanicOutcomeDraft = Omit<MechanicOutcome, 'id' | 'value'> & {
key: string;
descriptionSlot?: number;
variants: Record<string, ConditionalMechanicVariantValue>;
};
export type ConditionalMechanicClauseDraft = Omit<MechanicClause, 'id' | 'when' | 'outcomes'> & {
key: string;
event: MechanicEventKind;
recipeKey?: string;
outcomes: ConditionalMechanicOutcomeDraft[];
};
export type ConditionalMechanicDraftV1 = {
version: typeof CONDITIONAL_MECHANIC_DRAFT_VERSION;
simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only';
scenarioInputs: ScenarioInputDefinition[];
pools: ConditionalMechanicPoolDraft[];
clauses: ConditionalMechanicClauseDraft[];
};
export type ParseConditionalMechanicDraftOptions = {
variantKeys: readonly string[];
programId?: string;
programName?: string;
allowedAttributeIds?: ReadonlySet<string>;
allowedRecipeKeys?: ReadonlySet<string>;
};
export type ParsedConditionalMechanicDraft = {
draft: ConditionalMechanicDraftV1;
programs: Record<string, MechanicProgram>;
};
export type ConditionalMechanicDraftResult =
| { ok: true; value: ParsedConditionalMechanicDraft }
| { ok: false; errors: string[] };
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const scalar = (value: unknown) =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);
const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
const events = new Set<string>(MECHANIC_EVENT_KINDS);
const phases = new Set<OutcomePhase>(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']);
const outcomeKinds = new Set<MechanicOutcome['kind']>([
'attribute_modifier',
'direct_damage',
'restoration',
'resource_cost',
'marker'
]);
const scopes = new Set<MechanicOutcomeScope>([
'self',
'equipped_weapon',
'target',
'allies',
'self_and_allies',
'assigned_ability',
'all_skills'
]);
const bases = new Set<OutcomeBasis>([
'flat',
'max_health_percent',
'current_health_percent',
'weapon_base_percent'
]);
const resources = new Set<MechanicResource>(['health', 'armor', 'ammo']);
const stateKinds = new Set<MechanicStateKind>([
'flag',
'count',
'stack',
'duration',
'scalar',
'target_mark',
'resource'
]);
function parseRaw(raw: unknown, errors: string[]) {
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch {
errors.push('The conditional mechanic contains malformed JSON.');
return null;
}
}
function key(value: unknown, path: string, errors: string[]) {
if (typeof value !== 'string' || !identifier.test(value)) {
errors.push(`${path} must use a stable lower-case key.`);
return '';
}
return value;
}
function isEntitySelector(value: unknown) {
if (!object(value) || typeof value.kind !== 'string') return false;
if (['agent', 'event_actor', 'event_target'].includes(value.kind)) return true;
return value.kind === 'event_field' && typeof value.path === 'string' && Boolean(value.path.trim());
}
function isReference(value: unknown): value is ValueReference {
if (!object(value) || typeof value.source !== 'string') return false;
switch (value.source) {
case 'event':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'scenario':
return typeof value.key === 'string' && Boolean(value.key.trim());
case 'combat':
return typeof value.path === 'string'
&& Boolean(value.path.trim())
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'reducer':
return true;
case 'pool':
return typeof value.pool === 'string'
&& Boolean(value.pool.trim())
&& (value.entity === undefined || isEntitySelector(value.entity));
default:
return false;
}
}
function isExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) return isReference(value.ref);
if (value.op === 'clamp') {
return isExpression(value.value) && isExpression(value.min) && isExpression(value.max);
}
return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op))
&& isExpression(value.left)
&& isExpression(value.right);
}
function isOperand(value: unknown) {
return scalar(value) || isReference(value);
}
function isPredicate(value: unknown): value is Predicate {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'true') return true;
if (value.kind === 'exists') return isReference(value.value);
if (value.kind === 'not') return isPredicate(value.predicate);
if (value.kind === 'all' || value.kind === 'any') {
return Array.isArray(value.predicates) && value.predicates.every(isPredicate);
}
return value.kind === 'compare'
&& ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator))
&& isOperand(value.left)
&& isOperand(value.right);
}
function optionalGroupBy(value: Record<string, unknown>) {
return value.groupBy === undefined || isReference(value.groupBy);
}
function isReducer(value: unknown): value is Reducer {
if (!object(value) || typeof value.kind !== 'string') return false;
switch (value.kind) {
case 'none':
return true;
case 'count':
return finite(value.threshold) && optionalGroupBy(value);
case 'modulo':
return finite(value.every) && optionalGroupBy(value);
case 'distinct_count':
return finite(value.threshold)
&& finite(value.windowSeconds)
&& isReference(value.distinctBy);
case 'consecutive_count':
return finite(value.threshold) && optionalGroupBy(value);
case 'sliding_count':
return finite(value.threshold) && finite(value.windowSeconds) && optionalGroupBy(value);
case 'duration':
return finite(value.seconds) && optionalGroupBy(value);
case 'periodic':
return isExpression(value.intervalSeconds) && optionalGroupBy(value);
case 'derived_scalar':
return isExpression(value.value);
default:
return false;
}
}
function isMutation(value: unknown): value is ClauseMutation {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'none') return true;
if (value.kind === 'clear') {
return typeof value.pool === 'string'
&& (value.entity === undefined || isEntitySelector(value.entity));
}
if (value.kind === 'transfer') {
return typeof value.pool === 'string'
&& isEntitySelector(value.from)
&& isEntitySelector(value.to)
&& (value.value === undefined || value.value === 'all' || isExpression(value.value));
}
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
&& typeof value.pool === 'string'
&& isExpression(value.value)
&& (value.entity === undefined || isEntitySelector(value.entity));
}
function parseScenarioInputs(value: unknown, errors: string[]) {
if (!Array.isArray(value)) {
errors.push('Scenario inputs must be an array.');
return [] as ScenarioInputDefinition[];
}
const inputs: ScenarioInputDefinition[] = [];
for (const [index, candidate] of value.entries()) {
const path = `Scenario input ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
if (
!key(candidate.key, `${path} key`, errors)
|| typeof candidate.label !== 'string'
|| !candidate.label.trim()
|| !['toggle', 'slider', 'number'].includes(String(candidate.kind))
) {
errors.push(`${path} requires a label and supported input kind.`);
continue;
}
if (
candidate.readiness !== undefined
&& candidate.readiness !== 'ready'
&& candidate.readiness !== 'requires_assumption'
) {
errors.push(`${path} has an invalid readiness value.`);
continue;
}
inputs.push(candidate as ScenarioInputDefinition);
}
return inputs;
}
function parsePools(value: unknown, errors: string[]) {
if (!Array.isArray(value)) {
errors.push('State pools must be an array.');
return [] as ConditionalMechanicPoolDraft[];
}
const pools: ConditionalMechanicPoolDraft[] = [];
for (const [index, candidate] of value.entries()) {
const path = `State pool ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
const poolKey = key(candidate.key, `${path} key`, errors);
if (
!poolKey
|| typeof candidate.label !== 'string'
|| !candidate.label.trim()
|| !stateKinds.has(candidate.stateKind as MechanicStateKind)
|| !['agent', 'target'].includes(String(candidate.scope))
|| !finite(candidate.initial)
) {
errors.push(`${path} requires a label, state kind, scope, and finite initial value.`);
continue;
}
for (const property of ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'] as const) {
if (candidate[property] !== undefined && !finite(candidate[property])) {
errors.push(`${path} ${property} must be a finite number.`);
}
}
pools.push(candidate as ConditionalMechanicPoolDraft);
}
return pools;
}
function parseOutcome(
value: unknown,
path: string,
variantKeys: readonly string[],
allowedAttributes: ReadonlySet<string> | undefined,
errors: string[]
): ConditionalMechanicOutcomeDraft | null {
if (!object(value)) {
errors.push(`${path} must be an object.`);
return null;
}
const outcomeKey = key(value.key, `${path} key`, errors);
if (
!outcomeKey
|| typeof value.label !== 'string'
|| !value.label.trim()
|| !phases.has(value.phase as OutcomePhase)
|| !outcomeKinds.has(value.kind as MechanicOutcome['kind'])
|| !scopes.has(value.scope as MechanicOutcomeScope)
|| !bases.has(value.basis as OutcomeBasis)
) {
errors.push(`${path} requires a label, phase, kind, scope, and value basis.`);
return null;
}
const kind = value.kind as MechanicOutcome['kind'];
if (kind === 'attribute_modifier') {
if (typeof value.attributeId !== 'string' || !value.attributeId.trim()) {
errors.push(`${path} attribute modifiers require an Attribute ID.`);
} else if (allowedAttributes && !allowedAttributes.has(value.attributeId)) {
errors.push(`${path} references unknown Attribute "${value.attributeId}".`);
}
} else if (value.attributeId !== undefined) {
errors.push(`${path} may only use an Attribute ID for an attribute modifier.`);
}
if (kind === 'restoration' || kind === 'resource_cost') {
if (!resources.has(value.resource as MechanicResource)) {
errors.push(`${path} ${kind.replace('_', ' ')} requires a resource.`);
}
} else if (value.resource !== undefined) {
errors.push(`${path} may only use a resource for restoration or resource cost.`);
}
if (
value.combatTiming !== undefined
&& value.combatTiming !== 'pre_event'
&& value.combatTiming !== 'current'
) {
errors.push(`${path} has invalid combat timing.`);
}
if (
value.descriptionSlot !== undefined
&& (!Number.isInteger(value.descriptionSlot) || Number(value.descriptionSlot) < 1)
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
}
const actualKeys = Object.keys(value.variants).sort();
const expectedKeys = [...variantKeys].sort();
if (
actualKeys.length !== expectedKeys.length
|| actualKeys.some((variant, index) => variant !== expectedKeys[index])
) {
errors.push(`${path} must define exactly these variants: ${variantKeys.join(', ')}.`);
}
const variants: Record<string, ConditionalMechanicVariantValue> = {};
for (const variant of variantKeys) {
const configured = value.variants[variant];
if (!object(configured) || !finite(configured.value)) {
errors.push(`${path} variant "${variant}" requires a finite display value.`);
continue;
}
if (configured.expression !== undefined && !isExpression(configured.expression)) {
errors.push(`${path} variant "${variant}" has an invalid value expression.`);
continue;
}
variants[variant] = configured as ConditionalMechanicVariantValue;
}
return {
...(value as unknown as ConditionalMechanicOutcomeDraft),
key: outcomeKey,
variants
};
}
function parseClauses(
value: unknown,
variantKeys: readonly string[],
options: ParseConditionalMechanicDraftOptions,
errors: string[]
) {
if (!Array.isArray(value)) {
errors.push('Mechanic clauses must be an array.');
return [] as ConditionalMechanicClauseDraft[];
}
const clauses: ConditionalMechanicClauseDraft[] = [];
for (const [index, candidate] of value.entries()) {
const path = `Clause ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
const clauseKey = key(candidate.key, `${path} key`, errors);
if (!clauseKey || !events.has(String(candidate.event))) {
errors.push(`${path} requires a supported direct event.`);
continue;
}
if (
candidate.recipeKey !== undefined
&& (
typeof candidate.recipeKey !== 'string'
|| !candidate.recipeKey.trim()
|| (options.allowedRecipeKeys && !options.allowedRecipeKeys.has(candidate.recipeKey))
)
) {
errors.push(`${path} has an unknown recipe key.`);
}
if (!isPredicate(candidate.if ?? { kind: 'true' })) {
errors.push(`${path} has an invalid predicate.`);
}
if (!isReducer(candidate.reducer)) errors.push(`${path} has an invalid reducer.`);
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
errors.push(`${path} has an invalid weight expression.`);
}
if (
candidate.cooldownSeconds !== undefined
&& (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
) {
errors.push(`${path} cooldown must be greater than zero.`);
}
if (!Array.isArray(candidate.mutations) || !candidate.mutations.every(isMutation)) {
errors.push(`${path} mutations must be a valid array.`);
}
if (!Array.isArray(candidate.outcomes)) {
errors.push(`${path} outcomes must be an array.`);
continue;
}
const outcomes = candidate.outcomes
.map((outcome, outcomeIndex) => parseOutcome(
outcome,
`${path} outcome ${outcomeIndex + 1}`,
variantKeys,
options.allowedAttributeIds,
errors
))
.filter((outcome): outcome is ConditionalMechanicOutcomeDraft => outcome !== null);
clauses.push({
...(candidate as unknown as ConditionalMechanicClauseDraft),
key: clauseKey,
event: candidate.event as MechanicEventKind,
if: (candidate.if ?? { kind: 'true' }) as Predicate,
reducer: candidate.reducer as Reducer,
mutations: Array.isArray(candidate.mutations) ? candidate.mutations as ClauseMutation[] : [],
outcomes
});
}
return clauses;
}
function programsForDraft(
draft: ConditionalMechanicDraftV1,
options: ParseConditionalMechanicDraftOptions
) {
const programs: Record<string, MechanicProgram> = {};
for (const variant of options.variantKeys) {
programs[variant] = {
id: `${options.programId ?? 'mechanic'}:${variant}`,
name: options.programName?.trim() || 'Conditional Mechanic',
scenarioInputs: draft.scenarioInputs,
pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool),
clauses: draft.clauses.map((clause): MechanicClause => ({
id: clause.key,
...(clause.label ? { label: clause.label } : {}),
when: clause.event,
...(clause.recipeKey ? { recipeKey: clause.recipeKey } : {}),
...(clause.if ? { if: clause.if } : {}),
reducer: clause.reducer,
...(clause.cooldownSeconds === undefined
? {}
: { cooldownSeconds: clause.cooldownSeconds }),
...(clause.weight === undefined ? {} : { weight: clause.weight }),
mutations: clause.mutations,
outcomes: clause.outcomes.map(({ key: outcomeKey, variants, descriptionSlot: _slot, ...outcome }) => ({
...outcome,
id: outcomeKey,
value: variants[variant].expression ?? variants[variant].value
}))
}))
};
}
return programs;
}
/**
* Parses the browser-authored mechanic payload without evaluating text as code.
* Every branch is converted to each source variant and validated by the runtime.
* Malformed payloads return errors and never escape into page rendering as throws.
*/
export function parseConditionalMechanicDraft(
raw: unknown,
options: ParseConditionalMechanicDraftOptions
): ConditionalMechanicDraftResult {
const errors: string[] = [];
try {
if (
!Array.isArray(options.variantKeys)
|| !options.variantKeys.length
|| options.variantKeys.some((variant) => !variant.trim())
|| new Set(options.variantKeys).size !== options.variantKeys.length
) {
return { ok: false, errors: ['Variant keys must be unique non-empty strings.'] };
}
const parsed = parseRaw(raw, errors);
if (!object(parsed)) return { ok: false, errors: errors.length ? errors : ['The conditional mechanic must be an object.'] };
if (parsed.version !== CONDITIONAL_MECHANIC_DRAFT_VERSION) {
errors.push(`Conditional mechanic version ${CONDITIONAL_MECHANIC_DRAFT_VERSION} is required.`);
}
if (!['ready', 'requires_assumptions', 'captured_only'].includes(String(parsed.simulationReadiness))) {
errors.push('The conditional mechanic has invalid simulation readiness.');
}
const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors);
const pools = parsePools(parsed.pools, errors);
const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors);
const draft: ConditionalMechanicDraftV1 = {
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
simulationReadiness: parsed.simulationReadiness as ConditionalMechanicDraftV1['simulationReadiness'],
scenarioInputs,
pools,
clauses
};
const inputRequiresAssumption = scenarioInputs.some(
(input) => input.readiness === 'requires_assumption'
);
if (draft.simulationReadiness === 'ready' && inputRequiresAssumption) {
errors.push('Simulation readiness must require assumptions while an input requires an assumption.');
}
if (draft.simulationReadiness !== 'captured_only' && !clauses.length) {
errors.push('A simulation-ready mechanic requires at least one clause.');
}
const duplicate = (values: string[], label: string) => {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) errors.push(`${label} "${value}" is duplicated.`);
seen.add(value);
}
};
duplicate(scenarioInputs.map((input) => input.key), 'Scenario input');
duplicate(pools.map((pool) => pool.key), 'State pool');
duplicate(clauses.map((clause) => clause.key), 'Clause');
for (const clause of clauses) {
duplicate(clause.outcomes.map((outcome) => outcome.key), `Clause "${clause.key}" outcome`);
}
if (errors.length) return { ok: false, errors };
const programs = programsForDraft(draft, options);
for (const [variant, program] of Object.entries(programs)) {
let validation;
try {
validation = validateMechanicProgram(program);
} catch {
errors.push(`Variant "${variant}" contains a structure the mechanic runtime cannot validate.`);
continue;
}
if (!validation.ok) {
errors.push(...validation.errors.map((error) => `Variant "${variant}": ${error}`));
}
}
return errors.length
? { ok: false, errors }
: { ok: true, value: { draft, programs } };
} catch {
return {
ok: false,
errors: ['The conditional mechanic could not be parsed safely.']
};
}
}
export function blankConditionalMechanicDraft(): ConditionalMechanicDraftV1 {
return {
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
};
}
@@ -0,0 +1,420 @@
import { describe, expect, it } from 'vitest';
import {
adaptConditionalEffectProgram,
type ConditionalEffectClauseRow,
type ConditionalEffectOutcomeRow,
type ConditionalEffectRuleRow,
type ConditionalEffectStatePoolRow
} from './mechanic-persistence.js';
const rule = (
overrides: Partial<ConditionalEffectRuleRow> = {}
): ConditionalEffectRuleRow => ({
id: 'rule-1',
source_type: 'talent',
source_id: 'talent-1',
configured: 1,
script_version: 1,
simulation_readiness: 'ready',
scenario_inputs_json: '[]',
...overrides
});
const pool = (
overrides: Partial<ConditionalEffectStatePoolRow> = {}
): ConditionalEffectStatePoolRow => ({
id: 'pool-1',
rule_id: 'rule-1',
state_key: 'effect',
label: 'Effect',
state_kind: 'stack',
state_scope: 'agent',
initial_value: 0,
minimum_value: 0,
maximum_value: 10,
best_case_value: 10,
duration_seconds: 10,
expiry_policy: 'clear_all',
refresh_policy: 'refresh',
parameters_json: '{}',
sort_order: 1,
...overrides
});
const clause = (
overrides: Partial<ConditionalEffectClauseRow> = {}
): ConditionalEffectClauseRow => ({
id: 'clause-1',
rule_id: 'rule-1',
clause_key: 'primary',
label: 'Primary',
trigger_id: 'trigger-1',
parameters_json: JSON.stringify({ hitsRequired: 9, windowSeconds: 3 }),
conditions_json: JSON.stringify({ kind: 'true' }),
reducer_json: JSON.stringify({ kind: 'none' }),
emission_kind: 'each_match',
state_pool_id: 'pool-1',
mutations_json: JSON.stringify([{ kind: 'add', pool: 'effect', value: 1 }]),
cooldown_seconds: null,
output_branch: 'active',
sort_order: 1,
active: 1,
...overrides
});
const outcome = (
overrides: Partial<ConditionalEffectOutcomeRow> = {}
): ConditionalEffectOutcomeRow => ({
id: 'outcome-1',
rule_id: 'rule-1',
clause_id: 'clause-1',
state_pool_id: 'pool-1',
effect_key: 'weapon-damage',
variant_key: 'I',
outcome_key: 'active',
application_kind: 'active_modifier',
effect_scope: 'equipped_weapon',
stat_attribute_id: 'weapon-damage',
value: 3,
value_expression_json: null,
value_basis: 'flat',
value_scaling: 'per_state_unit',
unit: '%',
damage_basis: null,
application_phase: 'while_active',
min_state_value: 1,
max_state_value: null,
description_slot: 1,
sort_order: 1,
...overrides
});
describe('conditional-effect persistence adapter', () => {
it('adapts a migrated simple same-target rule without losing its typed mechanics', () => {
const result = adaptConditionalEffectProgram({
rule: rule(),
statePools: [pool()],
clauses: [clause()],
outcomes: [outcome()],
triggers: [{
id: 'trigger-1',
action_key: 'same_target_hits_within_window',
name: 'Same-target hits'
}]
}, { name: 'Superior Firepower', variantKey: 'I' });
expect(result).toMatchObject({
status: 'READY',
simulationReadiness: 'ready',
errors: []
});
expect(result.program).toMatchObject({
id: 'rule-1',
name: 'Superior Firepower',
pools: [{
key: 'effect',
scope: 'agent',
initial: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 10
}],
clauses: [{
id: 'clause-1',
when: 'weapon_hit',
if: {
kind: 'exists',
value: { source: 'event', path: 'targetId' }
},
reducer: {
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
outcomes: [{
id: 'outcome-1',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
attributeId: 'weapon-damage',
value: 3,
basis: 'flat',
statePool: 'effect',
perStack: true,
minStacks: 1
}]
}]
});
});
it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
const upgradeScale = {
op: 'add',
left: 1,
right: {
op: 'divide',
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
right: 5
}
};
const result = adaptConditionalEffectProgram({
rule: rule({
source_type: 'os_protocol_talent',
source_id: 'happy-survival',
simulation_readiness: 'requires_assumptions',
scenario_inputs_json: JSON.stringify([{
key: 'restorationIntervalSeconds',
label: 'Restoration interval',
kind: 'number',
readiness: 'requires_assumption',
description: 'Enter the verified talent cadence.',
min: 0.01
}])
}),
statePools: [pool({
state_key: 'nearby-enemies',
label: 'Nearby Enemies',
state_kind: 'count',
maximum_value: 20,
best_case_value: 5,
duration_seconds: null
})],
clauses: [
clause({
id: 'sample-nearby',
clause_key: 'sample-nearby',
label: 'Sample nearby enemies',
trigger_id: null,
event_kind: 'proximity_snapshot',
parameters_json: '{}',
reducer_json: JSON.stringify({
kind: 'derived_scalar',
value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } }
}),
mutations_json: JSON.stringify([{
kind: 'derive',
pool: 'nearby-enemies',
value: { ref: { source: 'reducer' } }
}]),
sort_order: 1
}),
clause({
id: 'restore-nearby',
clause_key: 'restore-nearby',
label: 'Restore by nearby enemies',
trigger_id: null,
event_kind: 'tick',
weight_expression_json: JSON.stringify({
op: 'divide',
left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } },
right: 60
}),
parameters_json: '{}',
reducer_json: JSON.stringify({
kind: 'periodic',
intervalSeconds: {
ref: {
source: 'scenario',
key: 'restorationIntervalSeconds'
}
}
}),
mutations_json: JSON.stringify([{ kind: 'none' }]),
state_pool_id: null,
sort_order: 2
}),
clause({
id: 'shot-health-exchange',
clause_key: 'shot-health-exchange',
label: 'Shot health exchange',
trigger_id: null,
event_kind: 'shot_fired',
recipe_key: 'happy-survival-shot',
parameters_json: '{}',
mutations_json: JSON.stringify([{ kind: 'none' }]),
state_pool_id: null,
sort_order: 3
})
],
outcomes: [
outcome({
id: 'restore-health',
clause_id: 'restore-nearby',
state_pool_id: 'pool-1',
effect_key: 'nearby-restoration',
variant_key: 'base',
application_kind: 'restore_health',
effect_scope: 'self',
stat_attribute_id: null,
value: 0.5,
value_expression_json: JSON.stringify({
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'multiply',
left: { ref: { source: 'pool', pool: 'nearby-enemies' } },
right: 0.5
},
min: 0,
max: 2.5
},
right: upgradeScale
}),
value_basis: 'max_health_percent',
resource_kind: 'health',
value_scaling: 'flat',
application_phase: 'after_event',
min_state_value: null,
sort_order: 1
}),
outcome({
id: 'health-cost',
clause_id: 'shot-health-exchange',
state_pool_id: null,
effect_key: 'shot-health-cost',
variant_key: 'base',
application_kind: 'resource_cost',
effect_scope: 'self',
stat_attribute_id: null,
value: 2,
value_basis: 'max_health_percent',
resource_kind: 'health',
value_scaling: 'flat',
application_phase: 'on_event',
min_state_value: null,
sort_order: 2
}),
outcome({
id: 'bonus-damage',
clause_id: 'shot-health-exchange',
state_pool_id: null,
effect_key: 'current-health-bonus-damage',
variant_key: 'base',
application_kind: 'direct_damage',
effect_scope: 'target',
stat_attribute_id: null,
value: 0.6,
value_expression_json: JSON.stringify({
op: 'multiply',
left: 0.6,
right: upgradeScale
}),
value_basis: 'current_health_percent',
combat_timing: 'current',
value_scaling: 'flat',
damage_basis: 'flat',
application_phase: 'on_event',
min_state_value: null,
sort_order: 3
})
],
triggers: []
}, { name: 'Happy Survival', variantKey: 'base' });
expect(result.errors).toEqual([]);
expect(result.status).toBe('REQUIRES_ASSUMPTIONS');
expect(result.program?.scenarioInputs?.[0]).toMatchObject({
key: 'restorationIntervalSeconds',
readiness: 'requires_assumption'
});
expect(result.program?.clauses.map(({ when }) => when)).toEqual([
'proximity_snapshot',
'tick',
'shot_fired'
]);
expect(result.program?.clauses[1].weight).toEqual({
op: 'divide',
left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } },
right: 60
});
expect(result.program?.clauses[2].recipeKey).toBe('happy-survival-shot');
expect(result.program?.clauses[1].reducer).toEqual({
kind: 'periodic',
intervalSeconds: {
ref: {
source: 'scenario',
key: 'restorationIntervalSeconds'
}
}
});
const restoration = result.program?.clauses[1].outcomes?.[0];
const shotOutcomes = result.program?.clauses[2].outcomes;
expect(restoration).toMatchObject({
kind: 'restoration',
phase: 'after_event',
basis: 'max_health_percent',
value: expect.objectContaining({
op: 'multiply',
right: expect.objectContaining({
op: 'add',
right: expect.objectContaining({
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades'
}
}
})
})
})
});
expect(shotOutcomes).toEqual([
expect.objectContaining({
id: 'health-cost',
kind: 'resource_cost',
resource: 'health',
phase: 'on_event',
basis: 'max_health_percent',
value: 2
}),
expect.objectContaining({
id: 'bonus-damage',
kind: 'direct_damage',
phase: 'on_event',
basis: 'current_health_percent',
combatTiming: 'current',
value: expect.objectContaining({
left: 0.6,
right: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades'
}
}
})
})
})
})
]);
});
it('returns INVALID instead of throwing when persisted JSON is malformed', () => {
expect(() => adaptConditionalEffectProgram({
rule: rule({ scenario_inputs_json: '[not-json' }),
statePools: [pool()],
clauses: [clause({ mutations_json: '{broken' })],
outcomes: [outcome()],
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
}, { variantKey: 'I' })).not.toThrow();
const result = adaptConditionalEffectProgram({
rule: rule({ scenario_inputs_json: '[not-json' }),
statePools: [pool()],
clauses: [clause({ mutations_json: '{broken' })],
outcomes: [outcome()],
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
}, { variantKey: 'I' });
expect(result.status).toBe('INVALID');
expect(result.program).toBeNull();
expect(result.errors).toEqual(expect.arrayContaining([
'Rule scenario inputs contains malformed JSON.',
'Clause 1 mutations contains malformed JSON.'
]));
});
});
@@ -0,0 +1,757 @@
import {
MECHANIC_EVENT_KINDS,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicProgram,
type NumericExpression,
type OutcomeBasis,
type OutcomePhase,
type PoolDefinition,
type Predicate,
type Reducer,
type ScenarioInputDefinition
} from './mechanic.js';
type DatabaseValue = string | number | bigint | boolean | null | undefined;
export type ConditionalEffectRuleRow = {
id: DatabaseValue;
source_type: DatabaseValue;
source_id: DatabaseValue;
configured: DatabaseValue;
script_version: DatabaseValue;
simulation_readiness: DatabaseValue;
scenario_inputs_json: DatabaseValue;
};
export type ConditionalEffectStatePoolRow = {
id: DatabaseValue;
rule_id: DatabaseValue;
state_key: DatabaseValue;
label: DatabaseValue;
state_kind: DatabaseValue;
state_scope: DatabaseValue;
initial_value: DatabaseValue;
minimum_value: DatabaseValue;
maximum_value: DatabaseValue;
best_case_value: DatabaseValue;
duration_seconds: DatabaseValue;
expiry_policy: DatabaseValue;
refresh_policy: DatabaseValue;
parameters_json: DatabaseValue;
sort_order: DatabaseValue;
};
export type ConditionalEffectClauseRow = {
id: DatabaseValue;
rule_id: DatabaseValue;
clause_key: DatabaseValue;
label: DatabaseValue;
trigger_id: DatabaseValue;
event_kind?: DatabaseValue;
recipe_key?: DatabaseValue;
parameters_json: DatabaseValue;
conditions_json: DatabaseValue;
reducer_json: DatabaseValue;
weight_expression_json?: DatabaseValue;
emission_kind: DatabaseValue;
state_pool_id: DatabaseValue;
mutations_json: DatabaseValue;
cooldown_seconds: DatabaseValue;
output_branch: DatabaseValue;
sort_order: DatabaseValue;
active: DatabaseValue;
};
export type ConditionalEffectOutcomeRow = {
id: DatabaseValue;
rule_id: DatabaseValue;
clause_id: DatabaseValue;
state_pool_id: DatabaseValue;
effect_key: DatabaseValue;
variant_key: DatabaseValue;
outcome_key: DatabaseValue;
application_kind: DatabaseValue;
effect_scope: DatabaseValue;
stat_attribute_id: DatabaseValue;
value: DatabaseValue;
value_expression_json: DatabaseValue;
value_basis: DatabaseValue;
resource_kind?: DatabaseValue;
combat_timing?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
application_phase: DatabaseValue;
min_state_value: DatabaseValue;
max_state_value: DatabaseValue;
description_slot: DatabaseValue;
sort_order: DatabaseValue;
};
export type TriggerActionMetadataRow = {
id: DatabaseValue;
action_key: DatabaseValue;
event_kind?: DatabaseValue;
name?: DatabaseValue;
};
export type MechanicPersistenceInput = {
rule: ConditionalEffectRuleRow;
statePools: ConditionalEffectStatePoolRow[];
clauses: ConditionalEffectClauseRow[];
outcomes: ConditionalEffectOutcomeRow[];
triggers: TriggerActionMetadataRow[];
};
export type MechanicPersistenceOptions = {
name?: string;
variantKey?: string;
};
export type MechanicPersistenceStatus =
| 'READY'
| 'REQUIRES_ASSUMPTIONS'
| 'CAPTURED_ONLY'
| 'INVALID';
export type MechanicPersistenceResult = {
status: MechanicPersistenceStatus;
simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only' | null;
program: MechanicProgram | null;
errors: string[];
warnings: string[];
};
type ParsedJson = { ok: true; value: unknown } | { ok: false };
const eventKinds = new Set<string>(MECHANIC_EVENT_KINDS);
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const text = (value: DatabaseValue) => typeof value === 'string' ? value.trim() : '';
const nullableText = (value: DatabaseValue) => {
const result = text(value);
return result || null;
};
const number = (value: DatabaseValue) => {
if (typeof value === 'bigint') return Number(value);
if (typeof value === 'boolean' || value === null || value === undefined || value === '') return null;
const result = Number(value);
return Number.isFinite(result) ? result : null;
};
const enabled = (value: DatabaseValue) =>
value === true || value === 1 || value === 1n || value === '1';
function parseJson(value: DatabaseValue, path: string, errors: string[]): ParsedJson {
if (typeof value !== 'string') {
errors.push(`${path} must contain JSON text.`);
return { ok: false };
}
try {
return { ok: true, value: JSON.parse(value) };
} catch {
errors.push(`${path} contains malformed JSON.`);
return { ok: false };
}
}
function requiredText(value: DatabaseValue, path: string, errors: string[]) {
const result = text(value);
if (!result) errors.push(`${path} is required.`);
return result;
}
function requiredNumber(value: DatabaseValue, path: string, errors: string[]) {
const result = number(value);
if (result === null) errors.push(`${path} must be a finite number.`);
return result ?? 0;
}
function isNumericExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) {
if (!object(value.ref) || typeof value.ref.source !== 'string') return false;
const source = value.ref.source;
if (source === 'event' || source === 'combat' || source === 'equipment') {
return typeof value.ref.path === 'string';
}
if (source === 'scenario') return typeof value.ref.key === 'string';
if (source === 'reducer') return true;
if (source === 'pool') return typeof value.ref.pool === 'string';
return false;
}
if (value.op === 'clamp') {
return isNumericExpression(value.value)
&& isNumericExpression(value.min)
&& isNumericExpression(value.max);
}
return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op))
&& isNumericExpression(value.left)
&& isNumericExpression(value.right);
}
function isPredicate(value: unknown): value is Predicate {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'true') return true;
if (value.kind === 'exists') return object(value.value) && typeof value.value.source === 'string';
if (value.kind === 'not') return isPredicate(value.predicate);
if (value.kind === 'all' || value.kind === 'any') {
return Array.isArray(value.predicates) && value.predicates.every(isPredicate);
}
return value.kind === 'compare'
&& ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator))
&& 'left' in value
&& 'right' in value;
}
function isReducer(value: unknown): value is Reducer {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'none') return true;
if (value.kind === 'count') return finite(value.threshold);
if (value.kind === 'modulo') return finite(value.every);
if (value.kind === 'distinct_count') {
return finite(value.threshold)
&& finite(value.windowSeconds)
&& object(value.distinctBy)
&& typeof value.distinctBy.source === 'string';
}
if (value.kind === 'consecutive_count') return finite(value.threshold);
if (value.kind === 'sliding_count') return finite(value.threshold) && finite(value.windowSeconds);
if (value.kind === 'duration') return finite(value.seconds);
if (value.kind === 'periodic') return isNumericExpression(value.intervalSeconds);
return value.kind === 'derived_scalar' && isNumericExpression(value.value);
}
function isMutation(value: unknown): value is ClauseMutation {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'none') return true;
if (value.kind === 'clear') return typeof value.pool === 'string';
if (value.kind === 'transfer') {
return typeof value.pool === 'string' && object(value.from) && object(value.to)
&& (value.value === undefined || value.value === 'all' || isNumericExpression(value.value));
}
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
&& typeof value.pool === 'string'
&& isNumericExpression(value.value);
}
function parseScenarioInputs(
raw: DatabaseValue,
errors: string[]
): ScenarioInputDefinition[] {
const parsed = parseJson(raw, 'Rule scenario inputs', errors);
if (!parsed.ok) return [];
if (!Array.isArray(parsed.value)) {
errors.push('Rule scenario inputs must be a JSON array.');
return [];
}
const inputs: ScenarioInputDefinition[] = [];
for (const [index, candidate] of parsed.value.entries()) {
const path = `Scenario input ${index + 1}`;
if (!object(candidate)) {
errors.push(`${path} must be an object.`);
continue;
}
if (
typeof candidate.key !== 'string'
|| typeof candidate.label !== 'string'
|| !['toggle', 'slider', 'number'].includes(String(candidate.kind))
) {
errors.push(`${path} requires a key, label, and supported input kind.`);
continue;
}
inputs.push(candidate as ScenarioInputDefinition);
}
return inputs;
}
type LegacyMapping = {
when: MechanicEventKind;
predicate?: Predicate;
reducer?: Reducer;
};
function legacyActionMapping(
action: string,
params: Record<string, unknown>,
path: string,
errors: string[]
): LegacyMapping | null {
const eventValue = (field: string) => ({ source: 'event' as const, path: `values.${field}` });
const targetExists: Predicate = {
kind: 'exists',
value: { source: 'event', path: 'targetId' }
};
switch (action) {
case 'same_target_hits_within_window': {
const hits = params.hitsRequired;
const window = params.windowSeconds;
if (!Number.isInteger(hits) || Number(hits) <= 0 || !finite(window) || window <= 0) {
errors.push(`${path} has invalid same-target hit parameters.`);
return null;
}
return {
when: 'weapon_hit',
predicate: targetExists,
reducer: {
kind: 'sliding_count',
threshold: Number(hits),
windowSeconds: window,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' }
}
};
}
case 'headshot':
return {
when: 'weapon_hit',
predicate: {
kind: 'compare',
left: eventValue('headshot'),
operator: 'eq',
right: true
}
};
case 'headshot_kill':
return {
when: 'enemy_killed',
predicate: {
kind: 'compare',
left: eventValue('headshot'),
operator: 'eq',
right: true
}
};
case 'weapon_kill':
return { when: 'enemy_killed' };
case 'target_hit_within_distance': {
const meters = params.meters;
if (!finite(meters) || meters <= 0) {
errors.push(`${path} has an invalid distance parameter.`);
return null;
}
return {
when: 'weapon_hit',
predicate: {
kind: 'all',
predicates: [
targetExists,
{
kind: 'compare',
left: eventValue('distanceMeters'),
operator: 'lte',
right: meters
}
]
}
};
}
case 'damage_taken':
return { when: 'health_damage_taken' };
case 'weapon_swap':
return { when: 'weapon_swapped' };
case 'reload':
return { when: 'reload_started' };
default:
errors.push(
`${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.`
);
return null;
}
}
function combinePredicates(left: Predicate | undefined, right: Predicate | undefined) {
if (!left || left.kind === 'true') return right;
if (!right || right.kind === 'true') return left;
return { kind: 'all' as const, predicates: [left, right] };
}
function resolveClauseMapping(
trigger: TriggerActionMetadataRow | null,
eventKind: DatabaseValue,
parameters: Record<string, unknown>,
explicitPredicate: Predicate,
explicitReducer: Reducer,
path: string,
errors: string[]
) {
const directEvent = nullableText(eventKind)
?? nullableText(trigger?.event_kind)
?? (eventKinds.has(text(trigger?.action_key)) ? text(trigger?.action_key) : null);
if (directEvent) {
return {
when: directEvent as MechanicEventKind,
predicate: explicitPredicate,
reducer: explicitReducer
};
}
if (!trigger) {
errors.push(`${path} requires a direct event or known Action Family recipe.`);
return null;
}
const legacy = legacyActionMapping(text(trigger.action_key), parameters, path, errors);
if (!legacy) return null;
return {
when: legacy.when,
predicate: combinePredicates(legacy.predicate, explicitPredicate),
reducer: explicitReducer.kind === 'none'
? legacy.reducer ?? explicitReducer
: explicitReducer
};
}
function parsePool(
row: ConditionalEffectStatePoolRow,
ruleId: string,
index: number,
errors: string[]
): { id: string; definition: PoolDefinition } | null {
const path = `State pool ${index + 1}`;
if (text(row.rule_id) !== ruleId) {
errors.push(`${path} belongs to a different rule.`);
return null;
}
const id = requiredText(row.id, `${path} ID`, errors);
const key = requiredText(row.state_key, `${path} key`, errors);
const label = requiredText(row.label, `${path} label`, errors);
const scope = text(row.state_scope);
if (scope !== 'agent' && scope !== 'target') errors.push(`${path} has an invalid scope.`);
const initial = requiredNumber(row.initial_value, `${path} initial value`, errors);
const minimum = number(row.minimum_value);
const maximum = requiredNumber(row.maximum_value, `${path} maximum value`, errors);
const bestCase = number(row.best_case_value);
const duration = number(row.duration_seconds);
const parameters = parseJson(row.parameters_json, `${path} parameters`, errors);
if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`);
return {
id,
definition: {
key,
label,
scope: scope === 'target' ? 'target' : 'agent',
initial,
...(minimum === null ? {} : { min: minimum }),
max: maximum,
...(bestCase === null ? {} : { bestCaseValue: bestCase }),
...(duration === null ? {} : { expiresAfterSeconds: duration })
}
};
}
function outcomeKind(value: string): MechanicOutcome['kind'] | null {
if (value === 'active_modifier') return 'attribute_modifier';
if (value === 'direct_damage') return 'direct_damage';
if (value === 'restoration' || ['restore_health', 'restore_armor', 'restore_ammo'].includes(value)) {
return 'restoration';
}
if (value === 'resource_cost') return 'resource_cost';
if (value === 'marker') return 'marker';
return null;
}
function outcomeScope(value: string): MechanicOutcome['scope'] | null {
if (value === 'self') return 'self';
if (value === 'equipped_weapon') return 'equipped_weapon';
if (value === 'target') return 'target';
if (value === 'allies') return 'allies';
if (value === 'self_and_allies') return 'self_and_allies';
if (value === 'assigned_ability') return 'assigned_ability';
if (value === 'all_skills') return 'all_skills';
return null;
}
function parseOutcome(
row: ConditionalEffectOutcomeRow,
ruleId: string,
poolKeys: Map<string, string>,
index: number,
errors: string[]
): { clauseId: string | null; outcome: MechanicOutcome } | null {
const path = `Outcome ${index + 1}`;
if (text(row.rule_id) !== ruleId) {
errors.push(`${path} belongs to a different rule.`);
return null;
}
const id = requiredText(row.id, `${path} ID`, errors);
const label = requiredText(row.effect_key, `${path} effect key`, errors);
const kind = outcomeKind(text(row.application_kind));
if (!kind) errors.push(`${path} has an unsupported application kind.`);
const scope = outcomeScope(text(row.effect_scope));
if (!scope) {
errors.push(`${path} has an effect scope that cannot be represented without losing meaning.`);
}
const basis = text(row.value_basis);
if (!['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'].includes(basis)) {
errors.push(`${path} has an unsupported value basis.`);
}
const phase = text(row.application_phase);
if (!['while_active', 'on_event', 'after_event', 'on_enter', 'on_exit'].includes(phase)) {
errors.push(`${path} has an unsupported application phase.`);
}
let value: NumericExpression = requiredNumber(row.value, `${path} value`, errors);
if (row.value_expression_json !== null && row.value_expression_json !== undefined && row.value_expression_json !== '') {
const parsed = parseJson(row.value_expression_json, `${path} value expression`, errors);
if (parsed.ok) {
if (isNumericExpression(parsed.value)) value = parsed.value;
else errors.push(`${path} value expression is not a valid numeric expression.`);
}
}
const statePoolId = nullableText(row.state_pool_id);
const statePool = statePoolId ? poolKeys.get(statePoolId) : undefined;
if (statePoolId && !statePool) errors.push(`${path} references an unknown state pool.`);
const scaling = text(row.value_scaling);
if (scaling !== 'flat' && scaling !== 'per_state_unit') {
errors.push(`${path} has an unsupported value-scaling mode.`);
}
if (!kind || !scope) return null;
const legacyResource = text(row.application_kind) === 'restore_health'
? 'health'
: text(row.application_kind) === 'restore_armor'
? 'armor'
: text(row.application_kind) === 'restore_ammo'
? 'ammo'
: text(row.application_kind) === 'resource_cost'
? 'health'
: null;
const resource = nullableText(row.resource_kind) ?? legacyResource;
if (
(kind === 'restoration' || kind === 'resource_cost')
&& !['health', 'armor', 'ammo'].includes(String(resource))
) {
errors.push(`${path} requires an explicit health, armor, or ammo resource.`);
}
const combatTiming = nullableText(row.combat_timing);
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
errors.push(`${path} has invalid combat timing.`);
}
return {
clauseId: nullableText(row.clause_id),
outcome: {
id,
label,
phase: phase as OutcomePhase,
kind,
scope,
value,
basis: basis as OutcomeBasis,
...(resource && ['health', 'armor', 'ammo'].includes(resource)
? { resource: resource as 'health' | 'armor' | 'ammo' }
: {}),
...(combatTiming === 'pre_event' || combatTiming === 'current'
? { combatTiming }
: {}),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
...(statePool ? { statePool } : {}),
...(scaling === 'per_state_unit' ? { perStack: true } : {}),
...(number(row.min_state_value) === null ? {} : { minStacks: number(row.min_state_value) as number }),
...(number(row.max_state_value) === null ? {} : { maxStacks: number(row.max_state_value) as number })
}
};
}
/**
* Converts normalized conditional-effect rows into the runtime's typed mechanic
* program. Invalid persisted data is reported as `INVALID`; this function never
* throws for row or JSON validation failures.
*/
export function adaptConditionalEffectProgram(
input: MechanicPersistenceInput,
options: MechanicPersistenceOptions = {}
): MechanicPersistenceResult {
const errors: string[] = [];
const warnings: string[] = [];
const readiness = text(input.rule.simulation_readiness);
const simulationReadiness = ['ready', 'requires_assumptions', 'captured_only'].includes(readiness)
? readiness as MechanicPersistenceResult['simulationReadiness']
: null;
if (!simulationReadiness) errors.push('Rule simulation readiness is invalid.');
const ruleId = requiredText(input.rule.id, 'Rule ID', errors);
const sourceType = requiredText(input.rule.source_type, 'Rule source type', errors);
const sourceId = requiredText(input.rule.source_id, 'Rule source ID', errors);
const scriptVersion = requiredNumber(input.rule.script_version, 'Rule script version', errors);
if (!Number.isInteger(scriptVersion) || scriptVersion <= 0) {
errors.push('Rule script version must be a positive integer.');
}
const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors);
const parsedPools = input.statePools
.slice()
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
.map((row, index) => parsePool(row, ruleId, index, errors))
.filter((value): value is NonNullable<typeof value> => value !== null);
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
const triggerMap = new Map(
input.triggers.map((trigger) => [text(trigger.id), trigger])
);
const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
let selectedVariant = options.variantKey?.trim() || '';
if (!selectedVariant) {
if (variantKeys.length > 1) {
errors.push(`A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`);
} else {
selectedVariant = variantKeys[0] ?? 'base';
}
} else if (variantKeys.length && !variantKeys.includes(selectedVariant)) {
errors.push(`Variant "${selectedVariant}" does not exist for this rule.`);
}
const selectedOutcomes = input.outcomes
.filter((row) => text(row.variant_key) === selectedVariant)
.slice()
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
.map((row, index) => parseOutcome(row, ruleId, poolKeys, index, errors))
.filter((value): value is NonNullable<typeof value> => value !== null);
const outcomesByClause = new Map<string, MechanicOutcome[]>();
const unassignedOutcomes: MechanicOutcome[] = [];
for (const parsed of selectedOutcomes) {
if (!parsed.clauseId) {
unassignedOutcomes.push(parsed.outcome);
continue;
}
const current = outcomesByClause.get(parsed.clauseId) ?? [];
current.push(parsed.outcome);
outcomesByClause.set(parsed.clauseId, current);
}
const activeRows = input.clauses
.filter((row) => enabled(row.active))
.slice()
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0));
if (unassignedOutcomes.length && activeRows.length !== 1) {
errors.push('Outcomes without a clause can only be adapted when the rule has exactly one active clause.');
}
const clauses: MechanicClause[] = [];
for (const [index, row] of activeRows.entries()) {
const path = `Clause ${index + 1}`;
if (text(row.rule_id) !== ruleId) {
errors.push(`${path} belongs to a different rule.`);
continue;
}
const id = requiredText(row.id, `${path} ID`, errors);
const triggerId = nullableText(row.trigger_id);
const directEvent = nullableText(row.event_kind);
const trigger = triggerId ? triggerMap.get(triggerId) ?? null : null;
if (!directEvent && !trigger) {
errors.push(`${path} references unknown trigger "${triggerId}".`);
continue;
}
const parametersJson = parseJson(row.parameters_json, `${path} parameters`, errors);
const conditionsJson = parseJson(row.conditions_json, `${path} conditions`, errors);
const reducerJson = parseJson(row.reducer_json, `${path} reducer`, errors);
const mutationsJson = parseJson(row.mutations_json, `${path} mutations`, errors);
if (!parametersJson.ok || !conditionsJson.ok || !reducerJson.ok || !mutationsJson.ok) continue;
if (!object(parametersJson.value)) {
errors.push(`${path} parameters must be a JSON object.`);
continue;
}
if (!isPredicate(conditionsJson.value)) {
errors.push(`${path} conditions are not a valid predicate.`);
continue;
}
if (!isReducer(reducerJson.value)) {
errors.push(`${path} reducer is invalid.`);
continue;
}
if (!Array.isArray(mutationsJson.value) || !mutationsJson.value.every(isMutation)) {
errors.push(`${path} mutations must be an array of valid state mutations.`);
continue;
}
const mapping = resolveClauseMapping(
trigger,
row.event_kind,
parametersJson.value,
conditionsJson.value,
reducerJson.value,
path,
errors
);
if (!mapping) continue;
const clauseOutcomes = outcomesByClause.get(id) ?? [];
if (activeRows.length === 1) clauseOutcomes.push(...unassignedOutcomes);
let weight: NumericExpression | undefined;
if (
row.weight_expression_json !== null
&& row.weight_expression_json !== undefined
&& row.weight_expression_json !== ''
) {
const parsedWeight = parseJson(row.weight_expression_json, `${path} weight`, errors);
if (parsedWeight.ok) {
if (isNumericExpression(parsedWeight.value)) weight = parsedWeight.value;
else errors.push(`${path} weight is not a valid numeric expression.`);
}
}
const recipeKey = nullableText(row.recipe_key);
clauses.push({
id,
label: text(row.label) || text(row.clause_key) || undefined,
when: mapping.when,
...(recipeKey ? { recipeKey } : {}),
...(mapping.predicate && mapping.predicate.kind !== 'true' ? { if: mapping.predicate } : {}),
reducer: mapping.reducer,
...(number(row.cooldown_seconds) === null
? {}
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
...(weight === undefined ? {} : { weight }),
mutations: mutationsJson.value,
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {})
});
}
for (const clauseId of outcomesByClause.keys()) {
if (!activeRows.some((row) => text(row.id) === clauseId)) {
errors.push(`Outcomes reference missing or inactive clause "${clauseId}".`);
}
}
const program: MechanicProgram = {
id: ruleId,
name: options.name?.trim() || `${sourceType}:${sourceId}`,
...(scenarioInputs.length ? { scenarioInputs } : {}),
...(parsedPools.length ? { pools: parsedPools.map((pool) => pool.definition) } : {}),
clauses
};
if (errors.length) {
return { status: 'INVALID', simulationReadiness, program: null, errors, warnings };
}
try {
const validation = validateMechanicProgram(program);
if (!validation.ok) {
return {
status: 'INVALID',
simulationReadiness,
program: null,
errors: validation.errors,
warnings
};
}
} catch {
return {
status: 'INVALID',
simulationReadiness,
program: null,
errors: ['The persisted mechanic contains a structure that the runtime cannot validate.'],
warnings
};
}
if (!enabled(input.rule.configured) || simulationReadiness === 'captured_only') {
warnings.push('The mechanic is retained for configuration and is not ready for simulation.');
return { status: 'CAPTURED_ONLY', simulationReadiness, program, errors: [], warnings };
}
const requiresAssumptions = simulationReadiness === 'requires_assumptions'
|| scenarioInputs.some((item) => item.readiness === 'requires_assumption');
return {
status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY',
simulationReadiness,
program,
errors: [],
warnings
};
}
@@ -0,0 +1,156 @@
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: {},
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('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: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 },
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 }
])
});
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
});
});
});
+336
View File
@@ -0,0 +1,336 @@
import type {
ConsecutiveHitsParams,
ContinuousFiringParams,
DistanceParams,
DistinctEnemiesParams,
HealthThresholdParams,
SameTargetHitsParams,
TriggerActionKey,
TriggerConfiguration,
TriggerParamsByAction
} from './conditional.js';
import type {
MechanicClause,
MechanicProgram,
PoolDefinition,
Predicate,
ValueReference
} from './mechanic.js';
/** A partial program that an editor can merge into a new mechanic draft. */
export type MechanicRecipeSeed = Pick<MechanicProgram, 'scenarioInputs' | 'pools' | 'clauses'>;
export type SupportedMechanicRecipe = {
status: 'supported';
seed: MechanicRecipeSeed;
};
export type UnsupportedMechanicRecipe = {
status: 'unsupported';
reason: string;
};
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
key: K;
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
};
export type MechanicRecipeRegistry = {
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
};
const eventValue = (field: string): ValueReference => ({ source: 'event', path: `values.${field}` });
const targetExists: Predicate = {
kind: 'exists',
value: { source: 'event', path: 'targetId' }
};
const noMutation = [{ kind: 'none' as const }];
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed });
function directClause(
id: string,
when: MechanicClause['when'],
ifPredicate?: Predicate
): MechanicClause {
return { id, when, if: ifPredicate, reducer: { kind: 'none' }, mutations: noMutation };
}
function firingLifecycleSeed(activePool: string, includeDuration?: number): MechanicRecipeSeed {
const pools: PoolDefinition[] = [{
key: activePool,
label: 'Firing active',
scope: 'agent',
initial: 0,
min: 0,
max: 1
}];
const clauses: MechanicClause[] = [
{
id: 'start-firing',
label: 'Start firing lifecycle',
when: 'firing_started',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: activePool, value: 1 }]
},
{
id: 'stop-firing',
label: 'Stop firing lifecycle',
when: 'firing_stopped',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: activePool }]
},
{
id: 'reload-stops-firing',
label: 'Reload stops firing lifecycle',
when: 'reload_started',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: activePool }]
}
];
if (includeDuration !== undefined) {
clauses.push({
id: 'continuous-firing-duration',
label: 'Reach continuous firing duration',
when: 'tick',
if: {
kind: 'compare',
left: { source: 'pool', pool: activePool },
operator: 'gt',
right: 0
},
reducer: { kind: 'duration', seconds: includeDuration },
mutations: noMutation
});
}
return { pools, clauses };
}
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
key: `health-band-${index + 1}`,
label: `Health band: ${band.outcomeKey}`,
scope: 'agent',
initial: 0,
min: 0,
max: 1
}));
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
const predicates: Predicate[] = [{
kind: 'compare',
left: eventValue('healthPercent'),
operator: 'lte',
right: band.thresholdPercent
}];
if (lowerBound !== undefined) {
predicates.push({
kind: 'compare',
left: eventValue('healthPercent'),
operator: 'gt',
right: lowerBound
});
}
return {
id: `select-health-band-${index + 1}`,
label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`,
when: 'health_damage_taken',
if: { kind: 'all', predicates },
reducer: { kind: 'none' },
mutations: pools.map((pool, poolIndex) =>
poolIndex === index
? { kind: 'set' as const, pool: pool.key, value: 1 }
: { kind: 'clear' as const, pool: pool.key }
)
};
});
clauses.push({
id: 'clear-health-bands',
label: 'Clear health bands above the highest threshold',
when: 'health_damage_taken',
if: {
kind: 'compare',
left: eventValue('healthPercent'),
operator: 'gt',
right: sortedBands.at(-1)?.thresholdPercent ?? 0
},
reducer: { kind: 'none' },
mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key }))
});
return { pools, clauses };
}
function proximityLifecycleSeed(
pool: PoolDefinition,
activePredicate: Predicate,
label: string
): MechanicRecipeSeed {
return {
pools: [pool],
clauses: [
{
id: `enter-${pool.key}`,
label: `Enter ${label}`,
when: 'proximity_snapshot',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
},
{
id: `exit-${pool.key}`,
label: `Exit ${label}`,
when: 'proximity_snapshot',
if: { kind: 'not', predicate: activePredicate },
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: pool.key }]
}
]
};
}
export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
same_target_hits_within_window: {
key: 'same_target_hits_within_window',
createSeed: (params) => {
const value = params as SameTargetHitsParams;
return supported({ clauses: [{
id: 'same-target-hit-window',
when: 'weapon_hit',
if: targetExists,
reducer: {
kind: 'sliding_count', threshold: value.hitsRequired, windowSeconds: value.windowSeconds,
reset: 'zero', groupBy: { source: 'event', path: 'targetId' }
},
mutations: noMutation
}] });
}
},
distinct_enemies_within_window: {
key: 'distinct_enemies_within_window',
createSeed: (params) => {
const value = params as DistinctEnemiesParams;
return supported({ clauses: [{
id: 'distinct-enemy-hit-window', when: 'weapon_hit', if: targetExists,
reducer: {
kind: 'distinct_count', threshold: value.enemiesRequired, windowSeconds: value.windowSeconds,
distinctBy: { source: 'event', path: 'targetId' }
},
mutations: noMutation
}] });
}
},
consecutive_same_target_hits: {
key: 'consecutive_same_target_hits',
createSeed: (params) => {
const value = params as ConsecutiveHitsParams;
return supported({ clauses: [{
id: 'consecutive-same-target-hits', when: 'weapon_hit', if: targetExists,
reducer: {
kind: 'consecutive_count', threshold: value.hitsRequired,
groupBy: { source: 'event', path: 'targetId' }
},
mutations: noMutation
}] });
}
},
continuous_firing_duration: {
key: 'continuous_firing_duration',
createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds))
},
while_firing: {
key: 'while_firing',
createSeed: (_params) => supported(firingLifecycleSeed('firing-active'))
},
health_threshold_bands: {
key: 'health_threshold_bands',
createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams))
},
headshot: {
key: 'headshot',
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
})] })
},
headshot_kill: {
key: 'headshot_kill',
createSeed: (_params) => supported({ clauses: [directClause('headshot-kill', 'enemy_killed', {
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
})] })
},
weapon_kill: {
key: 'weapon_kill',
createSeed: (_params) => supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] })
},
target_hit_within_distance: {
key: 'target_hit_within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported({ clauses: [directClause('target-hit-within-distance', 'weapon_hit', {
kind: 'all', predicates: [targetExists, {
kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters
}]
})] });
}
},
damage_taken: {
key: 'damage_taken',
createSeed: (_params) => supported({ clauses: [directClause('damage-taken', 'health_damage_taken')] })
},
weapon_swap: {
key: 'weapon_swap',
createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] })
},
reload: {
key: 'reload',
createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] })
},
within_distance: {
key: 'within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported(proximityLifecycleSeed(
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
{ kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
`target within ${value.meters}m`
));
}
},
extra_health_active: {
key: 'extra_health_active',
createSeed: (_params) => supported(proximityLifecycleSeed(
{ 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 active'
))
}
};
/**
* Creates an editable mechanic fragment for parameters that have already been
* validated by the trigger family registry.
*/
export function createMechanicRecipe<K extends TriggerActionKey>(
action: K,
params: TriggerParamsByAction[K]
): MechanicRecipeResult {
const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition<K>;
const result = definition.createSeed(params);
if (result.status === 'unsupported') return result;
return {
status: 'supported',
seed: {
...result.seed,
clauses: result.seed.clauses.map((clause) => ({
...clause,
recipeKey: action
}))
}
};
}
export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult {
return createMechanicRecipe(trigger.action, trigger.params);
}
+903
View File
@@ -0,0 +1,903 @@
import { describe, expect, it } from 'vitest';
import {
evaluateMechanicProgram,
formatMechanicProgram,
validateMechanicProgram,
type MechanicEvent,
type MechanicProgram
} from './mechanic.js';
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
mode: 'custom' as const,
encounterSeconds,
inputs
});
describe('structured mechanic runtime', () => {
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
const superiorFirepower: MechanicProgram = {
id: 'superior-firepower',
name: 'Superior Firepower',
pools: [{
key: 'relentless',
label: 'Relentless',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
expiresAfterSeconds: 10
}],
clauses: [{
id: 'build-relentless',
label: 'Build Relentless',
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
groupBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'add', pool: 'relentless', value: 1 }],
outcomes: [
{
id: 'relentless-weapon-damage',
label: 'Weapon Damage',
phase: 'while_active',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'equipped_weapon',
attributeId: 'weapon-damage',
value: 3,
statePool: 'relentless',
perStack: true
},
{
id: 'relentless-critical-damage',
label: 'Critical Damage',
phase: 'while_active',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'equipped_weapon',
attributeId: 'critical-damage',
value: 1,
statePool: 'relentless',
perStack: true
}
]
}]
};
const events: MechanicEvent[] = Array.from({ length: 18 }, (_, index) => ({
kind: 'weapon_hit',
at: index * 0.2,
targetId: 'enemy-a'
}));
const result = evaluateMechanicProgram(superiorFirepower, events, custom(15));
expect(result.status).toBe('OK');
expect(result.emissions[0].at).toBeCloseTo(1.6);
expect(result.emissions[1].at).toBeCloseTo(3.4);
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'relentless',
value: 0,
expiresAt: null
}));
expect(result.outcomes.every((outcome) => outcome.attributeId !== null)).toBe(true);
expect(result.outcomes.map((outcome) => outcome.value)).toEqual([3, 1, 6, 2]);
expect(result.script).toContain('COUNT 9 MATCHES WITHIN 3s PER EVENT.targetId');
expect(result.script).toContain('ADD 1 TO relentless');
const maximum = evaluateMechanicProgram(superiorFirepower, [], {
mode: 'maximum',
encounterSeconds: 15
});
expect(maximum.finalState[0].value).toBe(10);
expect(maximum.outcomes.map((outcome) => outcome.value)).toEqual([30, 10]);
});
it('supports every-N-shot reducers without a bespoke action family', () => {
const program: MechanicProgram = {
id: 'every-three-shots',
name: 'Every Three Shots',
pools: [{ key: 'charges', label: 'Charges', scope: 'agent', initial: 0, max: 10 }],
clauses: [{
id: 'charge',
when: 'shot_fired',
reducer: { kind: 'modulo', every: 3 },
mutations: [{ kind: 'add', pool: 'charges', value: 1 }]
}]
};
const events = Array.from({ length: 7 }, (_, index): MechanicEvent => ({
kind: 'shot_fired',
at: index
}));
const result = evaluateMechanicProgram(program, events, custom(10));
expect(result.emissions.map((emission) => emission.at)).toEqual([2, 5]);
expect(result.finalState[0].value).toBe(2);
});
it('supports duration and periodic reducers over deterministic timeline snapshots', () => {
const program: MechanicProgram = {
id: 'timed-movement',
name: 'Timed Movement',
pools: [
{ key: 'settled', label: 'Settled', scope: 'agent', initial: 0, max: 1 },
{ key: 'tempo', label: 'Tempo', scope: 'agent', initial: 0, max: 10 }
],
clauses: [
{
id: 'immobile-duration',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'immobile'
},
reducer: { kind: 'duration', seconds: 3 },
mutations: [{ kind: 'set', pool: 'settled', value: 1 }]
},
{
id: 'moving-periodic',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'moving'
},
reducer: { kind: 'periodic', intervalSeconds: 2 },
mutations: [{ kind: 'add', pool: 'tempo', value: 1 }]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'movement_snapshot', at: 0, values: { state: 'immobile' } },
{ kind: 'movement_snapshot', at: 2, values: { state: 'immobile' } },
{ kind: 'movement_snapshot', at: 3, values: { state: 'immobile' } },
{ kind: 'movement_snapshot', at: 5, values: { state: 'moving' } },
{ kind: 'movement_snapshot', at: 7, values: { state: 'moving' } },
{ kind: 'movement_snapshot', at: 9, values: { state: 'moving' } }
], custom(12));
expect(result.emissions.map(({ clauseId, at }) => ({ clauseId, at }))).toEqual([
{ clauseId: 'immobile-duration', at: 3 },
{ clauseId: 'moving-periodic', at: 7 },
{ clauseId: 'moving-periodic', at: 9 }
]);
expect(result.finalState).toEqual([
expect.objectContaining({ pool: 'settled', value: 1 }),
expect.objectContaining({ pool: 'tempo', value: 2 })
]);
});
it('emits outcomes in the declared event and state-transition phases', () => {
const program: MechanicProgram = {
id: 'phases',
name: 'Outcome Phases',
pools: [{ key: 'active', label: 'Active', scope: 'agent', initial: 0, max: 1 }],
clauses: [
{
id: 'activate',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
outcomes: [
{
id: 'triggering-hit',
label: 'Triggering Hit',
phase: 'on_event',
kind: 'marker',
basis: 'flat',
scope: 'equipped_weapon',
value: 1
},
{
id: 'entered',
label: 'Entered',
phase: 'on_enter',
kind: 'marker',
basis: 'flat',
scope: 'self',
statePool: 'active',
value: 1
},
{
id: 'active-modifier',
label: 'Active Modifier',
phase: 'while_active',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'equipped_weapon',
attributeId: 'weapon-damage',
statePool: 'active',
value: 10
}
]
},
{
id: 'deactivate',
when: 'firing_stopped',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: 'active' }],
outcomes: [{
id: 'exited',
label: 'Exited',
phase: 'on_exit',
kind: 'marker',
basis: 'flat',
scope: 'self',
statePool: 'active',
value: 1
}]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' },
{ kind: 'firing_stopped', at: 2 }
], custom());
expect(result.outcomes.map((outcome) => `${outcome.outcomeId}:${outcome.phase}`)).toEqual([
'triggering-hit:on_event',
'entered:on_enter',
'active-modifier:while_active',
'exited:on_exit'
]);
});
it('emits on-exit outcomes at pool expiry and applies their resource side effects', () => {
const program: MechanicProgram = {
id: 'expiry-exit',
name: 'Expiry Exit',
pools: [{
key: 'active',
label: 'Active',
scope: 'agent',
initial: 0,
max: 1,
expiresAfterSeconds: 2
}],
clauses: [{
id: 'activate',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
outcomes: [{
id: 'expiry-health-cost',
label: 'Expiry Health Cost',
phase: 'on_exit',
kind: 'resource_cost',
scope: 'self',
resource: 'health',
basis: 'max_health_percent',
statePool: 'active',
value: 10
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }],
custom(10),
{ maxHealth: 100, currentHealth: 100 }
);
expect(result.outcomes).toMatchObject([{
outcomeId: 'expiry-health-cost',
phase: 'on_exit',
at: 3,
appliedValue: 10,
combatBefore: { currentHealth: 100 },
combatAfter: { currentHealth: 90 }
}]);
expect(result.finalCombat?.currentHealth).toBe(90);
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'active',
value: 0,
expiresAt: null
}));
});
it('uses a custom percentage as a weighted estimate and resolves 100% in maximum mode', () => {
const program: MechanicProgram = {
id: 'full-health-hit',
name: 'Full Health Hit',
pools: [{
key: 'estimated-procs',
label: 'Estimated Procs',
scope: 'agent',
initial: 0
}],
scenarioInputs: [{
key: 'fullHealthHitPercent',
label: 'Hits received at full Health',
kind: 'slider',
defaultValue: 25,
bestCaseValue: 100,
min: 0,
max: 100
}],
clauses: [{
id: 'estimate-full-health-hits',
when: 'agent_hit',
reducer: { kind: 'none' },
weight: {
op: 'divide',
left: { ref: { source: 'scenario', key: 'fullHealthHitPercent' } },
right: 100
},
mutations: [{ kind: 'add', pool: 'estimated-procs', value: 1 }]
}]
};
const events: MechanicEvent[] = [0, 5, 10, 15].map((at) => ({ kind: 'agent_hit', at }));
const estimate = evaluateMechanicProgram(program, events, custom(20, { fullHealthHitPercent: 25 }));
expect(estimate.estimatedActivationCount).toBe(1);
expect(estimate.emissions.every((emission) => emission.weight === 0.25)).toBe(true);
expect(estimate.emissions.map((emission) => emission.mutations[0].after)).toEqual([
0.25,
0.5,
0.75,
1
]);
expect(estimate.finalState[0].value).toBe(1);
const maximum = evaluateMechanicProgram(program, events, { mode: 'maximum', encounterSeconds: 20 });
expect(maximum.estimatedActivationCount).toBe(4);
expect(maximum.scenarioInputs.fullHealthHitPercent).toBe(100);
expect(maximum.finalState[0].value).toBe(4);
});
it('derives proximity stacks directly from a builder-controlled snapshot', () => {
const program: MechanicProgram = {
id: 'nearby-allies',
name: 'Nearby Allies',
pools: [{
key: 'nearby-ally-stacks',
label: 'Nearby Ally Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 3,
bestCaseValue: 3
}],
clauses: [{
id: 'derive-nearby-allies',
when: 'proximity_snapshot',
reducer: {
kind: 'derived_scalar',
value: { ref: { source: 'event', path: 'values.alliesWithinRadius' } }
},
mutations: [{
kind: 'derive',
pool: 'nearby-ally-stacks',
value: { ref: { source: 'reducer' } }
}]
}]
};
const result = evaluateMechanicProgram(program, [{
kind: 'proximity_snapshot',
at: 0,
values: { alliesWithinRadius: 2 }
}], custom());
expect(result.finalState[0].value).toBe(2);
const maximum = evaluateMechanicProgram(program, [], { mode: 'maximum', encounterSeconds: 30 });
expect(maximum.finalState[0].value).toBe(3);
});
it('shares one movement charge pool across generation and consumption clauses', () => {
const program: MechanicProgram = {
id: 'mobile-charge',
name: 'Mobile Charge',
pools: [{
key: 'mobile-charge',
label: 'Mobile Charge',
scope: 'agent',
initial: 0,
min: 0,
max: 20
}],
clauses: [
{
id: 'gain-moving',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'moving'
},
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'mobile-charge', value: 1 }]
},
{
id: 'gain-sprinting',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'sprinting'
},
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'mobile-charge', value: 2 }]
},
{
id: 'spend-on-shot',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'mobile-charge' },
operator: 'gt',
right: 0
},
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 1 }]
},
{
id: 'spend-on-skill',
when: 'skill_deployed',
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 10 }]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'movement_snapshot', at: 0, values: { state: 'moving' } },
{ kind: 'movement_snapshot', at: 1, values: { state: 'sprinting' } },
{ kind: 'movement_snapshot', at: 2, values: { state: 'sprinting' } },
{ kind: 'shot_fired', at: 3 },
{ kind: 'skill_deployed', at: 4 }
], custom());
expect(result.finalState[0].value).toBe(0);
expect(result.emissions.map((emission) => emission.clauseId)).toEqual([
'gain-moving',
'gain-sprinting',
'gain-sprinting',
'spend-on-shot',
'spend-on-skill'
]);
});
it('transfers a target-scoped mark to the target named by the death event', () => {
const program: MechanicProgram = {
id: 'transferring-mark',
name: 'Transferring Mark',
pools: [{
key: 'mark',
label: 'Mark',
scope: 'target',
initial: 0,
min: 0,
max: 1
}],
clauses: [
{
id: 'apply-mark',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{
kind: 'set',
pool: 'mark',
entity: { kind: 'event_target' },
value: 1
}]
},
{
id: 'transfer-mark',
when: 'enemy_killed',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'mark', entity: { kind: 'event_target' } },
operator: 'gt',
right: 0
},
reducer: { kind: 'none' },
mutations: [{
kind: 'transfer',
pool: 'mark',
from: { kind: 'event_target' },
to: { kind: 'event_field', path: 'values.transferTargetId' },
value: 'all'
}]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' },
{
kind: 'enemy_killed',
at: 2,
targetId: 'enemy-a',
values: { transferTargetId: 'enemy-b' }
}
], custom());
expect(result.finalState).toEqual([
expect.objectContaining({ entityId: 'enemy-a', value: 0 }),
expect.objectContaining({ entityId: 'enemy-b', value: 1 })
]);
});
it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => {
const upgradeScale = {
op: 'add' as const,
left: 1,
right: {
op: 'divide' as const,
left: { ref: { source: 'equipment' as const, path: 'protocolTalentUpgrades' } },
right: 5
}
};
const program: MechanicProgram = {
id: 'os-protocol-health-engine',
name: 'OS Protocol Health Engine',
scenarioInputs: [{
key: 'restorationIntervalSeconds',
label: 'Restoration interval',
description: 'The game cadence is not yet verified; Custom Scenario must supply it.',
kind: 'number',
readiness: 'requires_assumption',
min: 0.01
}],
pools: [{
key: 'nearby-enemies',
label: 'Nearby Enemies',
scope: 'agent',
initial: 0,
min: 0,
max: 20
}],
clauses: [
{
id: 'sample-nearby-enemies',
when: 'proximity_snapshot',
reducer: {
kind: 'derived_scalar',
value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } }
},
mutations: [{
kind: 'derive',
pool: 'nearby-enemies',
value: { ref: { source: 'reducer' } }
}]
},
{
id: 'restore-nearby-enemies',
when: 'tick',
reducer: {
kind: 'periodic',
intervalSeconds: {
ref: {
source: 'scenario',
key: 'restorationIntervalSeconds'
}
}
},
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'nearby-restoration',
label: 'Nearby Enemy Restoration',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'max_health_percent',
value: {
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'multiply',
left: { ref: { source: 'pool', pool: 'nearby-enemies' } },
right: 0.5
},
min: 0,
max: 2.5
},
right: upgradeScale
}
}]
},
{
id: 'shot-health-exchange',
when: 'shot_fired',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'shot-health-cost',
label: 'Shot Health Cost',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
resource: 'health',
basis: 'max_health_percent',
value: 2
},
{
id: 'shot-health-damage',
label: 'Current Health Bonus Damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
basis: 'current_health_percent',
combatTiming: 'current',
value: {
op: 'multiply',
left: 0.6,
right: upgradeScale
}
}
]
}
]
};
const events: MechanicEvent[] = [
{ kind: 'tick', at: 0 },
{ kind: 'shot_fired', at: 1 },
{ kind: 'proximity_snapshot', at: 2, values: { enemiesWithinRadius: 9 } },
{ kind: 'tick', at: 3 }
];
const result = evaluateMechanicProgram(
program,
events,
custom(10, { restorationIntervalSeconds: 3 }),
{ maxHealth: 10_000, currentHealth: 10_000, weaponBaseDamage: 500 },
{ protocolTalentUpgrades: 5 }
);
expect(result.status).toBe('OK');
const cost = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-cost');
const damage = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage');
const restoration = result.outcomes.find((outcome) => outcome.outcomeId === 'nearby-restoration');
expect(cost).toMatchObject({
value: 2,
resolvedValue: 200,
appliedValue: 200
});
expect(damage).toMatchObject({
value: 1.2,
resolvedValue: 117.6,
appliedValue: 117.6
});
expect(restoration).toMatchObject({
value: 5,
resolvedValue: 500,
appliedValue: 200
});
expect(result.finalCombat?.currentHealth).toBe(10_000);
expect(result.equipment?.protocolTalentUpgrades).toBe(5);
const preCostProgram = structuredClone(program);
const preCostDamage = preCostProgram.clauses[2].outcomes?.find(
(outcome) => outcome.id === 'shot-health-damage'
);
if (preCostDamage) preCostDamage.combatTiming = 'pre_event';
const preCost = evaluateMechanicProgram(
preCostProgram,
[{ kind: 'shot_fired', at: 1 }],
custom(10, { restorationIntervalSeconds: 3 }),
{ maxHealth: 10_000, currentHealth: 10_000 },
{ protocolTalentUpgrades: 5 }
);
expect(preCost.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage'))
.toMatchObject({ resolvedValue: 120 });
const unresolved = evaluateMechanicProgram(
program,
[],
{ mode: 'maximum', encounterSeconds: 10 },
{ maxHealth: 10_000, currentHealth: 10_000 },
{ protocolTalentUpgrades: 5 }
);
expect(unresolved.readiness).toEqual({
status: 'REQUIRES_ASSUMPTIONS',
missingInputs: ['restorationIntervalSeconds']
});
});
it('counts distinct values in a rolling window, resets consecutive runs, and applies named resources', () => {
const program: MechanicProgram = {
id: 'runtime-gaps',
name: 'Runtime gaps',
clauses: [
{
id: 'distinct-targets',
recipeKey: 'distinct-enemies-within-window',
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'distinct_count',
threshold: 3,
windowSeconds: 2,
distinctBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'distinct-marker',
label: 'Distinct target threshold',
phase: 'after_event',
kind: 'marker',
scope: 'all_skills',
basis: 'flat',
value: 1
}]
},
{
id: 'consecutive-targets',
when: 'shot_fired',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'consecutive_count',
threshold: 3,
groupBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'consecutive-marker',
label: 'Consecutive target threshold',
phase: 'after_event',
kind: 'marker',
scope: 'assigned_ability',
basis: 'flat',
value: 1
}]
},
{
id: 'resources',
when: 'tick',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'health-cost', label: 'Health cost', phase: 'on_event', kind: 'resource_cost',
scope: 'self', resource: 'health', basis: 'flat', value: 10
},
{
id: 'armor-restore', label: 'Armor restore', phase: 'on_event', kind: 'restoration',
scope: 'self_and_allies', resource: 'armor', basis: 'flat', value: 20
},
{
id: 'ammo-restore', label: 'Ammo restore', phase: 'on_event', kind: 'restoration',
scope: 'equipped_weapon', resource: 'ammo', basis: 'flat', value: 5
}
]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 0, targetId: 'alpha' },
{ kind: 'weapon_hit', at: 0.2, targetId: 'alpha' },
{ kind: 'weapon_hit', at: 0.4, targetId: 'bravo' },
{ kind: 'weapon_hit', at: 0.6, targetId: 'charlie' },
{ kind: 'shot_fired', at: 1, targetId: 'alpha' },
{ kind: 'shot_fired', at: 1.1, targetId: 'alpha' },
{ kind: 'shot_fired', at: 1.2 },
{ kind: 'shot_fired', at: 1.3, targetId: 'bravo' },
{ kind: 'shot_fired', at: 1.4, targetId: 'bravo' },
{ kind: 'shot_fired', at: 1.5, targetId: 'bravo' },
{ kind: 'tick', at: 2 }
], custom(), { maxHealth: 100, currentHealth: 50, maxArmor: 50, currentArmor: 30, maxAmmo: 10, currentAmmo: 7 });
expect(result.status).toBe('OK');
expect(result.emissions.filter(({ clauseId }) => clauseId === 'distinct-targets')).toMatchObject([{ at: 0.6, reducerValue: 3 }]);
expect(result.emissions.filter(({ clauseId }) => clauseId === 'consecutive-targets')).toMatchObject([{ at: 1.5, reducerValue: 3 }]);
expect(result.outcomes.filter(({ clauseId }) => clauseId === 'resources')).toMatchObject([
{ outcomeId: 'health-cost', resource: 'health', appliedValue: 10 },
{ outcomeId: 'armor-restore', resource: 'armor', appliedValue: 20 },
{ outcomeId: 'ammo-restore', resource: 'ammo', appliedValue: 3 }
]);
expect(result.finalCombat).toMatchObject({ currentHealth: 40, currentArmor: 50, currentAmmo: 10 });
expect(result.script).toContain('COUNT 3 DISTINCT EVENT.targetId WITHIN 2s');
expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId');
expect(result.script).toContain('[RECIPE distinct-enemies-within-window]');
});
});
describe('mechanic validation and pseudo-script', () => {
it('enforces clause cooldowns without counting events received during the cooldown', () => {
const program: MechanicProgram = {
id: 'cooldown',
name: 'Cooldown',
clauses: [{
id: 'shot-proc',
when: 'shot_fired',
reducer: { kind: 'none' },
cooldownSeconds: 2,
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'proc',
label: 'Proc',
phase: 'after_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1
}]
}]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'shot_fired', at: 0 },
{ kind: 'shot_fired', at: 1 },
{ kind: 'shot_fired', at: 2 },
{ kind: 'shot_fired', at: 3 }
], custom(4));
expect(result.emissions.map(({ at }) => at)).toEqual([0, 2]);
expect(result.outcomes.map(({ at }) => at)).toEqual([0, 2]);
expect(result.script).toContain('COOLDOWN 2s');
});
it('rejects invalid references, reducers, and target transfer scopes', () => {
const invalid: MechanicProgram = {
id: 'invalid',
name: 'Invalid',
pools: [{ key: 'agent-state', label: 'Agent State', scope: 'agent', initial: 0 }],
clauses: [{
id: 'bad',
recipeKey: '',
when: 'shot_fired',
reducer: { kind: 'modulo', every: 0 },
mutations: [{
kind: 'transfer',
pool: 'agent-state',
from: { kind: 'agent' },
to: { kind: 'event_target' }
}],
outcomes: [{
id: 'missing-attribute',
label: 'Missing',
phase: 'after_event',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'self',
value: { ref: { source: 'scenario', key: 'missing-input' } }
}, {
id: 'missing-resource',
label: 'Missing resource',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
value: 1
}]
}]
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain('Clause 1 modulo interval must be a positive integer.');
expect(result.errors).toContain('Clause 1 recipe key cannot be empty.');
expect(result.errors).toContain('Clause 1 mutation 1 can only transfer a target-scoped pool.');
expect(result.errors).toContain('Clause 1 outcome 1 requires an Attribute ID.');
expect(result.errors).toContain('Clause 1 outcome 1 value references unknown scenario input "missing-input".');
expect(result.errors).toContain('Clause 1 outcome 2 requires a resource.');
}
});
it('formats nested conditions and state operations deterministically', () => {
const program: MechanicProgram = {
id: 'format',
name: 'Prepared',
pools: [{ key: 'prepared', label: 'Prepared', scope: 'agent', initial: 0, max: 10 }],
clauses: [{
id: 'gain',
label: 'Gain Prepared',
when: 'agent_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.healthPercentBefore' },
operator: 'eq',
right: 100
},
{ kind: 'not', predicate: { kind: 'exists', value: { source: 'event', path: 'values.blocked' } } }
]
},
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'prepared', value: 1 }]
}]
};
expect(formatMechanicProgram(program)).toBe([
'MECHANIC PREPARED',
'STATE prepared [AGENT] 0..10',
'',
'WHEN AGENT HIT // Gain Prepared',
'IF (EVENT.values.healthPercentBefore == 100) AND (NOT (EXISTS EVENT.values.blocked))',
'ADD 1 TO prepared'
].join('\n'));
});
});
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
import { describe, expect, it } from 'vitest';
import {
calculateSkillDps,
type SkillAbilityInput,
type SkillCalculationInput
} from './skill.js';
const directAbility = (
overrides: Partial<SkillAbilityInput> = {}
): SkillAbilityInput => ({
id: 'seeker',
baseDamage: 100,
damageType: 'blast',
delivery: { type: 'direct', hits: 2 },
castSeconds: 0,
cooldownSeconds: 5,
targetCap: 3,
chargePolicy: { type: 'none' },
activation: { mode: 'on_cooldown' },
...overrides
});
const base = (overrides: Partial<SkillCalculationInput> = {}): SkillCalculationInput => ({
rules: {
revisionId: 'skill-rules-1',
bindings: [
{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' },
{ attributeId: 'criticalChance', stage: 'critical_chance', operator: 'add' },
{ attributeId: 'criticalDamage', stage: 'critical_damage', operator: 'add' },
{ attributeId: 'multishotChance', stage: 'multishot_chance', operator: 'add' },
{ attributeId: 'multishotDamage', stage: 'multishot_damage', operator: 'add' }
]
},
abilities: [directAbility()],
attributes: {
skillDamage: 20,
criticalChance: 25,
criticalDamage: 50,
multishotChance: 20,
multishotDamage: 100
},
assumptions: { encounterSeconds: 10, targetCount: 5 },
...overrides
});
describe('calculateSkillDps', () => {
it('calculates critical and multishot expected damage without a headshot model', () => {
const result = calculateSkillDps(base());
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
const ability = result.abilities[0];
expect(result.revisionId).toBe('skill-rules-1');
expect(ability.activationCount).toBe(2);
expect(ability.damageEventCount).toBe(4);
expect(ability.effectiveTargets).toBe(3);
expect(ability.damage.averageExpectedDamagePerHit).toBe(162);
expect(ability.damage.singleTargetDamagePerActivation).toBe(324);
expect(ability.damage.singleTargetEncounterDamage).toBe(648);
expect(ability.damage.aggregateEncounterDamage).toBe(1944);
expect(ability.damage.singleTargetDps).toBe(64.8);
expect(ability.damage.aggregateDps).toBe(194.4);
});
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
id: 'turret',
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
cooldownSeconds: 100,
targetCap: 1
})],
attributes: {},
rules: {
revisionId: 'dynamic-ticks',
bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }]
},
modifierWindows: [{
startSeconds: 2,
endSeconds: 4,
attributes: { skillDamage: 100 },
abilityIds: ['turret']
}],
assumptions: { encounterSeconds: 5, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150);
});
it('supports multiple delivered hits on each periodic tick', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
id: 'multi-hit-dot',
delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 },
cooldownSeconds: 100,
targetCap: 1
})],
attributes: {},
rules: { revisionId: 'periodic-hits', bindings: [] },
assumptions: { encounterSeconds: 5, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].damageEventCount).toBe(6);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
});
it('applies published cooldown recovery and duration stages to cadence', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
id: 'cadence',
delivery: {
type: 'periodic',
ticks: 2,
hitsPerTick: 1,
intervalSeconds: 1,
firstTickDelaySeconds: 1
},
cooldownSeconds: 10,
targetCap: 1
})],
attributes: { recovery: 100, duration: 100 },
rules: {
revisionId: 'skill-cadence',
bindings: [
{ attributeId: 'recovery', stage: 'cooldown_recovery', operator: 'add' },
{ attributeId: 'duration', stage: 'duration', operator: 'add' }
]
},
assumptions: { encounterSeconds: 11, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].activationCount).toBe(3);
expect(result.abilities[0].damageEventCount).toBe(8);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800);
});
it('supports custom uses per minute and clips damage events at encounter end', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
cooldownSeconds: 0,
targetCap: 1,
activation: { mode: 'custom_per_minute', usesPerMinute: 4 }
})],
attributes: {},
rules: { revisionId: 'custom-rate', bindings: [] },
assumptions: { encounterSeconds: 31, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].activationCount).toBe(3);
expect(result.abilities[0].damageEventCount).toBe(7);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700);
});
it('models independently recharging charges and full-set recharge differently', () => {
const shared = {
delivery: { type: 'direct', hits: 1 } as const,
castSeconds: 2,
cooldownSeconds: 10,
targetCap: 1,
activation: { mode: 'on_cooldown' } as const
};
const result = calculateSkillDps(base({
abilities: [
directAbility({
...shared,
id: 'independent',
chargePolicy: { type: 'independent', charges: 3 }
}),
directAbility({
...shared,
id: 'full-set',
chargePolicy: { type: 'full_set', charges: 3 }
})
],
attributes: {},
rules: { revisionId: 'charges', bindings: [] },
assumptions: { encounterSeconds: 13, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities.find((item) => item.abilityId === 'independent')?.activationCount).toBe(5);
expect(result.abilities.find((item) => item.abilityId === 'full-set')?.activationCount).toBe(3);
});
it('combines every enabled ability and excludes disabled abilities', () => {
const result = calculateSkillDps(base({
abilities: [
directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }),
directAbility({ id: 'two', delivery: { type: 'direct', hits: 1 }, baseDamage: 200 }),
directAbility({
id: 'off',
baseDamage: undefined,
activation: { mode: 'off' }
})
],
attributes: {},
rules: { revisionId: 'combined', bindings: [] },
assumptions: { encounterSeconds: 10, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({
enabled: false,
activationCount: 0
});
expect(result.combined.singleTargetEncounterDamage).toBe(600);
expect(result.combined.singleTargetDps).toBe(60);
});
it('uses each ability target assumption before applying its target cap', () => {
const result = calculateSkillDps(base({
abilities: [
directAbility({ id: 'single', targetCount: 1 }),
directAbility({ id: 'crowd', targetCount: 2 })
],
attributes: {},
rules: { revisionId: 'per-ability-targets', bindings: [] },
assumptions: { encounterSeconds: 10, targetCount: 5 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities.find((item) => item.abilityId === 'single')?.effectiveTargets).toBe(1);
expect(result.abilities.find((item) => item.abilityId === 'crowd')?.effectiveTargets).toBe(2);
});
it('returns explicit missing requirements instead of partial damage', () => {
const result = calculateSkillDps({
abilities: [directAbility({
baseDamage: undefined,
targetCap: undefined,
chargePolicy: undefined,
activation: { mode: 'custom_per_minute', usesPerMinute: 0 }
})],
attributes: { unknownSkillAttribute: 10 },
assumptions: {}
});
expect(result).toMatchObject({
status: 'INSUFFICIENT_DATA',
revisionId: null,
abilities: [],
combined: null
});
expect(result.missing).toEqual(expect.arrayContaining([
'rules.revisionId',
'rules.bindings',
'assumptions.encounterSeconds',
'assumptions.targetCount',
'ability:seeker:baseDamage',
'ability:seeker:targetCap',
'ability:seeker:chargePolicy',
'ability:seeker:activation.usesPerMinute',
'binding:unknownSkillAttribute'
]));
});
});
+553
View File
@@ -0,0 +1,553 @@
export type SkillStatValues = Record<string, number>;
export type SkillCalculationStage =
| 'flat_damage'
| 'skill_damage'
| 'final_damage'
| 'critical_chance'
| 'critical_damage'
| 'multishot_chance'
| 'multishot_damage'
| 'cooldown_recovery'
| 'duration';
export type SkillAttributeBinding = {
attributeId: string;
stage: SkillCalculationStage;
operator: 'add' | 'multiply';
weight?: number;
};
export type SkillCalculationRules = {
revisionId: string;
bindings: SkillAttributeBinding[];
stageOrder?: SkillCalculationStage[];
};
export type SkillDelivery =
| { type: 'direct'; hits: number }
| {
type: 'periodic';
ticks: number;
durationSeconds?: number;
hitsPerTick?: number;
intervalSeconds: number;
firstTickDelaySeconds?: number;
};
export type SkillChargePolicy =
| { type: 'none' }
| { type: 'independent'; charges: number }
| { type: 'full_set'; charges: number };
export type SkillActivation =
| { mode: 'off' }
| { mode: 'on_cooldown' }
| { mode: 'custom_per_minute'; usesPerMinute: number };
export type SkillAbilityInput = {
id: string;
baseDamage?: number;
damageType?: string;
delivery: SkillDelivery;
castSeconds?: number;
cooldownSeconds?: number;
targetCap?: number;
targetCount?: number;
chargePolicy?: SkillChargePolicy;
activation: SkillActivation;
};
export type SkillModifierWindow = {
startSeconds: number;
endSeconds: number;
attributes: SkillStatValues;
abilityIds?: string[];
};
export type SkillCalculationInput = {
rules?: SkillCalculationRules;
abilities: SkillAbilityInput[];
attributes?: SkillStatValues;
attributeRules?: Record<string, {
combination: 'additive' | 'multiplicative_percent';
cap?: number | null;
}>;
modifierWindows?: SkillModifierWindow[];
assumptions: {
encounterSeconds?: number;
targetCount?: number;
};
};
export type SkillDamageSummary = {
unmodifiedBaseDamage: number;
averageExpectedDamagePerHit: number;
singleTargetDamagePerActivation: number;
aggregateDamagePerActivation: number;
singleTargetEncounterDamage: number;
aggregateEncounterDamage: number;
singleTargetDps: number;
aggregateDps: number;
};
export type SkillAbilityCalculation = {
abilityId: string;
damageType: string;
enabled: boolean;
activationCount: number;
damageEventCount: number;
effectiveTargets: number;
damage: SkillDamageSummary;
};
export type SkillCombinedCalculation = {
singleTargetEncounterDamage: number;
aggregateEncounterDamage: number;
singleTargetDps: number;
aggregateDps: number;
};
export type SkillCalculation =
| {
status: 'INSUFFICIENT_DATA';
revisionId: string | null;
missing: string[];
abilities: [];
combined: null;
}
| {
status: 'OK';
revisionId: string;
missing: [];
abilities: SkillAbilityCalculation[];
combined: SkillCombinedCalculation;
};
type DamageEvent = {
activationIndex: number;
time: number;
};
const EPSILON = 0.000_001;
const MAX_EVENTS = 100_000;
const isFiniteNumber = (value: unknown) => Number.isFinite(Number(value));
const isNonNegative = (value: unknown) => isFiniteNumber(value) && Number(value) >= 0;
const isPositive = (value: unknown) => isFiniteNumber(value) && Number(value) > 0;
const isPositiveInteger = (value: unknown) => Number.isInteger(Number(value)) && Number(value) > 0;
const clamp = (value: number, minimum: number, maximum: number) =>
Math.min(maximum, Math.max(minimum, value));
function requiredData(input: SkillCalculationInput) {
const missing: string[] = [];
const enabled = input.abilities.filter((ability) => ability.activation.mode !== 'off');
if (enabled.length) {
if (!input.rules?.revisionId?.trim()) missing.push('rules.revisionId');
if (!Array.isArray(input.rules?.bindings)) missing.push('rules.bindings');
if (!isPositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
if (!isPositiveInteger(input.assumptions.targetCount)) missing.push('assumptions.targetCount');
}
const seenIds = new Set<string>();
for (const ability of enabled) {
const prefix = `ability:${ability.id || 'unknown'}`;
if (!ability.id.trim()) missing.push(`${prefix}:id`);
if (seenIds.has(ability.id)) missing.push(`${prefix}:duplicate`);
seenIds.add(ability.id);
if (!isPositive(ability.baseDamage)) missing.push(`${prefix}:baseDamage`);
if (!ability.damageType?.trim()) missing.push(`${prefix}:damageType`);
if (!isNonNegative(ability.castSeconds)) missing.push(`${prefix}:castSeconds`);
if (!isNonNegative(ability.cooldownSeconds)) missing.push(`${prefix}:cooldownSeconds`);
if (!isPositiveInteger(ability.targetCap)) missing.push(`${prefix}:targetCap`);
if (ability.targetCount !== undefined && !isPositiveInteger(ability.targetCount)) {
missing.push(`${prefix}:targetCount`);
}
if (!ability.chargePolicy) missing.push(`${prefix}:chargePolicy`);
if (ability.chargePolicy?.type !== 'none' && !isPositiveInteger(ability.chargePolicy?.charges)) {
missing.push(`${prefix}:chargePolicy.charges`);
}
if (ability.delivery.type === 'direct') {
if (!isPositiveInteger(ability.delivery.hits)) missing.push(`${prefix}:delivery.hits`);
} else {
if (!isPositiveInteger(ability.delivery.ticks)) missing.push(`${prefix}:delivery.ticks`);
if (
ability.delivery.durationSeconds !== undefined
&& !isPositive(ability.delivery.durationSeconds)
) {
missing.push(`${prefix}:delivery.durationSeconds`);
}
if (!isPositiveInteger(ability.delivery.hitsPerTick ?? 1)) {
missing.push(`${prefix}:delivery.hitsPerTick`);
}
if (!isNonNegative(ability.delivery.intervalSeconds)) {
missing.push(`${prefix}:delivery.intervalSeconds`);
}
if (!isNonNegative(ability.delivery.firstTickDelaySeconds ?? 0)) {
missing.push(`${prefix}:delivery.firstTickDelaySeconds`);
}
}
if (
ability.activation.mode === 'custom_per_minute'
&& !isPositive(ability.activation.usesPerMinute)
) {
missing.push(`${prefix}:activation.usesPerMinute`);
}
if (
ability.activation.mode === 'on_cooldown'
&& !isPositive(ability.cooldownSeconds)
&& !isPositive(ability.castSeconds)
) {
missing.push(`${prefix}:activation.cadence`);
}
}
for (const [index, window] of (input.modifierWindows ?? []).entries()) {
if (!isNonNegative(window.startSeconds)) {
missing.push(`modifierWindow:${index}:startSeconds`);
}
if (!isPositive(window.endSeconds) || window.endSeconds <= window.startSeconds) {
missing.push(`modifierWindow:${index}:endSeconds`);
}
for (const [attributeId, value] of Object.entries(window.attributes)) {
if (!isFiniteNumber(value)) missing.push(`modifierWindow:${index}:attribute:${attributeId}`);
}
}
for (const [attributeId, value] of Object.entries(input.attributes ?? {})) {
if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`);
}
const boundIds = new Set(input.rules?.bindings.map((binding) => binding.attributeId) ?? []);
const suppliedIds = new Set([
...Object.keys(input.attributes ?? {}),
...(input.modifierWindows ?? []).flatMap((window) => Object.keys(window.attributes))
]);
for (const attributeId of suppliedIds) {
if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`);
}
return [...new Set(missing)];
}
function activationInterval(ability: SkillAbilityInput) {
return ability.activation.mode === 'custom_per_minute'
? 60 / ability.activation.usesPerMinute
: 0;
}
function stageFactorAt(
input: SkillCalculationInput,
rules: SkillCalculationRules,
abilityId: string,
time: number,
stage: 'cooldown_recovery' | 'duration'
) {
const values = stageValues(attributesAt(input, abilityId, time), rules.bindings, stage);
return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative);
}
function activationTimes(
ability: SkillAbilityInput,
encounterSeconds: number,
input: SkillCalculationInput,
rules: SkillCalculationRules
) {
if (ability.activation.mode === 'off') return [];
const castSeconds = Number(ability.castSeconds);
const cooldownAt = (time: number) => Number(ability.cooldownSeconds)
/ stageFactorAt(input, rules, ability.id, time, 'cooldown_recovery');
const requestedInterval = activationInterval(ability);
const policy = ability.chargePolicy!;
const times: number[] = [];
let lastActivation = -Infinity;
let nextRequested = 0;
if (policy.type === 'none') {
for (
let time = 0;
time < encounterSeconds - EPSILON && times.length < MAX_EVENTS;
time += Math.max(castSeconds, cooldownAt(time), requestedInterval)
) {
times.push(time);
}
return times;
}
if (policy.type === 'independent') {
const readyAt = Array.from({ length: policy.charges }, () => 0);
while (times.length < MAX_EVENTS) {
const chargeIndex = readyAt.reduce(
(earliest, ready, index) => ready < readyAt[earliest] ? index : earliest,
0
);
const time = Math.max(
lastActivation + castSeconds,
readyAt[chargeIndex],
ability.activation.mode === 'custom_per_minute' ? nextRequested : 0
);
if (time >= encounterSeconds - EPSILON) break;
times.push(time);
lastActivation = time;
readyAt[chargeIndex] = time + cooldownAt(time);
if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval;
}
return times;
}
let charges = policy.charges;
let fullRechargeAt = Infinity;
while (times.length < MAX_EVENTS) {
let time = Math.max(
lastActivation + castSeconds,
ability.activation.mode === 'custom_per_minute' ? nextRequested : 0
);
if (charges === 0) {
time = Math.max(time, fullRechargeAt);
charges = policy.charges;
fullRechargeAt = Infinity;
}
if (time >= encounterSeconds - EPSILON) break;
times.push(time);
lastActivation = time;
charges -= 1;
if (charges === 0) fullRechargeAt = time + cooldownAt(time);
if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval;
}
return times;
}
function damageEvents(
ability: SkillAbilityInput,
activations: number[],
encounterSeconds: number,
input: SkillCalculationInput,
rules: SkillCalculationRules
) {
const events: DamageEvent[] = [];
for (const [activationIndex, activationTime] of activations.entries()) {
const deliveryTime = activationTime + Number(ability.castSeconds);
if (ability.delivery.type === 'direct') {
for (let hit = 0; hit < ability.delivery.hits; hit += 1) {
if (deliveryTime < encounterSeconds - EPSILON) {
events.push({ activationIndex, time: deliveryTime });
}
}
continue;
}
const firstTick = deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0);
const durationFactor = stageFactorAt(input, rules, ability.id, activationTime, 'duration');
const baseDuration = Number(
ability.delivery.durationSeconds
?? ability.delivery.ticks * ability.delivery.intervalSeconds
);
const ticks = Math.max(
1,
Math.floor(baseDuration * durationFactor / ability.delivery.intervalSeconds)
);
for (let tick = 0; tick < ticks; tick += 1) {
const time = firstTick + tick * ability.delivery.intervalSeconds;
if (time < encounterSeconds - EPSILON) {
for (let hit = 0; hit < Number(ability.delivery.hitsPerTick ?? 1); hit += 1) {
events.push({ activationIndex, time });
}
}
}
}
return events;
}
function attributesAt(
input: SkillCalculationInput,
abilityId: string,
time: number
) {
const attributes = { ...(input.attributes ?? {}) };
for (const window of input.modifierWindows ?? []) {
const appliesToAbility = !window.abilityIds?.length || window.abilityIds.includes(abilityId);
if (!appliesToAbility || time < window.startSeconds || time >= window.endSeconds) continue;
for (const [attributeId, value] of Object.entries(window.attributes)) {
const current = attributes[attributeId] ?? 0;
const rule = input.attributeRules?.[attributeId];
const combined = rule?.combination === 'multiplicative_percent'
? ((1 + current / 100) * (1 + value / 100) - 1) * 100
: current + value;
attributes[attributeId] = rule?.cap === null || rule?.cap === undefined
? combined
: Math.min(combined, rule.cap);
}
}
return attributes;
}
function stageValues(
attributes: SkillStatValues,
bindings: SkillAttributeBinding[],
stage: SkillCalculationStage
) {
let additive = 0;
let multiplicative = 1;
for (const binding of bindings) {
if (binding.stage !== stage) continue;
const raw = Number(attributes[binding.attributeId] ?? 0);
const value = raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1);
if (binding.operator === 'add') additive += value;
else multiplicative *= value;
}
return { additive, multiplicative };
}
function expectedDamage(
baseDamage: number,
attributes: SkillStatValues,
rules: SkillCalculationRules
) {
let damage = baseDamage;
const stageOrder = rules.stageOrder?.length
? rules.stageOrder
: ['flat_damage', 'skill_damage', 'final_damage'] satisfies SkillCalculationStage[];
for (const stage of stageOrder) {
const values = stageValues(attributes, rules.bindings, stage);
if (stage === 'flat_damage') {
damage = (damage + values.additive) * values.multiplicative;
} else if (stage === 'skill_damage' || stage === 'final_damage') {
damage *= (1 + values.additive / 100) * values.multiplicative;
}
}
const criticalChance = stageValues(attributes, rules.bindings, 'critical_chance');
const criticalDamage = stageValues(attributes, rules.bindings, 'critical_damage');
const multishotChance = stageValues(attributes, rules.bindings, 'multishot_chance');
const multishotDamage = stageValues(attributes, rules.bindings, 'multishot_damage');
const criticalChanceRate = clamp(
criticalChance.additive * criticalChance.multiplicative / 100,
0,
1
);
const criticalDamageBonus =
criticalDamage.additive * criticalDamage.multiplicative / 100;
const multishotChanceRate = clamp(
multishotChance.additive * multishotChance.multiplicative / 100,
0,
1
);
const multishotDamageFactor =
multishotDamage.additive * multishotDamage.multiplicative / 100;
return damage
* (1 + criticalChanceRate * criticalDamageBonus)
* (1 + multishotChanceRate * multishotDamageFactor);
}
function zeroDamage(baseDamage = 0): SkillDamageSummary {
return {
unmodifiedBaseDamage: baseDamage,
averageExpectedDamagePerHit: 0,
singleTargetDamagePerActivation: 0,
aggregateDamagePerActivation: 0,
singleTargetEncounterDamage: 0,
aggregateEncounterDamage: 0,
singleTargetDps: 0,
aggregateDps: 0
};
}
export function calculateSkillDps(input: SkillCalculationInput): SkillCalculation {
const missing = requiredData(input);
if (missing.length) {
return {
status: 'INSUFFICIENT_DATA',
revisionId: input.rules?.revisionId ?? null,
missing,
abilities: [],
combined: null
};
}
const rules = input.rules!;
const encounterSeconds = Number(input.assumptions.encounterSeconds);
const targetCount = Number(input.assumptions.targetCount);
const abilities: SkillAbilityCalculation[] = input.abilities.map((ability) => {
if (ability.activation.mode === 'off') {
return {
abilityId: ability.id,
damageType: ability.damageType ?? '',
enabled: false,
activationCount: 0,
damageEventCount: 0,
effectiveTargets: 0,
damage: zeroDamage(Number(ability.baseDamage ?? 0))
};
}
const activations = activationTimes(ability, encounterSeconds, input, rules);
const events = damageEvents(ability, activations, encounterSeconds, input, rules);
const damageByEvent = events.map((event) =>
expectedDamage(
Number(ability.baseDamage),
attributesAt(input, ability.id, event.time),
rules
)
);
const singleTargetEncounterDamage = damageByEvent.reduce((total, damage) => total + damage, 0);
const effectiveTargets = Math.min(
Number(ability.targetCount ?? targetCount),
Number(ability.targetCap)
);
const aggregateEncounterDamage = singleTargetEncounterDamage * effectiveTargets;
const deliveredActivations = new Set(events.map((event) => event.activationIndex)).size;
const averageExpectedDamagePerHit = damageByEvent.length
? singleTargetEncounterDamage / damageByEvent.length
: 0;
const singleTargetDamagePerActivation = deliveredActivations
? singleTargetEncounterDamage / deliveredActivations
: 0;
return {
abilityId: ability.id,
damageType: ability.damageType!,
enabled: true,
activationCount: activations.length,
damageEventCount: events.length,
effectiveTargets,
damage: {
unmodifiedBaseDamage: Number(ability.baseDamage),
averageExpectedDamagePerHit,
singleTargetDamagePerActivation,
aggregateDamagePerActivation: singleTargetDamagePerActivation * effectiveTargets,
singleTargetEncounterDamage,
aggregateEncounterDamage,
singleTargetDps: singleTargetEncounterDamage / encounterSeconds,
aggregateDps: aggregateEncounterDamage / encounterSeconds
}
};
});
const combined = abilities.reduce<SkillCombinedCalculation>(
(total, ability) => ({
singleTargetEncounterDamage:
total.singleTargetEncounterDamage + ability.damage.singleTargetEncounterDamage,
aggregateEncounterDamage:
total.aggregateEncounterDamage + ability.damage.aggregateEncounterDamage,
singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps,
aggregateDps: total.aggregateDps + ability.damage.aggregateDps
}),
{
singleTargetEncounterDamage: 0,
aggregateEncounterDamage: 0,
singleTargetDps: 0,
aggregateDps: 0
}
);
return {
status: 'OK',
revisionId: rules.revisionId,
missing: [],
abilities,
combined
};
}