739 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|