feat: add community publishing and news syndication
This commit is contained in:
@@ -1,39 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatItemDamageRangeScript,
|
||||
formatWeaponCalculationScript
|
||||
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('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');
|
||||
});
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,131 +1,147 @@
|
||||
export type CalculationScriptStage = {
|
||||
stage_key: string;
|
||||
label: string;
|
||||
operator: string;
|
||||
display_order: number;
|
||||
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;
|
||||
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;
|
||||
id: string;
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
};
|
||||
|
||||
export type CalculationScriptRevision = {
|
||||
version?: number | null;
|
||||
name?: string | null;
|
||||
version?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const number = (value: unknown) =>
|
||||
Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3)));
|
||||
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';
|
||||
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>
|
||||
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 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}`];
|
||||
}
|
||||
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[];
|
||||
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;
|
||||
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'
|
||||
];
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
+221
-126
@@ -1,141 +1,236 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateCombat,
|
||||
expectedWeaponShotDamage,
|
||||
type CombatCalculationInput
|
||||
calculateCombat,
|
||||
expectedWeaponShotDamage,
|
||||
type CombatCalculationInput,
|
||||
} from './combat.js';
|
||||
|
||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
||||
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
|
||||
bindings: [
|
||||
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
||||
{ attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' },
|
||||
{ attributeId: 'final', stage: 'final_damage', operator: 'add' }
|
||||
],
|
||||
assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 },
|
||||
...overrides
|
||||
const base = (
|
||||
overrides: Partial<CombatCalculationInput> = {},
|
||||
): CombatCalculationInput => ({
|
||||
weapon: {
|
||||
baseDamage: 100,
|
||||
rpm: 600,
|
||||
magazine: 10,
|
||||
reloadSeconds: 2,
|
||||
headshotMultiplier: 2,
|
||||
},
|
||||
attributes: {
|
||||
weapon: 20,
|
||||
critChance: 25,
|
||||
critDamage: 50,
|
||||
headshotChance: 0,
|
||||
final: 10,
|
||||
},
|
||||
bindings: [
|
||||
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
||||
{
|
||||
attributeId: 'headshotChance',
|
||||
stage: 'headshot_chance',
|
||||
operator: 'add',
|
||||
},
|
||||
{ attributeId: 'final', stage: 'final_damage', operator: 'add' },
|
||||
],
|
||||
assumptions: {
|
||||
mode: 'off',
|
||||
hitRate: 100,
|
||||
headshotRate: 0,
|
||||
firingActivity: 100,
|
||||
encounterSeconds: 20,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('calculateCombat', () => {
|
||||
it('calculates four hit values and reload-aware DPS', () => {
|
||||
const result = calculateCombat(base());
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
|
||||
expect(result.burstDps).toBe(1485);
|
||||
expect(result.sustainedDps).toBeCloseTo(495);
|
||||
});
|
||||
it('calculates four hit values and reload-aware DPS', () => {
|
||||
const result = calculateCombat(base());
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.hitValues).toEqual({
|
||||
normal: 132,
|
||||
critical: 198,
|
||||
headshot: 264,
|
||||
criticalHeadshot: 396,
|
||||
});
|
||||
expect(result.burstDps).toBe(1485);
|
||||
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('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 },
|
||||
bindings: [{
|
||||
attributeId: 'weapon',
|
||||
stage: 'weapon_damage',
|
||||
operator: 'add',
|
||||
weight: 2,
|
||||
softCap: 50,
|
||||
hardCap: 80,
|
||||
curveType: 'power',
|
||||
curveParameter: 1
|
||||
}]
|
||||
}));
|
||||
expect(result.hitValues?.normal).toBe(180);
|
||||
});
|
||||
it('applies weights, hard caps, and a configured curve safely', () => {
|
||||
const result = calculateCombat(
|
||||
base({
|
||||
attributes: { weapon: 100 },
|
||||
bindings: [
|
||||
{
|
||||
attributeId: 'weapon',
|
||||
stage: 'weapon_damage',
|
||||
operator: 'add',
|
||||
weight: 2,
|
||||
softCap: 50,
|
||||
hardCap: 80,
|
||||
curveType: 'power',
|
||||
curveParameter: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
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('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',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3, critDamage: 1 }
|
||||
}];
|
||||
const off = calculateCombat(base({ stackingEffects }));
|
||||
const peak = calculateCombat(base({
|
||||
stackingEffects,
|
||||
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
|
||||
}));
|
||||
expect(off.hitValues?.normal).toBe(132);
|
||||
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
||||
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
||||
});
|
||||
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
||||
const stackingEffects = [
|
||||
{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3, critDamage: 1 },
|
||||
},
|
||||
];
|
||||
const off = calculateCombat(base({ stackingEffects }));
|
||||
const peak = calculateCombat(
|
||||
base({
|
||||
stackingEffects,
|
||||
assumptions: {
|
||||
mode: 'off',
|
||||
perEffect: { relentless: { mode: 'peak' } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(off.hitValues?.normal).toBe(132);
|
||||
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
||||
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
||||
});
|
||||
|
||||
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3 }
|
||||
}],
|
||||
assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 }
|
||||
}));
|
||||
expect(result.encounterDps).not.toBeNull();
|
||||
expect(result.encounterDps!).toBeGreaterThan(495);
|
||||
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
||||
expect(result.stackSummaries[0].maintained).toBe(true);
|
||||
});
|
||||
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
||||
const result = calculateCombat(
|
||||
base({
|
||||
stackingEffects: [
|
||||
{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: {
|
||||
maxStacks: 10,
|
||||
durationSeconds: 10,
|
||||
stacksPerTrigger: 1,
|
||||
},
|
||||
attributes: { weapon: 3 },
|
||||
},
|
||||
],
|
||||
assumptions: {
|
||||
mode: 'encounter',
|
||||
hitRate: 100,
|
||||
firingActivity: 100,
|
||||
encounterSeconds: 30,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.encounterDps).not.toBeNull();
|
||||
expect(result.encounterDps!).toBeGreaterThan(495);
|
||||
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
||||
expect(result.stackSummaries[0].maintained).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
id: 'slow',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
||||
attributes: { weapon: 3 }
|
||||
}],
|
||||
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
|
||||
}));
|
||||
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
|
||||
});
|
||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||
const result = calculateCombat(
|
||||
base({
|
||||
stackingEffects: [
|
||||
{
|
||||
id: 'slow',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
||||
attributes: { weapon: 3 },
|
||||
},
|
||||
],
|
||||
assumptions: {
|
||||
mode: 'encounter',
|
||||
hitRate: 10,
|
||||
firingActivity: 50,
|
||||
encounterSeconds: 20,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.stackSummaries[0]).toMatchObject({
|
||||
maxStacksReached: 0,
|
||||
averageStacks: 0,
|
||||
maintained: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns explicit requirements instead of partial results', () => {
|
||||
const result = calculateCombat(base({
|
||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||
assumptions: { mode: 'encounter' },
|
||||
stackingEffects: [{
|
||||
id: 'need-profile',
|
||||
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
||||
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
||||
attributes: {}
|
||||
}]
|
||||
}));
|
||||
expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null });
|
||||
expect(result.missing).toEqual(expect.arrayContaining([
|
||||
'weapon.rpm',
|
||||
'weapon.magazine',
|
||||
'weapon.reloadSeconds',
|
||||
'assumptions.hitRate',
|
||||
'assumptions.firingActivity',
|
||||
'assumptions.encounterSeconds'
|
||||
]));
|
||||
});
|
||||
it('returns explicit requirements instead of partial results', () => {
|
||||
const result = calculateCombat(
|
||||
base({
|
||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||
assumptions: { mode: 'encounter' },
|
||||
stackingEffects: [
|
||||
{
|
||||
id: 'need-profile',
|
||||
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
||||
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
||||
attributes: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
hitValues: null,
|
||||
burstDps: null,
|
||||
});
|
||||
expect(result.missing).toEqual(
|
||||
expect.arrayContaining([
|
||||
'weapon.rpm',
|
||||
'weapon.magazine',
|
||||
'weapon.reloadSeconds',
|
||||
'assumptions.hitRate',
|
||||
'assumptions.firingActivity',
|
||||
'assumptions.encounterSeconds',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+525
-390
@@ -1,465 +1,600 @@
|
||||
export type CalculationStage =
|
||||
| 'flat_damage'
|
||||
| 'weapon_damage'
|
||||
| 'critical_chance'
|
||||
| 'critical_damage'
|
||||
| 'headshot_chance'
|
||||
| 'headshot_damage'
|
||||
| 'rate_of_fire'
|
||||
| 'magazine_size'
|
||||
| 'reload_speed'
|
||||
| 'final_damage';
|
||||
| 'flat_damage'
|
||||
| 'weapon_damage'
|
||||
| 'critical_chance'
|
||||
| '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';
|
||||
export type AttributeBinding = {
|
||||
attributeId: string;
|
||||
stage: CalculationStage;
|
||||
operator: CalculationOperator;
|
||||
weight?: number;
|
||||
softCap?: number | null;
|
||||
hardCap?: number | null;
|
||||
curveType?: CurveType;
|
||||
curveParameter?: number | null;
|
||||
attributeId: string;
|
||||
stage: CalculationStage;
|
||||
operator: CalculationOperator;
|
||||
weight?: number;
|
||||
softCap?: number | null;
|
||||
hardCap?: number | null;
|
||||
curveType?: CurveType;
|
||||
curveParameter?: number | null;
|
||||
};
|
||||
export type StatValues = Record<string, number>;
|
||||
export type WeaponCombatInput = {
|
||||
baseDamage?: number;
|
||||
rpm?: number;
|
||||
magazine?: number;
|
||||
reloadSeconds?: number;
|
||||
headshotMultiplier?: number;
|
||||
baseDamage?: number;
|
||||
rpm?: number;
|
||||
magazine?: number;
|
||||
reloadSeconds?: number;
|
||||
headshotMultiplier?: number;
|
||||
};
|
||||
export type WeaponFiringCycle = {
|
||||
rpm: number;
|
||||
magazine: number;
|
||||
reloadSeconds: number;
|
||||
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 };
|
||||
export type StackLifecycle = {
|
||||
maxStacks: number;
|
||||
durationSeconds: number;
|
||||
stacksPerTrigger?: number;
|
||||
};
|
||||
export type StackingEffect = {
|
||||
id: string;
|
||||
trigger: TriggerRule;
|
||||
lifecycle: StackLifecycle;
|
||||
attributes: StatValues;
|
||||
id: string;
|
||||
trigger: TriggerRule;
|
||||
lifecycle: StackLifecycle;
|
||||
attributes: StatValues;
|
||||
};
|
||||
export type CombatAssumptions = {
|
||||
mode: StackingMode;
|
||||
hitRate?: number;
|
||||
headshotRate?: number;
|
||||
firingActivity?: number;
|
||||
encounterSeconds?: number;
|
||||
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
||||
mode: StackingMode;
|
||||
hitRate?: number;
|
||||
headshotRate?: number;
|
||||
firingActivity?: number;
|
||||
encounterSeconds?: number;
|
||||
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
||||
};
|
||||
export type MissingRequirement =
|
||||
| 'weapon.baseDamage'
|
||||
| 'weapon.rpm'
|
||||
| 'weapon.magazine'
|
||||
| 'weapon.reloadSeconds'
|
||||
| 'assumptions.hitRate'
|
||||
| 'assumptions.firingActivity'
|
||||
| 'assumptions.encounterSeconds'
|
||||
| `binding:${string}`;
|
||||
| 'weapon.baseDamage'
|
||||
| 'weapon.rpm'
|
||||
| 'weapon.magazine'
|
||||
| 'weapon.reloadSeconds'
|
||||
| 'assumptions.hitRate'
|
||||
| 'assumptions.firingActivity'
|
||||
| 'assumptions.encounterSeconds'
|
||||
| `binding:${string}`;
|
||||
export type DamageValues = {
|
||||
normal: number;
|
||||
critical: number;
|
||||
headshot: number;
|
||||
criticalHeadshot: number;
|
||||
normal: number;
|
||||
critical: number;
|
||||
headshot: number;
|
||||
criticalHeadshot: number;
|
||||
};
|
||||
export type StackSummary = {
|
||||
effectId: string;
|
||||
mode: StackingMode;
|
||||
stacksAtEnd: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
averageStacks: number;
|
||||
maintained: boolean;
|
||||
effectId: string;
|
||||
mode: StackingMode;
|
||||
stacksAtEnd: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
averageStacks: number;
|
||||
maintained: boolean;
|
||||
};
|
||||
export type CombatCalculation = {
|
||||
status: 'OK' | 'INSUFFICIENT_DATA';
|
||||
missing: MissingRequirement[];
|
||||
hitValues: DamageValues | null;
|
||||
burstDps: number | null;
|
||||
sustainedDps: number | null;
|
||||
encounterDps: number | null;
|
||||
stackSummaries: StackSummary[];
|
||||
status: 'OK' | 'INSUFFICIENT_DATA';
|
||||
missing: MissingRequirement[];
|
||||
hitValues: DamageValues | null;
|
||||
burstDps: number | null;
|
||||
sustainedDps: number | null;
|
||||
encounterDps: number | null;
|
||||
stackSummaries: StackSummary[];
|
||||
};
|
||||
export type CombatCalculationInput = {
|
||||
weapon: WeaponCombatInput;
|
||||
attributes: StatValues;
|
||||
bindings: AttributeBinding[];
|
||||
stageOrder?: CalculationStage[];
|
||||
stackingEffects?: StackingEffect[];
|
||||
assumptions: CombatAssumptions;
|
||||
weapon: WeaponCombatInput;
|
||||
attributes: StatValues;
|
||||
bindings: AttributeBinding[];
|
||||
stageOrder?: CalculationStage[];
|
||||
stackingEffects?: StackingEffect[];
|
||||
assumptions: CombatAssumptions;
|
||||
};
|
||||
|
||||
type ResolvedAssumptions = {
|
||||
mode: StackingMode;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
encounterSeconds: number;
|
||||
mode: StackingMode;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
encounterSeconds: number;
|
||||
};
|
||||
type StackState = {
|
||||
stacks: number;
|
||||
expiresAt: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
stacks: number;
|
||||
expiresAt: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
};
|
||||
type TimedStackEffect = {
|
||||
effect: StackingEffect;
|
||||
assumptions: ResolvedAssumptions;
|
||||
events: number[];
|
||||
effect: StackingEffect;
|
||||
assumptions: ResolvedAssumptions;
|
||||
events: number[];
|
||||
};
|
||||
|
||||
const EPSILON = 0.000_001;
|
||||
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
|
||||
const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
||||
const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100;
|
||||
const finitePositive = (value: unknown) =>
|
||||
Number.isFinite(Number(value)) && Number(value) > 0;
|
||||
const finiteNonNegative = (value: unknown) =>
|
||||
Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||
const clamp = (value: number, low: number, high: number) =>
|
||||
Math.min(high, Math.max(low, value));
|
||||
const percent = (value: unknown, fallback = 0) =>
|
||||
clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) /
|
||||
100;
|
||||
|
||||
function resolveAssumptions(base: CombatAssumptions, effectId: string): ResolvedAssumptions {
|
||||
const override = base.perEffect?.[effectId] ?? {};
|
||||
return {
|
||||
mode: override.mode ?? base.mode,
|
||||
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
||||
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
||||
firingActivity: percent(override.firingActivity ?? base.firingActivity, 100),
|
||||
encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0)
|
||||
};
|
||||
function resolveAssumptions(
|
||||
base: CombatAssumptions,
|
||||
effectId: string,
|
||||
): ResolvedAssumptions {
|
||||
const override = base.perEffect?.[effectId] ?? {};
|
||||
return {
|
||||
mode: override.mode ?? base.mode,
|
||||
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
||||
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
||||
firingActivity: percent(
|
||||
override.firingActivity ?? base.firingActivity,
|
||||
100,
|
||||
),
|
||||
encounterSeconds: Number(
|
||||
override.encounterSeconds ?? base.encounterSeconds ?? 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function transformedValue(raw: number, binding: AttributeBinding) {
|
||||
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
||||
const sign = raw < 0 ? -1 : 1;
|
||||
let value = Math.abs(raw * weight);
|
||||
const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
|
||||
const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null;
|
||||
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
|
||||
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
||||
const overflow = value - softCap;
|
||||
if (binding.curveType === 'power') {
|
||||
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
||||
} else {
|
||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
||||
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
||||
}
|
||||
}
|
||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||
return value * sign;
|
||||
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
||||
const sign = raw < 0 ? -1 : 1;
|
||||
let value = Math.abs(raw * weight);
|
||||
const softCap = finiteNonNegative(binding.softCap)
|
||||
? Number(binding.softCap)
|
||||
: null;
|
||||
const hardCap = finiteNonNegative(binding.hardCap)
|
||||
? Number(binding.hardCap)
|
||||
: null;
|
||||
if (
|
||||
softCap !== null &&
|
||||
value > softCap &&
|
||||
binding.curveType &&
|
||||
binding.curveType !== 'none'
|
||||
) {
|
||||
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
||||
const overflow = value - softCap;
|
||||
if (binding.curveType === 'power') {
|
||||
value =
|
||||
softCap +
|
||||
overflow *
|
||||
Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
||||
} else {
|
||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
||||
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
||||
}
|
||||
}
|
||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||
return value * sign;
|
||||
}
|
||||
|
||||
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
|
||||
for (const [id, value] of Object.entries(incoming)) {
|
||||
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
|
||||
}
|
||||
function addAttributes(
|
||||
target: StatValues,
|
||||
incoming: StatValues,
|
||||
multiplier = 1,
|
||||
) {
|
||||
for (const [id, value] of Object.entries(incoming)) {
|
||||
if (Number.isFinite(value))
|
||||
target[id] = (target[id] ?? 0) + value * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
||||
const missing: MissingRequirement[] = [];
|
||||
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
|
||||
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
||||
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
|
||||
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
|
||||
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
|
||||
for (const binding of input.bindings) {
|
||||
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
|
||||
}
|
||||
if (input.assumptions.mode === 'encounter') {
|
||||
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
for (const effect of input.stackingEffects ?? []) {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
if (assumptions.mode === 'encounter') {
|
||||
const override = input.assumptions.perEffect?.[effect.id];
|
||||
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) {
|
||||
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(missing)];
|
||||
const missing: MissingRequirement[] = [];
|
||||
if (!finitePositive(input.weapon.baseDamage))
|
||||
missing.push('weapon.baseDamage');
|
||||
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
||||
if (
|
||||
!Number.isInteger(input.weapon.magazine) ||
|
||||
Number(input.weapon.magazine) < 1
|
||||
)
|
||||
missing.push('weapon.magazine');
|
||||
if (!finiteNonNegative(input.weapon.reloadSeconds))
|
||||
missing.push('weapon.reloadSeconds');
|
||||
const boundIds = new Set(
|
||||
input.bindings.map((binding) => binding.attributeId),
|
||||
);
|
||||
for (const binding of input.bindings) {
|
||||
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0))
|
||||
missing.push(`binding:${binding.attributeId}`);
|
||||
}
|
||||
if (input.assumptions.mode === 'encounter') {
|
||||
if (!finitePositive(input.assumptions.hitRate))
|
||||
missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(input.assumptions.firingActivity))
|
||||
missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(input.assumptions.encounterSeconds))
|
||||
missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
for (const effect of input.stackingEffects ?? []) {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
if (assumptions.mode === 'encounter') {
|
||||
const override = input.assumptions.perEffect?.[effect.id];
|
||||
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate))
|
||||
missing.push('assumptions.hitRate');
|
||||
if (
|
||||
!finitePositive(
|
||||
override?.firingActivity ?? input.assumptions.firingActivity,
|
||||
)
|
||||
)
|
||||
missing.push('assumptions.firingActivity');
|
||||
if (
|
||||
!finitePositive(
|
||||
override?.encounterSeconds ?? input.assumptions.encounterSeconds,
|
||||
)
|
||||
)
|
||||
missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
if (assumptions.mode !== 'off')
|
||||
for (const id of Object.keys(effect.attributes)) {
|
||||
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(missing)];
|
||||
}
|
||||
|
||||
function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) {
|
||||
let additive = 0;
|
||||
let multiplicative = 1;
|
||||
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
||||
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
return { additive, multiplicative };
|
||||
function stageValues(
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stage: CalculationStage,
|
||||
) {
|
||||
let additive = 0;
|
||||
let multiplicative = 1;
|
||||
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
||||
const value = transformedValue(
|
||||
Number(attributes[binding.attributeId] ?? 0),
|
||||
binding,
|
||||
);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
return { additive, multiplicative };
|
||||
}
|
||||
|
||||
function damageBase(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[],
|
||||
) {
|
||||
let damage = weapon.baseDamage;
|
||||
const order = stageOrder?.length
|
||||
? stageOrder
|
||||
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
|
||||
for (const stage of order) {
|
||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
|
||||
const values = stageValues(attributes, bindings, stage);
|
||||
if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative;
|
||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||
}
|
||||
return damage;
|
||||
let damage = weapon.baseDamage;
|
||||
const order = stageOrder?.length
|
||||
? stageOrder
|
||||
: ([
|
||||
'flat_damage',
|
||||
'weapon_damage',
|
||||
'final_damage',
|
||||
] satisfies CalculationStage[]);
|
||||
for (const stage of order) {
|
||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage))
|
||||
continue;
|
||||
const values = stageValues(attributes, bindings, stage);
|
||||
if (stage === 'flat_damage')
|
||||
damage = (damage + values.additive) * values.multiplicative;
|
||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
|
||||
function hitValues(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[],
|
||||
): DamageValues {
|
||||
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_damage');
|
||||
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
||||
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
|
||||
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
|
||||
return {
|
||||
normal,
|
||||
critical: normal * criticalFactor,
|
||||
headshot: normal * headshotFactor,
|
||||
criticalHeadshot: normal * criticalFactor * headshotFactor
|
||||
};
|
||||
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_damage');
|
||||
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
||||
const criticalFactor =
|
||||
(1 + critical.additive / 100) * critical.multiplicative;
|
||||
const headshotFactor =
|
||||
weapon.headshotMultiplier *
|
||||
(1 + headshot.additive / 100) *
|
||||
headshot.multiplicative;
|
||||
return {
|
||||
normal,
|
||||
critical: normal * criticalFactor,
|
||||
headshot: normal * headshotFactor,
|
||||
criticalHeadshot: normal * criticalFactor * headshotFactor,
|
||||
};
|
||||
}
|
||||
|
||||
function dpsFor(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
assumptions: ResolvedAssumptions,
|
||||
stageOrder?: CalculationStage[]
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
assumptions: ResolvedAssumptions,
|
||||
stageOrder?: CalculationStage[],
|
||||
) {
|
||||
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
||||
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
|
||||
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
||||
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
||||
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
|
||||
+ values.critical * boundCriticalChance * (1 - headshotChance)
|
||||
+ values.headshot * (1 - boundCriticalChance) * headshotChance
|
||||
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
||||
weapon,
|
||||
attributes,
|
||||
bindings
|
||||
);
|
||||
const shotsPerSecond = rpm / 60;
|
||||
return {
|
||||
values,
|
||||
burst: average * shotsPerSecond,
|
||||
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
|
||||
};
|
||||
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
||||
const configuredHeadshot = stageValues(
|
||||
attributes,
|
||||
bindings,
|
||||
'headshot_chance',
|
||||
);
|
||||
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
||||
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
||||
const average =
|
||||
values.normal * (1 - boundCriticalChance) * (1 - headshotChance) +
|
||||
values.critical * boundCriticalChance * (1 - headshotChance) +
|
||||
values.headshot * (1 - boundCriticalChance) * headshotChance +
|
||||
values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
||||
weapon,
|
||||
attributes,
|
||||
bindings,
|
||||
);
|
||||
const shotsPerSecond = rpm / 60;
|
||||
return {
|
||||
values,
|
||||
burst: average * shotsPerSecond,
|
||||
sustained:
|
||||
(average * magazine) / (magazine / shotsPerSecond + reloadSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function expectedWeaponShotDamage(
|
||||
weapon: WeaponCombatInput,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
headshot = false,
|
||||
stageOrder?: CalculationStage[]
|
||||
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;
|
||||
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[]
|
||||
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
|
||||
)
|
||||
};
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
|
||||
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
|
||||
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
|
||||
let magazineRemaining = weapon.magazine;
|
||||
let expectedHits = 0;
|
||||
let counterStartedAt = 0;
|
||||
const events: number[] = [];
|
||||
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
||||
if (magazineRemaining === 0) {
|
||||
time += weapon.reloadSeconds;
|
||||
magazineRemaining = weapon.magazine;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedHits === 0) counterStartedAt = time;
|
||||
expectedHits += assumptions.hitRate;
|
||||
magazineRemaining -= 1;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = assumptions.hitRate;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
||||
events.push(time);
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
time += shotInterval;
|
||||
}
|
||||
return events;
|
||||
function triggerEvents(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
effect: StackingEffect,
|
||||
assumptions: ResolvedAssumptions,
|
||||
) {
|
||||
if (
|
||||
assumptions.mode !== 'encounter' ||
|
||||
effect.trigger.hitsRequired < 1 ||
|
||||
effect.trigger.windowSeconds <= 0
|
||||
)
|
||||
return [];
|
||||
const shotInterval =
|
||||
60 / weapon.rpm / Math.max(assumptions.firingActivity, EPSILON);
|
||||
let magazineRemaining = weapon.magazine;
|
||||
let expectedHits = 0;
|
||||
let counterStartedAt = 0;
|
||||
const events: number[] = [];
|
||||
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
||||
if (magazineRemaining === 0) {
|
||||
time += weapon.reloadSeconds;
|
||||
magazineRemaining = weapon.magazine;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedHits === 0) counterStartedAt = time;
|
||||
expectedHits += assumptions.hitRate;
|
||||
magazineRemaining -= 1;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = assumptions.hitRate;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
||||
events.push(time);
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
time += shotInterval;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||
if (effect.assumptions.mode === 'off') {
|
||||
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
}
|
||||
if (effect.assumptions.mode === 'peak') {
|
||||
return {
|
||||
stacks: effect.effect.lifecycle.maxStacks,
|
||||
expiresAt: Infinity,
|
||||
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
||||
buildUpSeconds: 0
|
||||
};
|
||||
}
|
||||
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
for (const eventTime of effect.events) {
|
||||
if (eventTime - time > EPSILON) break;
|
||||
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
state.stacks = Math.min(
|
||||
effect.effect.lifecycle.maxStacks,
|
||||
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1)
|
||||
);
|
||||
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
||||
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
||||
if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) {
|
||||
state.buildUpSeconds = eventTime;
|
||||
}
|
||||
}
|
||||
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
return state;
|
||||
if (effect.assumptions.mode === 'off') {
|
||||
return {
|
||||
stacks: 0,
|
||||
expiresAt: 0,
|
||||
maxStacksReached: 0,
|
||||
buildUpSeconds: null,
|
||||
};
|
||||
}
|
||||
if (effect.assumptions.mode === 'peak') {
|
||||
return {
|
||||
stacks: effect.effect.lifecycle.maxStacks,
|
||||
expiresAt: Infinity,
|
||||
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
||||
buildUpSeconds: 0,
|
||||
};
|
||||
}
|
||||
const state: StackState = {
|
||||
stacks: 0,
|
||||
expiresAt: 0,
|
||||
maxStacksReached: 0,
|
||||
buildUpSeconds: null,
|
||||
};
|
||||
for (const eventTime of effect.events) {
|
||||
if (eventTime - time > EPSILON) break;
|
||||
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
state.stacks = Math.min(
|
||||
effect.effect.lifecycle.maxStacks,
|
||||
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1),
|
||||
);
|
||||
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
||||
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
||||
if (
|
||||
state.stacks === effect.effect.lifecycle.maxStacks &&
|
||||
state.buildUpSeconds === null
|
||||
) {
|
||||
state.buildUpSeconds = eventTime;
|
||||
}
|
||||
}
|
||||
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
|
||||
const result = { ...base };
|
||||
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
|
||||
return result;
|
||||
function stackAttributes(
|
||||
base: StatValues,
|
||||
effects: TimedStackEffect[],
|
||||
time: number,
|
||||
) {
|
||||
const result = { ...base };
|
||||
for (const effect of effects)
|
||||
addAttributes(
|
||||
result,
|
||||
effect.effect.attributes,
|
||||
stateAt(effect, time).stacks,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
missing,
|
||||
hitValues: null,
|
||||
burstDps: null,
|
||||
sustainedDps: null,
|
||||
encounterDps: null,
|
||||
stackSummaries: []
|
||||
};
|
||||
}
|
||||
const weapon: Required<WeaponCombatInput> = {
|
||||
baseDamage: Number(input.weapon.baseDamage),
|
||||
rpm: Number(input.weapon.rpm),
|
||||
magazine: Number(input.weapon.magazine),
|
||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
|
||||
};
|
||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
|
||||
});
|
||||
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
||||
const finalTime = Math.max(
|
||||
globalAssumptions.encounterSeconds,
|
||||
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
|
||||
);
|
||||
const current = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, finalTime),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
);
|
||||
let encounterDps: number | null = null;
|
||||
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
|
||||
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
||||
let total = 0;
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
const time = finalTime * (index + 0.5) / steps;
|
||||
const result = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, time),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
);
|
||||
total += result.sustained * globalAssumptions.firingActivity;
|
||||
}
|
||||
encounterDps = total / steps;
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
missing: [],
|
||||
hitValues: current.values,
|
||||
burstDps: current.burst,
|
||||
sustainedDps: current.sustained,
|
||||
encounterDps,
|
||||
stackSummaries: effects.map((item) => {
|
||||
const end = stateAt(item, finalTime);
|
||||
const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0;
|
||||
const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
sum += stateAt(item, duration * (index + 0.5) / samples).stacks;
|
||||
}
|
||||
return {
|
||||
effectId: item.effect.id,
|
||||
mode: item.assumptions.mode,
|
||||
stacksAtEnd: end.stacks,
|
||||
maxStacksReached: end.maxStacksReached,
|
||||
buildUpSeconds: end.buildUpSeconds,
|
||||
averageStacks: sum / samples,
|
||||
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
|
||||
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
|
||||
};
|
||||
})
|
||||
};
|
||||
export function calculateCombat(
|
||||
input: CombatCalculationInput,
|
||||
): CombatCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
missing,
|
||||
hitValues: null,
|
||||
burstDps: null,
|
||||
sustainedDps: null,
|
||||
encounterDps: null,
|
||||
stackSummaries: [],
|
||||
};
|
||||
}
|
||||
const weapon: Required<WeaponCombatInput> = {
|
||||
baseDamage: Number(input.weapon.baseDamage),
|
||||
rpm: Number(input.weapon.rpm),
|
||||
magazine: Number(input.weapon.magazine),
|
||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2),
|
||||
};
|
||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map(
|
||||
(effect) => {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
return {
|
||||
effect,
|
||||
assumptions,
|
||||
events: triggerEvents(weapon, effect, assumptions),
|
||||
};
|
||||
},
|
||||
);
|
||||
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
||||
const finalTime = Math.max(
|
||||
globalAssumptions.encounterSeconds,
|
||||
...effects
|
||||
.filter((effect) => effect.assumptions.mode === 'encounter')
|
||||
.map((effect) => effect.assumptions.encounterSeconds),
|
||||
);
|
||||
const current = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, finalTime),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder,
|
||||
);
|
||||
let encounterDps: number | null = null;
|
||||
if (
|
||||
effects.some((effect) => effect.assumptions.mode === 'encounter') &&
|
||||
finalTime > 0
|
||||
) {
|
||||
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
||||
let total = 0;
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
const time = (finalTime * (index + 0.5)) / steps;
|
||||
const result = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, time),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder,
|
||||
);
|
||||
total += result.sustained * globalAssumptions.firingActivity;
|
||||
}
|
||||
encounterDps = total / steps;
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
missing: [],
|
||||
hitValues: current.values,
|
||||
burstDps: current.burst,
|
||||
sustainedDps: current.sustained,
|
||||
encounterDps,
|
||||
stackSummaries: effects.map((item) => {
|
||||
const end = stateAt(item, finalTime);
|
||||
const duration =
|
||||
item.assumptions.mode === 'encounter'
|
||||
? item.assumptions.encounterSeconds
|
||||
: 0;
|
||||
const samples =
|
||||
item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
sum += stateAt(item, (duration * (index + 0.5)) / samples).stacks;
|
||||
}
|
||||
return {
|
||||
effectId: item.effect.id,
|
||||
mode: item.assumptions.mode,
|
||||
stacksAtEnd: end.stacks,
|
||||
maxStacksReached: end.maxStacksReached,
|
||||
buildUpSeconds: end.buildUpSeconds,
|
||||
averageStacks: sum / samples,
|
||||
maintained:
|
||||
end.maxStacksReached === item.effect.lifecycle.maxStacks &&
|
||||
(item.assumptions.mode === 'peak' || end.stacks > 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,413 +1,571 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
TRIGGER_ACTION_REGISTRY,
|
||||
evaluateConditionalProgram,
|
||||
summarizeTrigger,
|
||||
validateConditionalProgram,
|
||||
validateTriggerConfiguration,
|
||||
type ConditionalEvent,
|
||||
type ConditionalProgram,
|
||||
type TriggerConfiguration
|
||||
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
|
||||
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> = {}
|
||||
trigger: TriggerConfiguration,
|
||||
overrides: Partial<ConditionalProgram> = {},
|
||||
): ConditionalProgram => ({
|
||||
id: 'test-effect',
|
||||
trigger,
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 10,
|
||||
maxStacks: 10,
|
||||
stacksPerActivation: 1
|
||||
},
|
||||
outcomes: [modifier],
|
||||
...overrides
|
||||
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
|
||||
at: number,
|
||||
targetId: string | null,
|
||||
hit = true,
|
||||
headshot = false,
|
||||
): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot });
|
||||
|
||||
describe('trigger action registry', () => {
|
||||
it('owns every action and validates discriminated parameters', () => {
|
||||
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
|
||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 9,
|
||||
windowSeconds: 3
|
||||
})).toMatchObject({ ok: true });
|
||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 0,
|
||||
windowSeconds: -1
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.']
|
||||
});
|
||||
expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false });
|
||||
expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({
|
||||
ok: false,
|
||||
errors: ['Distance must be greater than 0.']
|
||||
});
|
||||
});
|
||||
it('owns every action and validates discriminated parameters', () => {
|
||||
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
|
||||
expect(
|
||||
validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 9,
|
||||
windowSeconds: 3,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 0,
|
||||
windowSeconds: -1,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
errors: [
|
||||
'Hits required must be a positive integer.',
|
||||
'Window seconds must be greater than 0.',
|
||||
],
|
||||
});
|
||||
expect(
|
||||
validateTriggerConfiguration('while_firing', { unexpected: true }),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(
|
||||
validateTriggerConfiguration('target_hit_within_distance', { meters: 0 }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
errors: ['Distance must be greater than 0.'],
|
||||
});
|
||||
});
|
||||
|
||||
it('generates user-facing summaries from typed values', () => {
|
||||
expect(summarizeTrigger({
|
||||
action: 'distinct_enemies_within_window',
|
||||
params: { enemiesRequired: 4, windowSeconds: 2.5 }
|
||||
})).toBe('Hit 4 distinct enemies within 2.5 seconds.');
|
||||
expect(summarizeTrigger({
|
||||
action: 'continuous_firing_duration',
|
||||
params: { seconds: 1 }
|
||||
})).toBe('Fire continuously for 1 second.');
|
||||
expect(summarizeTrigger({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
})).toBe('Hit a target within 20 meters.');
|
||||
});
|
||||
it('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('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 });
|
||||
});
|
||||
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 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('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('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('starts cooldown on activation and applies the activation after its triggering event', () => {
|
||||
const result = evaluateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'headshot',
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
lifecycle: {
|
||||
cooldownSeconds: 3,
|
||||
durationSeconds: 5,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
[
|
||||
shot(1, 'alpha', true, true),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(4, 'alpha', true, true),
|
||||
],
|
||||
{ mode: 'encounter', encounterSeconds: 10 },
|
||||
);
|
||||
expect(result.activations).toHaveLength(2);
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([
|
||||
1, 4,
|
||||
]);
|
||||
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
|
||||
expect(result.activations[1]).toMatchObject({
|
||||
stacksBefore: 1,
|
||||
stacksAfter: 2,
|
||||
expiresAt: 9,
|
||||
cooldownUntil: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit non-headshot hit as a body shot', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'body_shot',
|
||||
params: {}
|
||||
}), [
|
||||
shot(1, 'alpha', true, false),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(3, 'alpha', false, false)
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
|
||||
});
|
||||
it('treats an explicit non-headshot hit as a body shot', () => {
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'body_shot',
|
||||
params: {},
|
||||
}),
|
||||
[
|
||||
shot(1, 'alpha', true, false),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(3, 'alpha', false, false),
|
||||
],
|
||||
{ mode: 'encounter', encounterSeconds: 5 },
|
||||
);
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('refreshes one shared duration and clears every stack on expiry', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'damage_taken',
|
||||
params: {}
|
||||
}, {
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 3,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
{ kind: 'damage_taken', at: 0 },
|
||||
{ kind: 'damage_taken', at: 1 },
|
||||
{ kind: 'damage_taken', at: 5 }
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([
|
||||
{ stacksBefore: 0, stacksAfter: 1 },
|
||||
{ stacksBefore: 1, stacksAfter: 2 },
|
||||
{ stacksBefore: 0, stacksAfter: 1 }
|
||||
]);
|
||||
expect(result.intervals).toEqual([
|
||||
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
|
||||
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
|
||||
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null }
|
||||
]);
|
||||
});
|
||||
it('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('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('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('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('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('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('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]);
|
||||
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);
|
||||
});
|
||||
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('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 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+1004
-816
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,186 +1,258 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
TRIGGER_ACTION_KEYS,
|
||||
validateTriggerConfiguration,
|
||||
type TriggerActionKey,
|
||||
type TriggerConfiguration
|
||||
TRIGGER_ACTION_KEYS,
|
||||
validateTriggerConfiguration,
|
||||
type TriggerActionKey,
|
||||
type TriggerConfiguration,
|
||||
} from './conditional.js';
|
||||
import {
|
||||
createMechanicRecipeForTrigger,
|
||||
MECHANIC_RECIPE_REGISTRY,
|
||||
type MechanicRecipeSeed
|
||||
createMechanicRecipeForTrigger,
|
||||
MECHANIC_RECIPE_REGISTRY,
|
||||
type MechanicRecipeSeed,
|
||||
} from './mechanic-recipes.js';
|
||||
import { validateMechanicProgram } from './mechanic.js';
|
||||
|
||||
const paramsByAction: Record<TriggerActionKey, unknown> = {
|
||||
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
|
||||
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
|
||||
consecutive_same_target_hits: { hitsRequired: 4 },
|
||||
continuous_firing_duration: { seconds: 2.5 },
|
||||
while_firing: {},
|
||||
health_threshold_bands: {
|
||||
bands: [
|
||||
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
||||
{ thresholdPercent: 50, outcomeKey: 'damaged' }
|
||||
]
|
||||
},
|
||||
headshot: {},
|
||||
body_shot: {},
|
||||
headshot_kill: {},
|
||||
weapon_kill: {},
|
||||
target_hit_within_distance: { meters: 15 },
|
||||
damage_taken: {},
|
||||
weapon_swap: {},
|
||||
reload: {},
|
||||
within_distance: { meters: 10 },
|
||||
extra_health_active: {}
|
||||
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
|
||||
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
|
||||
consecutive_same_target_hits: { hitsRequired: 4 },
|
||||
continuous_firing_duration: { seconds: 2.5 },
|
||||
while_firing: {},
|
||||
health_threshold_bands: {
|
||||
bands: [
|
||||
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
||||
{ thresholdPercent: 50, outcomeKey: 'damaged' },
|
||||
],
|
||||
},
|
||||
headshot: {},
|
||||
body_shot: {},
|
||||
headshot_kill: {},
|
||||
weapon_kill: {},
|
||||
target_hit_within_distance: { meters: 15 },
|
||||
damage_taken: {},
|
||||
weapon_swap: {},
|
||||
reload: {},
|
||||
within_distance: { meters: 10 },
|
||||
extra_health_active: {},
|
||||
};
|
||||
|
||||
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
|
||||
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
|
||||
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
|
||||
return trigger.value;
|
||||
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;
|
||||
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('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('produces a valid editable program for every supported recipe without hidden placeholders', () => {
|
||||
for (const action of TRIGGER_ACTION_KEYS) {
|
||||
const seed = seedFor(action);
|
||||
const validation = validateMechanicProgram({
|
||||
id: `recipe-${action}`,
|
||||
name: `Recipe ${action}`,
|
||||
...seed,
|
||||
});
|
||||
expect(validation, action).toMatchObject({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
|
||||
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
});
|
||||
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'distinct_count', threshold: 3, windowSeconds: 4,
|
||||
distinctBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
});
|
||||
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'consecutive_count', threshold: 4,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
});
|
||||
});
|
||||
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
|
||||
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'sliding_count',
|
||||
threshold: 3,
|
||||
windowSeconds: 2,
|
||||
reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
});
|
||||
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'distinct_count',
|
||||
threshold: 3,
|
||||
windowSeconds: 4,
|
||||
distinctBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
});
|
||||
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'consecutive_count',
|
||||
threshold: 4,
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps head and body shots to authoritative weapon-hit predicates', () => {
|
||||
for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) {
|
||||
expect(seedFor(action).clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.headshot' },
|
||||
operator: 'eq',
|
||||
right: headshot
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
it('maps head and body shots to authoritative weapon-hit predicates', () => {
|
||||
for (const [action, headshot] of [
|
||||
['headshot', true],
|
||||
['body_shot', false],
|
||||
] as const) {
|
||||
expect(seedFor(action).clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'values.headshot' },
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.headshot' },
|
||||
operator: 'eq',
|
||||
right: headshot,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('models firing triggers as explicit lifecycle branches', () => {
|
||||
const continuous = seedFor('continuous_firing_duration');
|
||||
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
||||
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
||||
'firing_started', 'firing_stopped', 'reload_started', 'tick'
|
||||
]);
|
||||
expect(continuous.clauses.at(-1)).toMatchObject({
|
||||
reducer: { kind: 'duration', seconds: 2.5 },
|
||||
if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 }
|
||||
});
|
||||
expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([
|
||||
'firing_started', 'firing_stopped', 'reload_started'
|
||||
]);
|
||||
});
|
||||
it('models firing triggers as explicit lifecycle branches', () => {
|
||||
const continuous = seedFor('continuous_firing_duration');
|
||||
expect(continuous.pools).toEqual([
|
||||
expect.objectContaining({ key: 'firing-active', max: 1 }),
|
||||
]);
|
||||
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
||||
'firing_started',
|
||||
'firing_stopped',
|
||||
'reload_started',
|
||||
'tick',
|
||||
]);
|
||||
expect(continuous.clauses.at(-1)).toMatchObject({
|
||||
reducer: { kind: 'duration', seconds: 2.5 },
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'firing-active' },
|
||||
operator: 'gt',
|
||||
right: 0,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
seedFor('while_firing').clauses.map((clause) => clause.when),
|
||||
).toEqual(['firing_started', 'firing_stopped', 'reload_started']);
|
||||
});
|
||||
|
||||
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
|
||||
const health = seedFor('health_threshold_bands');
|
||||
expect(health.pools?.map((pool) => pool.key)).toEqual(['health-band-1', 'health-band-2']);
|
||||
expect(health.clauses.map((clause) => clause.id)).toEqual([
|
||||
'select-health-band-1', 'select-health-band-2', 'clear-health-bands'
|
||||
]);
|
||||
expect(health.clauses[1].if).toMatchObject({
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
|
||||
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 }
|
||||
])
|
||||
});
|
||||
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
|
||||
expect(health.clauses[2].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'gte',
|
||||
right: 50
|
||||
});
|
||||
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
|
||||
const health = seedFor('health_threshold_bands');
|
||||
expect(health.pools?.map((pool) => pool.key)).toEqual([
|
||||
'health-band-1',
|
||||
'health-band-2',
|
||||
]);
|
||||
expect(health.clauses.map((clause) => clause.id)).toEqual([
|
||||
'select-health-band-1',
|
||||
'select-health-band-2',
|
||||
'clear-health-bands',
|
||||
]);
|
||||
expect(health.clauses[1].if).toMatchObject({
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'lt',
|
||||
right: 50,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'gte',
|
||||
right: 25,
|
||||
},
|
||||
]),
|
||||
});
|
||||
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
|
||||
expect(health.clauses[2].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'gte',
|
||||
right: 50,
|
||||
});
|
||||
|
||||
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 }
|
||||
])
|
||||
}
|
||||
});
|
||||
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 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.every((clause) => clause.when === 'extra_health_changed')).toBe(true);
|
||||
expect(extraHealth.clauses[0].if).toEqual({
|
||||
kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true
|
||||
});
|
||||
});
|
||||
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.every(
|
||||
(clause) => clause.when === 'extra_health_changed',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(extraHealth.clauses[0].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.hasExtraHealth' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,336 +1,471 @@
|
||||
import type {
|
||||
ConsecutiveHitsParams,
|
||||
ContinuousFiringParams,
|
||||
DistanceParams,
|
||||
DistinctEnemiesParams,
|
||||
HealthThresholdParams,
|
||||
SameTargetHitsParams,
|
||||
TriggerActionKey,
|
||||
TriggerConfiguration,
|
||||
TriggerParamsByAction
|
||||
ConsecutiveHitsParams,
|
||||
ContinuousFiringParams,
|
||||
DistanceParams,
|
||||
DistinctEnemiesParams,
|
||||
HealthThresholdParams,
|
||||
SameTargetHitsParams,
|
||||
TriggerActionKey,
|
||||
TriggerConfiguration,
|
||||
TriggerParamsByAction,
|
||||
} from './conditional.js';
|
||||
import type {
|
||||
MechanicClause,
|
||||
MechanicEventKind,
|
||||
MechanicProgram,
|
||||
PoolDefinition,
|
||||
Predicate,
|
||||
ValueReference
|
||||
MechanicClause,
|
||||
MechanicEventKind,
|
||||
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 MechanicRecipeSeed = Pick<
|
||||
MechanicProgram,
|
||||
'scenarioInputs' | 'pools' | 'clauses'
|
||||
>;
|
||||
|
||||
export type SupportedMechanicRecipe = {
|
||||
status: 'supported';
|
||||
seed: MechanicRecipeSeed;
|
||||
status: 'supported';
|
||||
seed: MechanicRecipeSeed;
|
||||
};
|
||||
|
||||
export type UnsupportedMechanicRecipe = {
|
||||
status: 'unsupported';
|
||||
reason: string;
|
||||
status: 'unsupported';
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||
export type MechanicRecipeResult =
|
||||
SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||
|
||||
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
|
||||
key: K;
|
||||
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
||||
export type MechanicRecipeDefinition<
|
||||
K extends TriggerActionKey = TriggerActionKey,
|
||||
> = {
|
||||
key: K;
|
||||
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
||||
};
|
||||
|
||||
export type MechanicRecipeRegistry = {
|
||||
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
|
||||
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
|
||||
};
|
||||
|
||||
const eventValue = (field: string): ValueReference => ({ source: 'event', path: `values.${field}` });
|
||||
const eventValue = (field: string): ValueReference => ({
|
||||
source: 'event',
|
||||
path: `values.${field}`,
|
||||
});
|
||||
const targetExists: Predicate = {
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' }
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' },
|
||||
};
|
||||
const noMutation = [{ kind: 'none' as const }];
|
||||
|
||||
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed });
|
||||
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({
|
||||
status: 'supported',
|
||||
seed,
|
||||
});
|
||||
|
||||
function directClause(
|
||||
id: string,
|
||||
when: MechanicClause['when'],
|
||||
ifPredicate?: Predicate
|
||||
id: string,
|
||||
when: MechanicClause['when'],
|
||||
ifPredicate?: Predicate,
|
||||
): MechanicClause {
|
||||
return { id, when, if: ifPredicate, reducer: { kind: 'none' }, mutations: noMutation };
|
||||
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 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 currentHealthPercent: ValueReference = {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current'
|
||||
};
|
||||
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: currentHealthPercent,
|
||||
operator: 'lt',
|
||||
right: band.thresholdPercent
|
||||
}];
|
||||
if (lowerBound !== undefined) {
|
||||
predicates.push({
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: lowerBound
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: `select-health-band-${index + 1}`,
|
||||
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
|
||||
when: 'tick',
|
||||
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: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key }))
|
||||
});
|
||||
return { pools, clauses };
|
||||
const sortedBands = [...params.bands].sort(
|
||||
(left, right) => left.thresholdPercent - right.thresholdPercent,
|
||||
);
|
||||
const currentHealthPercent: ValueReference = {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
};
|
||||
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: currentHealthPercent,
|
||||
operator: 'lt',
|
||||
right: band.thresholdPercent,
|
||||
},
|
||||
];
|
||||
if (lowerBound !== undefined) {
|
||||
predicates.push({
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: lowerBound,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: `select-health-band-${index + 1}`,
|
||||
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
|
||||
when: 'tick',
|
||||
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: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: pools.map((pool) => ({
|
||||
kind: 'clear' as const,
|
||||
pool: pool.key,
|
||||
})),
|
||||
});
|
||||
return { pools, clauses };
|
||||
}
|
||||
|
||||
function eventLifecycleSeed(
|
||||
pool: PoolDefinition,
|
||||
event: MechanicEventKind,
|
||||
activePredicate: Predicate,
|
||||
label: string
|
||||
pool: PoolDefinition,
|
||||
event: MechanicEventKind,
|
||||
activePredicate: Predicate,
|
||||
label: string,
|
||||
): MechanicRecipeSeed {
|
||||
return {
|
||||
pools: [pool],
|
||||
clauses: [
|
||||
{
|
||||
id: `enter-${pool.key}`,
|
||||
label: `Enter ${label}`,
|
||||
when: event,
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
|
||||
},
|
||||
{
|
||||
id: `exit-${pool.key}`,
|
||||
label: `Exit ${label}`,
|
||||
when: event,
|
||||
if: { kind: 'not', predicate: activePredicate },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: pool.key }]
|
||||
}
|
||||
]
|
||||
};
|
||||
return {
|
||||
pools: [pool],
|
||||
clauses: [
|
||||
{
|
||||
id: `enter-${pool.key}`,
|
||||
label: `Enter ${label}`,
|
||||
when: event,
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }],
|
||||
},
|
||||
{
|
||||
id: `exit-${pool.key}`,
|
||||
label: `Exit ${label}`,
|
||||
when: event,
|
||||
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: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{ kind: 'exists', value: eventValue('headshot') },
|
||||
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true }
|
||||
]
|
||||
})] })
|
||||
},
|
||||
body_shot: {
|
||||
key: 'body_shot',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{ kind: 'exists', value: eventValue('headshot') },
|
||||
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false }
|
||||
]
|
||||
})] })
|
||||
},
|
||||
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(eventLifecycleSeed(
|
||||
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
'proximity_snapshot',
|
||||
{ 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(eventLifecycleSeed(
|
||||
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
'extra_health_changed',
|
||||
{ kind: 'compare', left: eventValue('hasExtraHealth'), operator: 'eq', right: true },
|
||||
'Extra Health active'
|
||||
))
|
||||
}
|
||||
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: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{ kind: 'exists', value: eventValue('headshot') },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
body_shot: {
|
||||
key: 'body_shot',
|
||||
createSeed: (_params) =>
|
||||
supported({
|
||||
clauses: [
|
||||
directClause('body-shot', 'weapon_hit', {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{ kind: 'exists', value: eventValue('headshot') },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
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(
|
||||
eventLifecycleSeed(
|
||||
{
|
||||
key: 'target-within-distance',
|
||||
label: 'Target within distance',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
},
|
||||
'proximity_snapshot',
|
||||
{
|
||||
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(
|
||||
eventLifecycleSeed(
|
||||
{
|
||||
key: 'extra-health-active',
|
||||
label: 'Extra Health active',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
},
|
||||
'extra_health_changed',
|
||||
{
|
||||
kind: 'compare',
|
||||
left: eventValue('hasExtraHealth'),
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
'Extra Health active',
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -338,24 +473,28 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
* validated by the trigger family registry.
|
||||
*/
|
||||
export function createMechanicRecipe<K extends TriggerActionKey>(
|
||||
action: K,
|
||||
params: TriggerParamsByAction[K]
|
||||
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
|
||||
}))
|
||||
}
|
||||
};
|
||||
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);
|
||||
export function createMechanicRecipeForTrigger(
|
||||
trigger: TriggerConfiguration,
|
||||
): MechanicRecipeResult {
|
||||
return createMechanicRecipe(trigger.action, trigger.params);
|
||||
}
|
||||
|
||||
+2492
-1924
File diff suppressed because it is too large
Load Diff
+2765
-2065
File diff suppressed because it is too large
Load Diff
+319
-240
@@ -1,268 +1,347 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
type SkillAbilityInput,
|
||||
type SkillCalculationInput
|
||||
calculateSkillDps,
|
||||
type SkillAbilityInput,
|
||||
type SkillCalculationInput,
|
||||
} from './skill.js';
|
||||
|
||||
const directAbility = (
|
||||
overrides: Partial<SkillAbilityInput> = {}
|
||||
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
|
||||
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
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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'
|
||||
]));
|
||||
});
|
||||
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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+561
-456
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ConditionalMechanicDraftV1 } from './mechanic-editor';
|
||||
import { missingTalentDescriptionSlots, numberedTalentOutcomes } from './talent-description';
|
||||
import {
|
||||
missingTalentDescriptionSlots,
|
||||
numberedTalentOutcomes,
|
||||
} from './talent-description';
|
||||
|
||||
const draft = (outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes']): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [{
|
||||
key: 'primary',
|
||||
label: 'Primary',
|
||||
event: 'enemy_killed',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes
|
||||
}]
|
||||
const draft = (
|
||||
outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes'],
|
||||
): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [
|
||||
{
|
||||
key: 'primary',
|
||||
label: 'Primary',
|
||||
event: 'enemy_killed',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('talent description outcomes', () => {
|
||||
it('accepts a numbered restoration outcome as a description value', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'restore-health',
|
||||
label: 'Restore Health',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
basis: 'max_health_percent',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 }
|
||||
}
|
||||
}]));
|
||||
it('accepts a numbered restoration outcome as a description value', () => {
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'restore-health',
|
||||
label: 'Restore Health',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
basis: 'max_health_percent',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'restoration', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]);
|
||||
});
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({
|
||||
kind: 'restoration',
|
||||
descriptionSlot: 1,
|
||||
});
|
||||
expect(
|
||||
missingTalentDescriptionSlots('Restore {1} over time.', outcomes),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a numbered marker outcome for a structured timing value', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'delayed-reload',
|
||||
label: 'Delayed reload',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'equipped_weapon',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 }
|
||||
}
|
||||
}]));
|
||||
it('accepts a numbered marker outcome for a structured timing value', () => {
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'delayed-reload',
|
||||
label: 'Delayed reload',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'equipped_weapon',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
|
||||
});
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
|
||||
expect(
|
||||
missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a numbered weapon-reload delay', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'inactive-reload',
|
||||
label: 'Inactive weapon reload',
|
||||
phase: 'after_event',
|
||||
kind: 'weapon_reload',
|
||||
scope: 'inactive_weapon',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 }
|
||||
}
|
||||
}]));
|
||||
it('accepts a numbered weapon-reload delay', () => {
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'inactive-reload',
|
||||
label: 'Inactive weapon reload',
|
||||
phase: 'after_event',
|
||||
kind: 'weapon_reload',
|
||||
scope: 'inactive_weapon',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
|
||||
});
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({
|
||||
kind: 'weapon_reload',
|
||||
descriptionSlot: 1,
|
||||
});
|
||||
expect(
|
||||
missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports exact missing slots instead of relying on outcome count', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'damage',
|
||||
label: 'Damage',
|
||||
phase: 'on_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 2,
|
||||
variants: {
|
||||
I: { value: 10 },
|
||||
II: { value: 14 }
|
||||
}
|
||||
}]));
|
||||
it('reports exact missing slots instead of relying on outcome count', () => {
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'damage',
|
||||
label: 'Damage',
|
||||
phase: 'on_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 2,
|
||||
variants: {
|
||||
I: { value: 10 },
|
||||
II: { value: 14 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes)).toEqual([1, 3]);
|
||||
});
|
||||
expect(
|
||||
missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes),
|
||||
).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import type {
|
||||
ConditionalMechanicDraftV1,
|
||||
ConditionalMechanicOutcomeDraft
|
||||
ConditionalMechanicDraftV1,
|
||||
ConditionalMechanicOutcomeDraft,
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
|
||||
const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost',
|
||||
'weapon_reload',
|
||||
'marker'
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost',
|
||||
'weapon_reload',
|
||||
'marker',
|
||||
]);
|
||||
|
||||
export const numberedTalentOutcomes = (
|
||||
draft: ConditionalMechanicDraftV1
|
||||
draft: ConditionalMechanicDraftV1,
|
||||
): ConditionalMechanicOutcomeDraft[] => {
|
||||
const outcomes = draft.clauses
|
||||
.flatMap((clause) => clause.outcomes)
|
||||
.filter((outcome) =>
|
||||
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind)
|
||||
&& Number.isInteger(outcome.descriptionSlot)
|
||||
&& Number(outcome.descriptionSlot) > 0
|
||||
)
|
||||
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
|
||||
const outcomes = draft.clauses
|
||||
.flatMap((clause) => clause.outcomes)
|
||||
.filter(
|
||||
(outcome) =>
|
||||
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind) &&
|
||||
Number.isInteger(outcome.descriptionSlot) &&
|
||||
Number(outcome.descriptionSlot) > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.descriptionSlot) - Number(right.descriptionSlot),
|
||||
);
|
||||
|
||||
return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()];
|
||||
return [
|
||||
...new Map(
|
||||
outcomes.map((outcome) => [outcome.descriptionSlot, outcome]),
|
||||
).values(),
|
||||
];
|
||||
};
|
||||
|
||||
export const missingTalentDescriptionSlots = (
|
||||
template: string,
|
||||
outcomes: ConditionalMechanicOutcomeDraft[]
|
||||
template: string,
|
||||
outcomes: ConditionalMechanicOutcomeDraft[],
|
||||
) => {
|
||||
const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot)));
|
||||
return [...new Set(
|
||||
[...template.matchAll(/\{(\d+)\}/g)]
|
||||
.map((match) => Number(match[1]))
|
||||
.filter((slot) => !availableSlots.has(slot))
|
||||
)];
|
||||
const availableSlots = new Set(
|
||||
outcomes.map((outcome) => Number(outcome.descriptionSlot)),
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
[...template.matchAll(/\{(\d+)\}/g)]
|
||||
.map((match) => Number(match[1]))
|
||||
.filter((slot) => !availableSlots.has(slot)),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user