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
|
1. Slider bounds, direct-input validation, displayed bounds, and clamping must
|
||||||
all enforce the same availability minimum.
|
all enforce the same availability minimum.
|
||||||
|
|
||||||
ISAC weapon-tier damage and core ranges are observations recorded at their
|
ISAC ranges that represent observed values retain their explicit reference
|
||||||
explicit reference level. The progression curve still has a level-1 origin,
|
level. The progression curve still has a level-1 origin, but builder scaling
|
||||||
but builder scaling must calculate the ratio between the target level and the
|
must calculate the ratio between the target level and the stored observation's
|
||||||
stored observation's `reference_level`. Do not treat every stored endpoint as a
|
`reference_level`. Do not treat every observed endpoint as a level-1 value
|
||||||
level-1 value merely because the curve begins there.
|
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
|
### Shared core allocation
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ rawMinimum(level) = hiddenMinimum × gearMultiplier(level)
|
|||||||
rawMaximum(level) = hiddenMaximum × 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:
|
Given an unrounded range recorded at a known reference level:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -150,6 +156,16 @@ targetRaw =
|
|||||||
/ weaponMultiplier(referenceLevel)
|
/ 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.
|
SHD.build stores the reference level associated with each weapon-tier range.
|
||||||
Builder calculations use that recorded observation level in the ratio above.
|
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
|
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 gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level);
|
||||||
export const weaponMultiplier = (level: unknown) => 1.14 + 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(
|
export function levelScaledRange(
|
||||||
minimum: unknown,
|
minimum: unknown,
|
||||||
maximum: unknown,
|
maximum: unknown,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import {
|
import {
|
||||||
blankRoll, clamp, createDraft, gearSlots, levelScaledRange, remapRangeValue, scaled,
|
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
|
||||||
type LoadoutDraft, type Roll
|
type LoadoutDraft, type Roll
|
||||||
} from '$lib/builder/loadout';
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
@@ -37,9 +37,11 @@
|
|||||||
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
|
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
|
||||||
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
|
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
|
||||||
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
|
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) =>
|
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 coreAllocationRange = (tier: number, level: number, source: 'gear' | 'weapon') => {
|
||||||
const profile = data.coreRanges.find((item) => Number(item.tier) === Number(tier));
|
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);
|
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/>
|
<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}
|
{#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>
|
</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>
|
<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')}
|
{@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>
|
<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>
|
</div>
|
||||||
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
||||||
{#if range}
|
{#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)}
|
{@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>
|
<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')}
|
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
|
||||||
|
|||||||
Reference in New Issue
Block a user