feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user