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); }); });