From 6af38024fac3f9433ad7650cb45ce88dea4af907 Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 7 Aug 2026 13:16:12 -0400 Subject: [PATCH] feat: add catalog weapon throughput baseline --- docs/DESIGN_SYSTEM.md | 29 +++- docs/DPS_SIMULATION_PROTOTYPE.md | 41 +++++ docs/architecture.md | 16 +- src/lib/builder/weapon-baseline.test.ts | 146 ++++++++++++++++++ src/lib/builder/weapon-baseline.ts | 112 ++++++++++++++ .../builder/WeaponBaselineThroughput.svelte | 97 ++++++++++++ src/routes/builder/+page.svelte | 24 +++ 7 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 docs/DPS_SIMULATION_PROTOTYPE.md create mode 100644 src/lib/builder/weapon-baseline.test.ts create mode 100644 src/lib/builder/weapon-baseline.ts create mode 100644 src/lib/components/builder/WeaponBaselineThroughput.svelte diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index cc62e6f..1a3f7fe 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -2595,10 +2595,33 @@ anchor scrolling and sticky-state transitions. #### Builder calculation modes The current Builder and shared Build pages do not expose calculation modes or -combat assumptions. They show deterministic equipped-item totals and reserve -conditional Talent outcomes, weapon and Skill DPS, encounter assumptions, +combat assumptions. The Builder summary rail additionally contains one local +experimental catalog baseline panel; shared Build pages do not render it. Keep +the panel in the summary rail only, with two independent cards labeled `PRIMARY` +and `SECONDARY`. + +Use the exact eyebrow `WEAPON OUTPUT / CATALOG BASELINE` and show the +`EXPERIMENTAL` state. Each card displays the catalog weapon ID, selected damage, +catalog RPM, magazine, reload, rated body-hit throughput, and magazine-cycle +body-hit throughput. Include a visible formula and exclusions block with these +exact formulas: + +- `selected damage × (RPM ÷ 60)` for rated body-hit throughput; +- `(magazine × selected damage) ÷ ((magazine ÷ (RPM ÷ 60)) + reload seconds)` + for magazine-cycle body-hit throughput. + +The panel is evidence-only. It excludes inferred or substituted values, combat +profiles, effects, Talents, Attribute totals, calculation profiles, accuracy, +critical hits, headshots, enemy mitigation, target count, encounter uptime, +rotations, and simulation. It must not present these values as actual, expected, +or encounter DPS. When a required source value is unavailable or invalid, show +`NOT AVAILABLE` and list the missing source fields under the metric; never +display zero as a placeholder. + +The panel does not alter the planned calculation modes or full combat panels. +Keep conditional Talent outcomes, weapon and Skill DPS, encounter assumptions, activation profiles, rotations, and simulation behind explicit `COMING SOON` -panels. +panels. Calculation modes remain planned and use these visible labels: Loadout schema version 6 retains `maximum`, `custom`, conditional state, encounter-assumption, and per-ability activation fields. Version 4 is obsolete diff --git a/docs/DPS_SIMULATION_PROTOTYPE.md b/docs/DPS_SIMULATION_PROTOTYPE.md new file mode 100644 index 0000000..4e5f83d --- /dev/null +++ b/docs/DPS_SIMULATION_PROTOTYPE.md @@ -0,0 +1,41 @@ +# Weapon baseline prototype + +## Scope + +`calculateWeaponBaseline` is a pure, client-safe Builder helper. It records +weapon ID, name, and label, then reports three evidence-only metrics: + +- `selectedDamage`: the supplied positive finite damage value. +- `ratedBodyHitThroughput`: `selectedDamage × rpm / 60`. +- `magazineCycleBodyHitThroughput`: + `(selectedDamage × magazine) / (magazine / (rpm / 60) + reloadSeconds)`. + +Unknown, absent, non-positive, non-finite, or invalid source values make only +the metrics that depend on them unavailable. The helper preserves decimal inputs +and does not substitute values. + +These are not actual, expected, or encounter DPS. They contain no hit +probability, critical or headshot outcomes, target state, or encounter timeline. +The rated metric is nominal continuous body-hit throughput; the magazine-cycle +metric includes the supplied reload interval. + +## Excluded inputs + +This helper does not read combat profiles, effects, talents, Attribute totals, +calculation profiles, encounter assumptions, or any other build inputs. It does +not call the simulation or combat engines. + +`selectedDamage` is already the equipped weapon damage used at this boundary. It +is consumed exactly once; no additional level scaling or damage scaling is +applied. + +## Missing data for a DPS model + +A valid DPS model still needs the authoritative damage calculation and its +modifiers, hit and critical/headshot assumptions, firing and reload behavior, +target and encounter assumptions, and the timing and contribution of talents, +effects, Attributes, skills, and weapon swaps. Those data are outside this +baseline. + +This is Builder-only, local prototype work. It does not define the player DPS +simulation contract or change the existing calculation architecture. diff --git a/docs/architecture.md b/docs/architecture.md index c5d16cb..49117df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,12 +25,16 @@ The team builds the project in vertical slices. Server code checks authorization for each mutation. UI visibility has no effect on server authorization. -Builder and shared Build pages currently expose only deterministic totals from -the equipped items, selected rolls, fixed bonuses, inherited profiles, augments, -and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS, -encounter assumptions, activation profiles, rotations, and simulation remain -planned. Explicit `COMING SOON` panels identify those player-facing -capabilities. +Builder and shared Build pages expose deterministic totals from the equipped +items, selected rolls, fixed bonuses, inherited profiles, augments, and active +set bonuses. Builder additionally shows an experimental catalog baseline for +each selected weapon: body-hit throughput from selected weapon damage and +catalog RPM, magazine, and reload values only. It is not expected or encounter +DPS, does not infer or substitute missing values, reports blocked metrics with +their source fields, and does not change shared Build behavior. Conditional +Talent outcomes, full weapon and Skill DPS, encounter assumptions, activation +profiles, rotations, and simulation remain planned. Explicit `COMING SOON` +panels identify those player-facing capabilities. Weapon and gear range calculations must preserve decimal endpoints and follow the tested progression rules in diff --git a/src/lib/builder/weapon-baseline.test.ts b/src/lib/builder/weapon-baseline.test.ts new file mode 100644 index 0000000..ea9012d --- /dev/null +++ b/src/lib/builder/weapon-baseline.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateWeaponBaseline, + type WeaponBaselineInput, +} from './weapon-baseline.js'; + +const input = ( + overrides: Partial = {}, +): WeaponBaselineInput => ({ + weaponId: 'weapon-17', + weaponName: 'Evidence Rifle', + weaponLabel: 'PRIMARY', + selectedDamage: 12.75, + rpm: 137.5, + magazine: 31, + reloadSeconds: 2.35, + ...overrides, +}); + +describe('calculateWeaponBaseline', () => { + it('preserves decimal inputs in the selected and rated metrics', () => { + const baseline = calculateWeaponBaseline(input()); + + expect(baseline.selectedDamage).toEqual({ value: 12.75, missing: [] }); + expect(baseline.ratedBodyHitThroughput).toEqual({ + value: 29.21875, + missing: [], + }); + }); + + it('keeps rated throughput available when reload inputs are missing', () => { + const baseline = calculateWeaponBaseline( + input({ magazine: undefined, reloadSeconds: undefined }), + ); + + expect(baseline.ratedBodyHitThroughput).toEqual({ + value: 29.21875, + missing: [], + }); + expect(baseline.magazineCycleBodyHitThroughput).toEqual({ + value: null, + missing: [ + 'magazine is unavailable for weapon weapon-17', + 'reloadSeconds is unavailable for weapon weapon-17', + ], + }); + }); + + it('uses the complete magazine-cycle formula without rounding', () => { + const baseline = calculateWeaponBaseline( + input({ + selectedDamage: 13.25, + rpm: 97.5, + magazine: 17.5, + reloadSeconds: 2.75, + }), + ); + const expected = (13.25 * 17.5) / (17.5 / (97.5 / 60) + 2.75); + + expect(baseline.magazineCycleBodyHitThroughput).toEqual({ + value: expected, + missing: [], + }); + }); + + it('uses selected damage exactly once at the no-double-scaling boundary', () => { + const baseline = calculateWeaponBaseline( + input({ selectedDamage: 12.5, rpm: 120, magazine: 10, reloadSeconds: 5 }), + ); + + expect(baseline.selectedDamage.value).toBe(12.5); + expect(baseline.ratedBodyHitThroughput.value).toBe(12.5 * (120 / 60)); + expect(baseline.magazineCycleBodyHitThroughput.value).toBe( + (12.5 * 10) / (10 / (120 / 60) + 5), + ); + expect(baseline.ratedBodyHitThroughput.value).not.toBe( + 12.5 * 12.5 * (120 / 60), + ); + expect(baseline.magazineCycleBodyHitThroughput.value).not.toBe(12.5 * 12.5); + }); + + it.each([ + null, + undefined, + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ])('rejects invalid selected damage: %s', (selectedDamage) => { + const baseline = calculateWeaponBaseline(input({ selectedDamage })); + + expect(baseline.selectedDamage.value).toBeNull(); + expect(baseline.ratedBodyHitThroughput.value).toBeNull(); + expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull(); + expect(baseline.selectedDamage.missing[0]).toContain('selectedDamage'); + expect(baseline.selectedDamage.missing[0]).toContain('weapon-17'); + for (const metric of [ + baseline.selectedDamage, + baseline.ratedBodyHitThroughput, + baseline.magazineCycleBodyHitThroughput, + ]) { + expect(metric.value).toBeNull(); + expect(metric.value).not.toBeNaN(); + expect(metric.value).not.toBe(Infinity); + expect(metric.value).not.toBe(-Infinity); + } + }); + + it.each([ + ['rpm', null], + ['rpm', 0], + ['rpm', Number.NaN], + ['magazine', -1], + ['reloadSeconds', Number.POSITIVE_INFINITY], + ] as const)( + 'makes only dependent metrics unavailable for invalid %s', + (field, value) => { + const baseline = calculateWeaponBaseline(input({ [field]: value })); + + expect(baseline.selectedDamage.value).toBe(12.75); + if (field === 'rpm') { + expect(baseline.ratedBodyHitThroughput.value).toBeNull(); + expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull(); + } else if (field === 'magazine' || field === 'reloadSeconds') { + expect(baseline.ratedBodyHitThroughput.value).toBe(29.21875); + expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull(); + } + + const dependent = + field === 'rpm' + ? [ + baseline.ratedBodyHitThroughput, + baseline.magazineCycleBodyHitThroughput, + ] + : [baseline.magazineCycleBodyHitThroughput]; + for (const metric of dependent) { + expect(metric.value).not.toBeNaN(); + expect(metric.value).not.toBe(Infinity); + expect(metric.value).not.toBe(-Infinity); + expect(metric.missing[0]).toContain(field); + expect(metric.missing[0]).toContain('weapon-17'); + } + }, + ); +}); diff --git a/src/lib/builder/weapon-baseline.ts b/src/lib/builder/weapon-baseline.ts new file mode 100644 index 0000000..cbc5917 --- /dev/null +++ b/src/lib/builder/weapon-baseline.ts @@ -0,0 +1,112 @@ +type NumericSource = number | null | undefined; + +export type WeaponBaselineInput = { + weaponId: string; + weaponName: string; + weaponLabel: string; + selectedDamage?: NumericSource; + rpm?: NumericSource; + magazine?: NumericSource; + reloadSeconds?: NumericSource; +}; + +export type BaselineMetric = { + value: number | null; + missing: string[]; +}; + +export type WeaponBaseline = { + weaponId: string; + weaponName: string; + weaponLabel: string; + selectedDamage: BaselineMetric; + ratedBodyHitThroughput: BaselineMetric; + magazineCycleBodyHitThroughput: BaselineMetric; +}; + +const isPositiveFinite = (value: NumericSource): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0; + +const missingField = (field: string, weaponId: string): string => + `${field} is unavailable for weapon ${weaponId}`; + +const unavailableMetric = ( + fields: string[], + weaponId: string, +): BaselineMetric => ({ + value: null, + missing: fields.map((field) => missingField(field, weaponId)), +}); + +const calculatedMetric = ( + value: number, + metricField: string, + weaponId: string, +): BaselineMetric => + Number.isFinite(value) + ? { value, missing: [] } + : { value: null, missing: [missingField(metricField, weaponId)] }; + +export function calculateWeaponBaseline( + input: WeaponBaselineInput, +): WeaponBaseline { + const damageAvailable = isPositiveFinite(input.selectedDamage); + const rpmAvailable = isPositiveFinite(input.rpm); + const magazineAvailable = isPositiveFinite(input.magazine); + const reloadAvailable = isPositiveFinite(input.reloadSeconds); + const damage = damageAvailable ? (input.selectedDamage as number) : null; + const rpm = rpmAvailable ? (input.rpm as number) : null; + const magazine = magazineAvailable ? (input.magazine as number) : null; + const reloadSeconds = reloadAvailable + ? (input.reloadSeconds as number) + : null; + + const selectedDamage = + damage !== null + ? calculatedMetric(damage, 'selectedDamage', input.weaponId) + : unavailableMetric(['selectedDamage'], input.weaponId); + + const ratedBodyHitThroughput = + damage !== null && rpm !== null + ? calculatedMetric( + (damage * rpm) / 60, + 'ratedBodyHitThroughput', + input.weaponId, + ) + : unavailableMetric( + [ + ...(!damageAvailable ? ['selectedDamage'] : []), + ...(!rpmAvailable ? ['rpm'] : []), + ], + input.weaponId, + ); + + const magazineCycleBodyHitThroughput = + damage !== null && + rpm !== null && + magazine !== null && + reloadSeconds !== null + ? calculatedMetric( + (damage * magazine) / (magazine / (rpm / 60) + reloadSeconds), + 'magazineCycleBodyHitThroughput', + input.weaponId, + ) + : unavailableMetric( + [ + ...(!damageAvailable ? ['selectedDamage'] : []), + ...(!rpmAvailable ? ['rpm'] : []), + ...(!magazineAvailable ? ['magazine'] : []), + ...(!reloadAvailable ? ['reloadSeconds'] : []), + ], + input.weaponId, + ); + + return { + weaponId: input.weaponId, + weaponName: input.weaponName, + weaponLabel: input.weaponLabel, + selectedDamage, + ratedBodyHitThroughput, + magazineCycleBodyHitThroughput, + }; +} diff --git a/src/lib/components/builder/WeaponBaselineThroughput.svelte b/src/lib/components/builder/WeaponBaselineThroughput.svelte new file mode 100644 index 0000000..2552e0b --- /dev/null +++ b/src/lib/components/builder/WeaponBaselineThroughput.svelte @@ -0,0 +1,97 @@ + + +
+
+
+

WEAPON OUTPUT / CATALOG BASELINE

+

Weapon output

+
+ EXPERIMENTAL +
+

Strict evidence-only throughput from the selected catalog weapon and selected damage.

+
+ {#each entries as entry} +
+
+
+ {entry.slot} +

{entry.name}

+
+ {entry.id || 'NO CATALOG ID'} +
+
+ SELECTED DAMAGE{numberValue(entry.baseline.selectedDamage.value)} + RPM{numberValue(entry.rpm)} + MAGAZINE{numberValue(entry.magazine, ' SHOTS')} + RELOAD{numberValue(entry.reloadSpeed, ' SEC')} +
+
+ {#each [ + { label: 'RATED BODY-HIT THROUGHPUT', metric: entry.baseline.ratedBodyHitThroughput }, + { label: 'MAGAZINE-CYCLE BODY-HIT THROUGHPUT', metric: entry.baseline.magazineCycleBodyHitThroughput } + ] as output} +
+ {output.label}PER SECOND + {metricValue(output.metric)} + {#if output.metric.value === null} +

MISSING FIELDS

+
    {#each output.metric.missing as field}
  • {field}
  • {/each}
+ {/if} +
+ {/each} +
+
+ {/each} +
+
+

FORMULAS Selected damage × (RPM ÷ 60)

+

Magazine × selected damage ÷ ((Magazine ÷ (RPM ÷ 60)) + reload seconds)

+

This does not calculate accuracy, critical hits, headshots, Talents, enemy mitigation, rotations, target count, or encounter uptime.

+
+

Source provenance: each result shows its catalog weapon ID above.

+
+ + diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index f526758..971f143 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -11,9 +11,11 @@ import BuildShareControl from '$lib/components/BuildShareControl.svelte'; import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte'; import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte'; + import WeaponBaselineThroughput from '$lib/components/builder/WeaponBaselineThroughput.svelte'; import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte'; import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte'; import { summarizeLoadout } from '$lib/builder/loadout-summary'; + import { calculateWeaponBaseline } from '$lib/builder/weapon-baseline'; import { toast } from '$lib/toast'; import { blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange, @@ -340,6 +342,27 @@ }; const activeParts = $derived(active.split(':')); const summary = $derived(summarizeLoadout(draft, data)); + const weaponBaselines = $derived(draft.weapons.map((selection, index) => { + const item = data.weapons.find((entry) => entry.id === selection.itemId); + return { + slot: index ? 'SECONDARY' as const : 'PRIMARY' as const, + id: String(item?.id ?? ''), + name: item?.name ?? 'UNASSIGNED', + damage: Number.isFinite(Number(selection.damage)) ? Number(selection.damage) : null, + rpm: item?.rpm == null ? null : Number(item.rpm), + magazine: item?.magazine == null ? null : Number(item.magazine), + reloadSpeed: item?.reload_speed == null ? null : Number(item.reload_speed), + baseline: calculateWeaponBaseline({ + weaponId: String(item?.id ?? 'NO CATALOG ID'), + weaponName: item?.name, + weaponLabel: index ? 'SECONDARY' : 'PRIMARY', + selectedDamage: selection.damage, + rpm: item?.rpm, + magazine: item?.magazine, + reloadSeconds: item?.reload_speed + }) + }; + })); const rollSummary = (rolls: Roll[]) => rolls .filter((roll) => roll.statId) .map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`) @@ -674,6 +697,7 @@