Correct builder item level scaling

This commit is contained in:
JB
2026-07-25 22:18:50 -04:00
parent 478a37c17a
commit 1c32b1dfdc
4 changed files with 70 additions and 10 deletions
+17 -5
View File
@@ -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
+16
View File
@@ -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
+30
View File
@@ -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,
+7 -5
View File
@@ -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 @@
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{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.`}</p>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{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.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
@@ -360,7 +362,7 @@
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{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.`}</p>
<p class:provisional={selection.level < 40} class="builder-scaling-note">{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.'}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}