Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
+460
View File
@@ -2,12 +2,31 @@ 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;
@@ -27,6 +46,86 @@ describe('loadout mechanic timeline', () => {
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 },
@@ -40,6 +139,367 @@ describe('loadout mechanic timeline', () => {
}]
)).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('falls back to a documented Tier I mechanic when equipment 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,
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', () => {