Files
shd.build/src/lib/builder/loadout-combat.test.ts
T
jb 7ca6cb8458
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
Updating mechanics for OS protocols
2026-08-03 16:12:20 -04:00

739 lines
20 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
evaluateMechanicProgram,
type MechanicProgram,
} from '$lib/calculations/mechanic';
import { createDraft } from './loadout.js';
import {
buildSkillDeploymentEvents,
buildWeaponMechanicTimeline,
equippedConditionalProgram,
mechanicModifiersAt,
skillModifierWindowsForMechanic,
weaponCriticalChance,
} from './loadout-combat.js';
import {
calculateSkillDps,
type SkillAbilityInput,
} from '$lib/calculations/skill';
describe('loadout mechanic timeline', () => {
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
const profile = createDraft().combatProfile;
profile.encounterSeconds = encounterSeconds;
profile.hitRate = 0;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
return buildWeaponMechanicTimeline(
{ rpm: 60, magazine, reload_speed: 0 },
profile,
)
.filter((event) => event.kind === 'shot_fired')
.map((event) => event.values?.magazineTwentyPercentBandsCrossed);
}
it('emits deterministic critical hits from modeled critical chance', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 4;
profile.hitRate = 100;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
const timeline = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
50,
);
const criticalHits = timeline
.filter((event) => event.kind === 'weapon_hit')
.map((event) => event.values?.critical);
expect(criticalHits).toEqual([false, true, false, true, false]);
});
it('marks every fired shot with its deterministic hit result', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 4;
profile.hitRate = 50;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
const hitResults = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
)
.filter((event) => event.kind === 'shot_fired')
.map((event) => event.values?.hit);
expect(hitResults).toEqual([false, true, false, true, false]);
});
it('marks enemy kills from deterministic critical killing shots', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 30;
profile.killsPerMinute = 4;
const criticalKills = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
50,
)
.filter((event) => event.kind === 'enemy_killed')
.map((event) => event.values?.critical);
expect(criticalKills).toEqual([false, true]);
});
it('marks enemy kills from the configured headshot-kill rate', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 30;
profile.killsPerMinute = 8;
profile.headshotKillRate = 50;
const headshotKills = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
)
.filter((event) => event.kind === 'enemy_killed')
.map((event) => event.values?.headshot);
expect(headshotKills).toEqual([false, true, false, true]);
});
it('marks reload starts from the pre-reload empty-magazine state', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 2;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
const emptyStates = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 2, reload_speed: 5 },
profile,
)
.filter((event) => event.kind === 'reload_started')
.map((event) => event.values?.emptyMagazine);
expect(emptyStates).toEqual([true]);
});
it('emits post-shot 20% magazine-band crossings for an even magazine', () => {
expect(magazineBandsFor(10)).toEqual([0, 1, 0, 1, 0, 1, 0, 1, 0, 0]);
});
it.each([
{ magazine: 7, expected: [0, 1, 1, 0, 1, 1, 0] },
{ magazine: 3, expected: [1, 2, 1] },
])(
'counts every crossed band for an odd/small $magazine-round magazine',
({ magazine, expected }) => {
expect(magazineBandsFor(magazine)).toEqual(expected);
},
);
it('restarts magazine-band crossings after reloading', () => {
expect(magazineBandsFor(5, 5)).toEqual([1, 1, 1, 1, 0, 1]);
});
it('derives critical chance through the configured binding transform', () => {
expect(
weaponCriticalChance({ critical: 80 }, [
{
attributeId: 'critical',
stage: 'critical_chance',
operator: 'add',
weight: 0.5,
hardCap: 30,
curveType: 'none',
},
]),
).toBe(30);
});
it('publishes one standardized movement state and lets sprinting win', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 1;
const moving = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
0,
{ scenarioInputs: { moving: true, sprinting: false } },
).filter((event) => event.kind === 'movement_snapshot');
const sprinting = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
0,
{ scenarioInputs: { moving: true, sprinting: true } },
).filter((event) => event.kind === 'movement_snapshot');
expect(moving.map((event) => event.values)).toEqual([
{ state: 'moving' },
{ state: 'moving' },
]);
expect(
sprinting.every((event) => event.values?.state === 'sprinting'),
).toBe(true);
});
it('seeds Extra Health state before periodic stack evaluation', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 2;
profile.extraHealthActive = true;
const timeline = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
);
expect(
timeline.filter((event) => event.kind === 'extra_health_changed'),
).toEqual([
{
kind: 'extra_health_changed',
at: 0,
actorId: 'agent',
values: { hasExtraHealth: true },
},
]);
const program: MechanicProgram = {
id: 'extra-health-periodic-stacks',
name: 'Extra Health periodic stacks',
pools: [
{
key: 'active',
label: 'Active',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
},
{
key: 'stacks',
label: 'Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
},
],
clauses: [
{
id: 'set-active',
when: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
},
{
id: 'gain-stack',
when: 'tick',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'active' },
operator: 'eq',
right: 1,
},
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
},
],
};
const result = evaluateMechanicProgram(program, timeline, {
mode: 'custom',
encounterSeconds: 2,
});
expect(result.finalState).toEqual(
expect.arrayContaining([
expect.objectContaining({ pool: 'stacks', value: 2 }),
]),
);
expect(
result.emissions
.filter((emission) => emission.clauseId === 'gain-stack')
.map((emission) => emission.at),
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
source_type: 'talent',
source_id: 'exotic-talent',
configured: 1,
script_version: 1,
simulation_readiness: 'ready',
scenario_inputs_json: '[]',
},
statePools: [],
clauses: [
{
id: 'shot-clause',
rule_id: 'tier-i-only',
clause_key: 'shot',
label: 'Shot',
trigger_id: null,
event_kind: 'shot_fired',
parameters_json: '{}',
conditions_json: JSON.stringify({ kind: 'true' }),
reducer_json: JSON.stringify({ kind: 'none' }),
emission_kind: 'each_match',
state_pool_id: null,
mutations_json: '[]',
cooldown_seconds: null,
output_branch: 'active',
sort_order: 1,
active: 1,
},
],
outcomes: [
{
id: 'weapon-damage-i',
rule_id: 'tier-i-only',
clause_id: 'shot-clause',
state_pool_id: null,
effect_key: 'Weapon Damage',
variant_key: 'I',
outcome_key: 'weapon-damage',
application_kind: 'active_modifier',
effect_scope: 'equipped_weapon',
stat_attribute_id: 'weapon-damage',
value: 15,
value_expression_json: null,
value_basis: 'flat',
resource_kind: null,
combat_timing: null,
delivery_mode: 'instant',
delivery_duration_seconds: null,
targeting_json: null,
value_scaling: 'flat',
unit: '%',
damage_basis: null,
application_phase: 'on_event',
min_state_value: null,
max_state_value: null,
description_slot: 1,
sort_order: 1,
},
],
triggers: [],
};
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
rule: persistedRule,
});
expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({
id: 'weapon-damage-i',
value: 15,
});
});
});
describe('movement-charge exotic mechanic wiring', () => {
const ability: SkillAbilityInput = {
id: 'skill-a',
baseDamage: 100,
damageType: 'kinetic',
delivery: { type: 'direct', hits: 1 },
castSeconds: 0,
cooldownSeconds: 10,
targetCap: 1,
targetCount: 1,
chargePolicy: { type: 'none' },
activation: { mode: 'on_cooldown' },
};
const program: MechanicProgram = {
id: 'movement-charge-exotic',
name: 'Movement Charge Exotic',
scenarioInputs: [
{
key: 'moving',
label: 'Moving',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
{
key: 'sprinting',
label: 'Sprinting',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{
key: 'movement-charge',
label: 'Movement charge',
scope: 'agent',
initial: 0,
min: 0,
max: 40,
bestCaseValue: 40,
},
{
key: 'kill-speed',
label: 'Kill movement speed',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'gain-while-moving',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'moving',
},
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }],
},
{
id: 'gain-while-sprinting',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'sprinting',
},
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }],
},
{
id: 'spend-on-shot',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'movement-charge' },
operator: 'gt',
right: 0,
},
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'movement-charge', value: 1 }],
outcomes: [
{
id: 'shot-weapon-damage',
label: 'Shot Weapon Damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 15,
basis: 'flat',
attributeId: 'weapon-damage',
},
],
},
{
id: 'spend-on-skill',
when: 'skill_deployed',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'movement-charge' },
operator: 'gte',
right: 10,
},
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'movement-charge', value: 10 }],
outcomes: [
{
id: 'deployment-skill-damage',
label: 'Deployment Skill Damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value: 15,
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
{
id: 'speed-on-kill',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'kill-speed', value: 1 }],
outcomes: [
{
id: 'kill-movement-speed',
label: 'Kill Movement Speed',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
value: 10,
basis: 'flat',
attributeId: 'movement-speed',
statePool: 'kill-speed',
},
],
},
],
};
it('gains stacks, consumes them on shots and eligible deployments, and applies timed outcomes', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 15;
const skillDeployments = buildSkillDeploymentEvents([ability], 15);
const events = [
...buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
0,
{
scenarioInputs: { moving: true, sprinting: false },
skillDeploymentEvents: skillDeployments,
},
).filter(
(event) =>
event.kind === 'movement_snapshot' || event.kind === 'skill_deployed',
),
{ kind: 'shot_fired' as const, at: 3.1 },
{ kind: 'enemy_killed' as const, at: 4 },
];
const result = evaluateMechanicProgram(program, events, {
mode: 'custom',
encounterSeconds: 15,
inputs: { moving: true, sprinting: false },
});
expect(skillDeployments).toEqual([
{
kind: 'skill_deployed',
at: 0,
targetId: 'skill-a',
values: { abilityId: 'skill-a' },
},
{
kind: 'skill_deployed',
at: 10,
targetId: 'skill-a',
values: { abilityId: 'skill-a' },
},
]);
expect(result.status).toBe('OK');
expect(
result.emissions.filter(
(emission) => emission.clauseId === 'spend-on-skill',
),
).toHaveLength(1);
expect(
result.emissions.find((emission) => emission.clauseId === 'spend-on-shot')
?.mutations[0],
).toMatchObject({ before: 12, after: 11 });
expect(
result.emissions.find(
(emission) => emission.clauseId === 'spend-on-skill',
)?.mutations[0],
).toMatchObject({ before: 39, after: 29 });
expect(result.outcomes).toEqual(
expect.arrayContaining([
expect.objectContaining({
outcomeId: 'shot-weapon-damage',
at: 3.1,
appliedValue: 15,
}),
expect.objectContaining({
outcomeId: 'deployment-skill-damage',
at: 10,
targetId: 'skill-a',
appliedValue: 15,
}),
expect.objectContaining({
outcomeId: 'kill-movement-speed',
at: 4,
appliedValue: 10,
}),
]),
);
expect(result.finalState).toContainEqual(
expect.objectContaining({
pool: 'kill-speed',
value: 0,
expiresAt: null,
}),
);
const source = {
status: 'OK' as const,
result,
program,
encounterSeconds: 15,
events,
};
const skillWindows = skillModifierWindowsForMechanic(
source,
null,
new Set(['skill-a']),
new Set(['skill-damage']),
);
const movementWindows = skillModifierWindowsForMechanic(
source,
null,
new Set(['skill-a']),
new Set(['movement-speed']),
);
expect(skillWindows).toEqual([
{
startSeconds: 10,
endSeconds: 15,
attributes: { 'skill-damage': 15 },
abilityIds: ['skill-a'],
},
]);
expect(movementWindows).toEqual([
{
startSeconds: 4,
endSeconds: 14,
attributes: { 'movement-speed': 10 },
abilityIds: undefined,
},
]);
const skillResult = calculateSkillDps({
rules: {
revisionId: 'test',
bindings: [
{
attributeId: 'skill-damage',
stage: 'skill_damage',
operator: 'add',
},
],
},
abilities: [ability],
attributes: {},
modifierWindows: skillWindows,
assumptions: { encounterSeconds: 15, targetCount: 1 },
});
expect(skillResult.status).toBe('OK');
if (skillResult.status === 'OK') {
expect(skillResult.abilities[0].activationCount).toBe(2);
expect(skillResult.abilities[0].damage.singleTargetEncounterDamage).toBe(
215,
);
}
});
});
describe('per-shot mechanic modifiers', () => {
const program: MechanicProgram = {
id: 'phase-order',
name: 'Phase order',
pools: [
{
key: 'stacks',
label: 'Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'stack-on-hit',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
outcomes: [
{
id: 'instant-a',
label: 'Instant A',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 2,
basis: 'flat',
attributeId: 'weapon-damage',
},
{
id: 'after-hit',
label: 'After hit',
phase: 'after_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 100,
basis: 'flat',
attributeId: 'weapon-damage',
},
{
id: 'active-stacks',
label: 'Active stacks',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 3,
basis: 'flat',
attributeId: 'weapon-damage',
statePool: 'stacks',
perStack: true,
},
],
},
{
id: 'second-instant',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'instant-b',
label: 'Instant B',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
attributeId: 'weapon-damage',
},
],
},
],
};
const evaluation = evaluateMechanicProgram(
program,
[
{ kind: 'weapon_hit', at: 1, targetId: 'target' },
{ kind: 'weapon_hit', at: 2, targetId: 'target' },
],
{ mode: 'custom', encounterSeconds: 5 },
);
const source = {
status: 'OK' as const,
result: evaluation,
program,
encounterSeconds: 5,
};
it('combines same-attribute on-event effects and excludes after-event effects', () => {
expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([
{
attributeId: 'weapon-damage',
scope: 'equipped_weapon',
value: 7,
},
]);
});
it('uses the pre-hit active stack state when the triggering hit adds another stack', () => {
expect(mechanicModifiersAt(source, 1.5, 'weapon_hit')[0]?.value).toBe(3);
expect(mechanicModifiersAt(source, 2, 'weapon_hit')[0]?.value).toBe(10);
});
});