feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic';
import { createDraft } from './loadout.js';
import {
buildWeaponMechanicTimeline,
mechanicModifiersAt,
weaponCriticalChance
} from './loadout-combat.js';
describe('loadout mechanic timeline', () => {
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('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);
});
});
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);
});
});