feat: add catalog weapon throughput baseline
This commit is contained in:
+26
-3
@@ -2595,10 +2595,33 @@ anchor scrolling and sticky-state transitions.
|
|||||||
#### Builder calculation modes
|
#### Builder calculation modes
|
||||||
|
|
||||||
The current Builder and shared Build pages do not expose calculation modes or
|
The current Builder and shared Build pages do not expose calculation modes or
|
||||||
combat assumptions. They show deterministic equipped-item totals and reserve
|
combat assumptions. The Builder summary rail additionally contains one local
|
||||||
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
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`
|
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,
|
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
|
||||||
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
||||||
|
|||||||
@@ -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
@@ -25,12 +25,16 @@ The team builds the project in vertical slices.
|
|||||||
Server code checks authorization for each mutation. UI visibility has no effect
|
Server code checks authorization for each mutation. UI visibility has no effect
|
||||||
on server authorization.
|
on server authorization.
|
||||||
|
|
||||||
Builder and shared Build pages currently expose only deterministic totals from
|
Builder and shared Build pages expose deterministic totals from the equipped
|
||||||
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
|
items, selected rolls, fixed bonuses, inherited profiles, augments, and active
|
||||||
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
|
set bonuses. Builder additionally shows an experimental catalog baseline for
|
||||||
encounter assumptions, activation profiles, rotations, and simulation remain
|
each selected weapon: body-hit throughput from selected weapon damage and
|
||||||
planned. Explicit `COMING SOON` panels identify those player-facing
|
catalog RPM, magazine, and reload values only. It is not expected or encounter
|
||||||
capabilities.
|
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
|
Weapon and gear range calculations must preserve decimal endpoints and follow
|
||||||
the tested progression rules in
|
the tested progression rules in
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
||||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.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 PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||||
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
|
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
|
||||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||||
|
import { calculateWeaponBaseline } from '$lib/builder/weapon-baseline';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import {
|
import {
|
||||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
||||||
@@ -340,6 +342,27 @@
|
|||||||
};
|
};
|
||||||
const activeParts = $derived(active.split(':'));
|
const activeParts = $derived(active.split(':'));
|
||||||
const summary = $derived(summarizeLoadout(draft, data));
|
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
|
const rollSummary = (rolls: Roll[]) => rolls
|
||||||
.filter((roll) => roll.statId)
|
.filter((roll) => roll.statId)
|
||||||
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
|
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
|
||||||
@@ -674,6 +697,7 @@
|
|||||||
|
|
||||||
<aside class="loadout-summary">
|
<aside class="loadout-summary">
|
||||||
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
|
<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>
|
<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>
|
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user