From 1c32b1dfdce5e5e5aca34374635ffcc63c706545 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 22:18:50 -0400 Subject: [PATCH] Correct builder item level scaling --- docs/DESIGN_SYSTEM.md | 22 +++++++++++++++++----- docs/STAT_LEVEL_SCALING.md | 16 ++++++++++++++++ src/lib/builder/loadout.ts | 30 ++++++++++++++++++++++++++++++ src/routes/builder/+page.svelte | 12 +++++++----- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 5671aef..2819c44 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -776,11 +776,23 @@ calculates the ordinary level-40 curve value; never rebase level 40 as T2 level 1. Slider bounds, direct-input validation, displayed bounds, and clamping must all enforce the same availability minimum. -ISAC weapon-tier damage and core ranges are observations recorded at their -explicit reference level. The progression curve still has a level-1 origin, -but builder scaling must calculate the ratio between the target level and the -stored observation's `reference_level`. Do not treat every stored endpoint as a -level-1 value merely because the curve begins there. +ISAC ranges that represent observed values retain their explicit reference +level. The progression curve still has a level-1 origin, but builder scaling +must calculate the ratio between the target level and the stored observation's +`reference_level`. Do not treat every observed endpoint as a level-1 value +merely because the curve begins there. + +Weapon damage is the exception to reference-observation scaling: each +weapon-tier damage range is stored as a hidden base and must be multiplied +directly by `weaponMultiplier(selectedLevel)`. Changing weapon level remaps the +selected damage value into the newly calculated range. Shared core attributes +remain level-50 reference profiles and use ratio scaling independently. + +Gear base Health and Armor follow the same hidden-base principle. Multiply +their stored per-item endpoints directly by `gearMultiplier(selectedLevel)`, +truncate only for display, and remap the user's selected value proportionally +when gear level changes. Do not apply this rule to rolled Attributes or the +shared core-allocation profile, which retain independent reference semantics. ### Shared core allocation diff --git a/docs/STAT_LEVEL_SCALING.md b/docs/STAT_LEVEL_SCALING.md index 2040c47..9cb01f6 100644 --- a/docs/STAT_LEVEL_SCALING.md +++ b/docs/STAT_LEVEL_SCALING.md @@ -79,6 +79,12 @@ rawMinimum(level) = hiddenMinimum × gearMultiplier(level) rawMaximum(level) = hiddenMaximum × gearMultiplier(level) ``` +ISAC gear `base_health` and `base_armor` endpoints are these hidden, +level-independent bases. Builder base survivability therefore uses the direct +multiplier form above rather than treating those endpoints as observations at +the row's legacy reference level. Rolled Attributes and shared core profiles +retain their separately defined reference semantics. + Given an unrounded range recorded at a known reference level: ```text @@ -150,6 +156,16 @@ targetRaw = / weaponMultiplier(referenceLevel) ``` +Per-weapon damage endpoints in ISAC are hidden base ranges, not observations at +the shared core profile's reference level. Calculate weapon damage directly: + +```text +damageRaw(level) = storedDamageBase × weaponMultiplier(level) +``` + +Firepower, Toughness, and Engineering remain shared reference-level +observations and continue to use the ratio form. + SHD.build stores the reference level associated with each weapon-tier range. Builder calculations use that recorded observation level in the ratio above. The curve's level-1 origin does not imply that every stored range was observed diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index 2e205b6..533adea 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -49,6 +49,36 @@ const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50)); export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level); export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level); +export function gearBaseStatRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange { + const target = itemLevel(targetLevel); + const configuredMin = Number(minimum); + const configuredMax = Number(maximum); + const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target); + const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * gearMultiplier(target); + return { + rawMin, + rawMax, + min: Math.floor(rawMin), + max: Math.floor(rawMax), + provisional: target < 10 + }; +} + +export function weaponBaseRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange { + const target = itemLevel(targetLevel); + const configuredMin = Number(minimum); + const configuredMax = Number(maximum); + const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target); + const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * weaponMultiplier(target); + return { + rawMin, + rawMax, + min: Math.floor(rawMin), + max: Math.floor(rawMax), + provisional: target < 40 + }; +} + export function levelScaledRange( minimum: unknown, maximum: unknown, diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index a1cc963..8dd80de 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -7,7 +7,7 @@ import RangeValueControl from '$lib/components/RangeValueControl.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import { - blankRoll, clamp, createDraft, gearSlots, levelScaledRange, remapRangeValue, scaled, + blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange, type LoadoutDraft, type Roll } from '$lib/builder/loadout'; @@ -37,9 +37,11 @@ const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') => levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source); const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) => - scaledRange(item?.[`base_${key}_min`], item?.[`base_${key}_max`], level, item?.range_reference_level, 'gear'); + gearBaseStatRange(item?.[`base_${key}_min`], item?.[`base_${key}_max`], level); const weaponStatRange = (range: any, key: typeof weaponRangeKeys[number], level: number) => - scaledRange(range?.[`${key}_min`], range?.[`${key}_max`], level, range?.reference_level ?? 50, 'weapon'); + key === 'damage' + ? weaponBaseRange(range?.damage_min, range?.damage_max, level) + : scaledRange(range?.[`${key}_min`], range?.[`${key}_max`], level, range?.reference_level ?? 50, 'weapon'); const coreAllocationRange = (tier: number, level: number, source: 'gear' | 'weapon') => { const profile = data.coreRanges.find((item) => Number(item.tier) === Number(tier)); const nominal = scaledRange(profile?.nominal_min, profile?.nominal_max, level, profile?.reference_level ?? 50, source); @@ -337,7 +339,7 @@ brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/> {#if item.talentIds.length} talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if} -

{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : `Ranges scaled from the stored level-${item.range_reference_level ?? 50} decimal endpoints. Display values are truncated to match the game.`}

+

{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}

{#if armorRange}{/if}
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
CORE ATTRIBUTE ALLOCATIONSHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES
gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}
@@ -360,7 +362,7 @@ {@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)} {#if range} -

{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : `Damage scaled from the stored level-${range.reference_level ?? 50} decimal endpoints. Core attributes use the shared tier allocation pool.`}

+

{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}

{@const damageRange = weaponStatRange(range,'damage',selection.level)}
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}