feat: add catalog weapon throughput baseline

This commit is contained in:
jb
2026-08-07 13:16:12 -04:00
parent 4777a3abd5
commit 6af38024fa
7 changed files with 456 additions and 9 deletions
+26 -3
View File
@@ -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
+41
View File
@@ -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.
+10 -6
View File
@@ -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
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
calculateWeaponBaseline,
type WeaponBaselineInput,
} from './weapon-baseline.js';
const input = (
overrides: Partial<WeaponBaselineInput> = {},
): 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');
}
},
);
});
+112
View File
@@ -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,
};
}
@@ -0,0 +1,97 @@
<script lang="ts">
import type { WeaponBaseline } from '$lib/builder/weapon-baseline';
type WeaponBaselineEntry = {
slot: 'PRIMARY' | 'SECONDARY';
id: string;
name: string;
damage: number | null;
rpm: number | null;
magazine: number | null;
reloadSpeed: number | null;
baseline: WeaponBaseline;
};
let { entries, headingId }: { entries: WeaponBaselineEntry[]; headingId: string } = $props();
const numberValue = (value: number | null, suffix = '') =>
value === null || !Number.isFinite(value) || value <= 0 ? 'NOT AVAILABLE' : `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`;
const metricValue = (metric: { value: number | null; missing: string[] }) => numberValue(metric.value, ' /s');
</script>
<section class="weapon-baseline" aria-labelledby={headingId}>
<header class="baseline-head">
<div>
<p class="eyebrow"><span></span> WEAPON OUTPUT / CATALOG BASELINE</p>
<h2 id={headingId}>Weapon output</h2>
</div>
<strong>EXPERIMENTAL</strong>
</header>
<p class="baseline-intro">Strict evidence-only throughput from the selected catalog weapon and selected damage.</p>
<div class="baseline-results" aria-live="polite" aria-atomic="false">
{#each entries as entry}
<article class="weapon-result" aria-labelledby={`${headingId}-${entry.slot.toLowerCase()}`}>
<header class="weapon-result-head">
<div>
<span>{entry.slot}</span>
<h3 id={`${headingId}-${entry.slot.toLowerCase()}`}>{entry.name}</h3>
</div>
<code>{entry.id || 'NO CATALOG ID'}</code>
</header>
<div class="baseline-inputs" aria-label={`${entry.slot} baseline inputs`}>
<span><small>SELECTED DAMAGE</small><b>{numberValue(entry.baseline.selectedDamage.value)}</b></span>
<span><small>RPM</small><b>{numberValue(entry.rpm)}</b></span>
<span><small>MAGAZINE</small><b>{numberValue(entry.magazine, ' SHOTS')}</b></span>
<span><small>RELOAD</small><b>{numberValue(entry.reloadSpeed, ' SEC')}</b></span>
</div>
<div class="baseline-metrics">
{#each [
{ label: 'RATED BODY-HIT THROUGHPUT', metric: entry.baseline.ratedBodyHitThroughput },
{ label: 'MAGAZINE-CYCLE BODY-HIT THROUGHPUT', metric: entry.baseline.magazineCycleBodyHitThroughput }
] as output}
<div class:unavailable={output.metric.value === null}>
<span>{output.label}<small>PER SECOND</small></span>
<strong>{metricValue(output.metric)}</strong>
{#if output.metric.value === null}
<p class="missing-label">MISSING FIELDS</p>
<ul>{#each output.metric.missing as field}<li>{field}</li>{/each}</ul>
{/if}
</div>
{/each}
</div>
</article>
{/each}
</div>
<div class="baseline-contract">
<p><b>FORMULAS</b> Selected damage × (RPM ÷ 60)</p>
<p>Magazine × selected damage ÷ ((Magazine ÷ (RPM ÷ 60)) + reload seconds)</p>
<p>This does not calculate accuracy, critical hits, headshots, Talents, enemy mitigation, rotations, target count, or encounter uptime.</p>
</div>
<p class="baseline-source">Source provenance: each result shows its catalog weapon ID above.</p>
</section>
<style>
.weapon-baseline{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-orange);background:var(--shd-model-surface);min-width:0}
.baseline-head,.weapon-result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--shd-gap-md)}
.baseline-head>div,.weapon-result-head>div{display:grid;gap:3px;min-width:0}
.eyebrow,.baseline-head p{margin:0;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}
.eyebrow span{display:inline-block;width:6px;height:6px;margin-right:4px;background:var(--shd-orange)}
h2,h3{margin:0;text-transform:uppercase;letter-spacing:.7px;color:var(--shd-text)}
h2{font-size:var(--shd-type-body)}h3{font-size:var(--shd-type-supporting)}
.baseline-head>strong{padding:5px 7px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
.baseline-intro,.baseline-contract p,.baseline-source{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.baseline-results{display:grid;gap:var(--shd-gap-sm)}
.weapon-result{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}
.weapon-result-head span,.baseline-inputs small,.baseline-metrics span,.missing-label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.weapon-result-head code{max-width:45%;overflow:hidden;color:var(--shd-text-dim);font:var(--shd-type-telemetry) var(--shd-font-mono);text-overflow:ellipsis;white-space:nowrap}
.baseline-inputs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-block:1px solid var(--shd-border-soft)}
.baseline-inputs span{display:grid;gap:3px;padding:7px;border-left:1px solid var(--shd-border-soft);min-width:0}.baseline-inputs span:first-child{border-left:0}
.baseline-inputs b{font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
.baseline-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)}
.baseline-metrics>div{display:grid;gap:5px;padding:9px;border-left:2px solid var(--shd-success);background:var(--shd-bg);min-width:0}.baseline-metrics>div.unavailable{border-left-color:var(--shd-danger)}
.baseline-metrics span{display:grid;gap:2px}.baseline-metrics span small{color:var(--shd-text-dim);font-size:10px}.baseline-metrics strong{font-size:18px;color:var(--shd-success)}.baseline-metrics .unavailable strong{color:var(--shd-danger)}
.missing-label{margin:2px 0 0;color:var(--shd-danger)}.baseline-metrics ul{margin:0;padding-left:17px;color:var(--shd-danger);font:var(--shd-type-telemetry)/1.4 var(--shd-font-mono)}
.baseline-contract{display:grid;gap:5px;padding-top:var(--shd-gap-sm);border-top:1px solid var(--shd-border)}.baseline-contract b{color:var(--shd-text);letter-spacing:.7px}.baseline-source{font-size:var(--shd-type-telemetry)}
@media(max-width:620px){.baseline-head{display:grid}.baseline-inputs{grid-template-columns:repeat(2,minmax(0,1fr))}.baseline-inputs span:nth-child(odd){border-left:0}.baseline-inputs span:nth-child(n+3){border-top:1px solid var(--shd-border-soft)}.baseline-metrics{grid-template-columns:1fr}.weapon-result-head code{max-width:50%}}
@media(prefers-reduced-motion:reduce){.weapon-baseline *{scroll-behavior:auto;transition:none!important;animation:none!important}}
</style>
+24
View File
@@ -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 @@
<aside class="loadout-summary">
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
<WeaponBaselineThroughput entries={weaponBaselines} headingId="builder-weapon-output-heading" />
<div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
</aside>