Adding skills & isac talent updates
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user