Correct builder item level scaling
This commit is contained in:
+17
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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')}
|
||||
|
||||
Reference in New Issue
Block a user