Add level-aware builder controls

This commit is contained in:
JB
2026-07-25 20:52:08 -04:00
parent 7f9a75021d
commit 0425fede8e
13 changed files with 654 additions and 26 deletions
+2
View File
File diff suppressed because one or more lines are too long
+46 -4
View File
@@ -1,6 +1,6 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string };
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: [Roll, Roll]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
export type LoadoutDraft = {
@@ -15,8 +15,8 @@ export type LoadoutDraft = {
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({
version: 1,
@@ -36,3 +36,45 @@ export const clamp = (value: unknown, min: unknown, max: unknown) => {
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
export type LevelScaledRange = {
rawMin: number;
rawMax: number;
min: number;
max: number;
provisional: boolean;
};
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 levelScaledRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
referenceLevel: unknown,
source: 'gear' | 'weapon'
): LevelScaledRange {
const target = itemLevel(targetLevel);
const reference = itemLevel(referenceLevel);
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
const ratio = multiplier(target) / multiplier(reference);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40
};
}
export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) {
const span = from.max - from.min;
const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
return Math.round(to.min + (to.max - to.min) * position);
}
+7 -1
View File
@@ -132,7 +132,13 @@
{@render children()}
<footer class="site-footer">
<div><a class="brand footer-brand" href="/"><span class="brand-mark"><i></i><b>SHD</b></span><span>SHD<span class="accent">.build</span></span></a><p>Independent loadout intelligence for the agent community.</p></div>
<div>
<a class="brand footer-brand" href="/" aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span>
<span class="brand-name">SHD<span>.build</span><small>LOADOUT NETWORK</small></span>
</a>
<p>Independent loadout intelligence for the agent community.</p>
</div>
<div class="footer-links">
<a class="footer-discord" href="https://discord.gg/dhpd33VwQK" target="_blank" rel="noreferrer">
<img src="/discord-symbol.svg" alt="" aria-hidden="true" />
@@ -0,0 +1,52 @@
<script lang="ts">
let {
label,
name,
items,
value = $bindable(''),
columns = Math.min(items.length, 4),
searchable = false,
required = false,
onchange
}: {
label: string;
name: string;
items: Array<{ id: string; name: string; iconUrl?: string; category?: string }>;
value?: string;
columns?: number;
searchable?: boolean;
required?: boolean;
onchange?: (id: string) => void;
} = $props();
let query = $state('');
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
function choose(id: string) {
value = id;
onchange?.(id);
}
</script>
<fieldset class="builder-radio-grid" style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} onchange={() => choose(item.id)}/>
<span>
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
</fieldset>
@@ -0,0 +1,57 @@
<script lang="ts">
import { clamp } from '$lib/builder/loadout';
let {
label,
min,
max,
value = $bindable(),
provisional = false,
step = 1,
onchange
}: {
label: string;
min: number;
max: number;
value: number;
provisional?: boolean;
step?: number;
onchange?: (value: number) => void;
} = $props();
function update(raw: unknown) {
const next = Number(clamp(raw, min, max));
if (onchange) onchange(next);
else value = next;
}
</script>
<label class="range-value-control">
<span class="range-value-head">
<b>{label}</b>
<small>{min}–{max}{provisional ? ' · ESTIMATED' : ''}</small>
</span>
<span class="range-value-inputs">
<input
class="range-value-slider"
type="range"
{min}
{max}
{step}
value={value}
aria-label={`${label} slider`}
oninput={(event) => update(event.currentTarget.value)}
/>
<input
class="range-value-number"
type="number"
{min}
{max}
{step}
value={value}
aria-label={`${label} value`}
onchange={(event) => update(event.currentTarget.value)}
onblur={(event) => update(event.currentTarget.value)}
/>
</span>
</label>
+105 -15
View File
@@ -3,8 +3,11 @@
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import BuilderPicker from '$lib/components/BuilderPicker.svelte';
import BuilderRadioGrid from '$lib/components/BuilderRadioGrid.svelte';
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import {
blankRoll, clamp, createDraft, gearSlots, scaled,
blankRoll, clamp, createDraft, gearSlots, levelScaledRange, remapRangeValue, scaled,
type LoadoutDraft, type Roll
} from '$lib/builder/loadout';
@@ -30,10 +33,20 @@
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip') => {
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');
const weaponStatRange = (range: any, key: typeof weaponRangeKeys[number], level: number) =>
scaledRange(range?.[`${key}_min`], range?.[`${key}_max`], level, range?.reference_level, 'weapon');
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'gear' ? stat?.gear_min : source === 'weapon' ? stat?.weapon_min : stat?.skill_chip_min;
const min = source === 'chip'
? stat?.skill_chip_min
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
};
const toggleTalent = (key: string) => {
@@ -62,17 +75,47 @@
selection.talentId = item?.talentIds[0] ?? '';
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
selection.rolls = [blankRoll()];
selection.rolls = [blankRoll(), blankRoll()];
selection.augmentSlots = [];
};
const changeGearLevel = (slot: string, rawLevel: unknown) => {
const selection = draft.gear[slot], item = gearItem(slot);
const nextLevel = Math.round(clamp(rawLevel, 1, 50));
if (!item || nextLevel === selection.level) {
selection.level = nextLevel;
return;
}
selection.health = remapRangeValue(
selection.health,
gearBaseRange(item, 'health', selection.level),
gearBaseRange(item, 'health', nextLevel)
);
if (item.base_armor_min !== null) selection.armor = remapRangeValue(
selection.armor,
gearBaseRange(item, 'armor', selection.level),
gearBaseRange(item, 'armor', nextLevel)
);
for (const roll of selection.rolls) {
const stat = attribute(roll.statId);
if (!stat) continue;
roll.value = remapRangeValue(
roll.value,
attributeRange(stat, 'gear', selection.level),
attributeRange(stat, 'gear', nextLevel)
);
}
selection.level = nextLevel;
};
const configureWeapon = (index: number, id: string) => {
const selection = draft.weapons[index], item = data.weapons.find((entry) => entry.id === id);
selection.itemId = id;
selection.level = 50;
selection.tier = Number(item?.native_tier ?? 0);
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0);
for (const key of weaponRangeKeys) selection[key] = weaponStatRange(range, key, selection.level).min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
@@ -89,7 +132,31 @@
const selection = draft.weapons[index], item = weaponItem(index);
selection.tier = tier;
const range = item?.ranges.find((entry: any) => Number(entry.tier) === tier);
for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0);
for (const key of weaponRangeKeys) selection[key] = weaponStatRange(range, key, selection.level).min;
};
const changeWeaponLevel = (index: number, rawLevel: unknown) => {
const selection = draft.weapons[index], item = weaponItem(index);
const nextLevel = Math.round(clamp(rawLevel, 1, 50));
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
if (!range || nextLevel === selection.level) {
selection.level = nextLevel;
return;
}
for (const key of weaponRangeKeys) selection[key] = remapRangeValue(
selection[key],
weaponStatRange(range, key, selection.level),
weaponStatRange(range, key, nextLevel)
);
for (const roll of selection.rolls) {
const stat = attribute(roll.statId);
if (!stat) continue;
roll.value = remapRangeValue(
roll.value,
attributeRange(stat, 'weapon', selection.level),
attributeRange(stat, 'weapon', nextLevel)
);
}
selection.level = nextLevel;
};
const activeParts = $derived(active.split(':'));
const talentCooldown = (id: string) => {
@@ -139,6 +206,7 @@
if (!item) continue;
for (const name of ['Damage', 'Firepower', 'Toughness', 'Engineering'] as const) add(byName(name), selection[name.toLowerCase() as 'damage'], `${index ? 'Secondary' : 'Primary'} weapon`);
for (const bonus of item.baseBonuses) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
for (const roll of selection.rolls) add(roll.statId, roll.value, `${index ? 'Secondary' : 'Primary'} weapon roll`);
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) equippedTalents.push({
key: `weapon:${index}:${talentId}`, id: talentId, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(talentId, selection.tier)
});
@@ -203,7 +271,14 @@
const parsed = JSON.parse(stored);
if (parsed?.version === 1) {
draft = parsed;
for (const slot of gearSlots) if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
for (const slot of gearSlots) {
if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
draft.gear[slot].rolls = [...(draft.gear[slot].rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
for (const weapon of draft.weapons) {
if (!Number.isInteger(weapon.level)) weapon.level = 50;
weapon.rolls = [...(weapon.rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
}
@@ -235,11 +310,17 @@
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
{#if item}
<div class="builder-field-grid"><label>GEAR LEVEL <span class="builder-field-range">01–50</span><input type="number" min="1" max="50" bind:value={selection.level} onblur={() => selection.level = clamp(selection.level,1,50)}/></label><label>BRAND SET<select value={selection.brandId} onchange={(event) => selection.brandId = event.currentTarget.value}>{#each item.brandIds as id}<option value={id}>{brand(id)?.name}</option>{/each}</select></label>{#if item.talentIds.length}<label>EQUIPPED TALENT<select bind:value={selection.talentId}>{#each item.talentIds as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}</div>
<p class="builder-scaling-note">Gear level belongs to this build instance. Base-range level scaling will remain neutral until the authoritative game scaling curve is configured.</p>
<div class="builder-range-grid"><label>BASE HEALTH <span>{item.base_health_min}–{item.base_health_max}</span><input type="number" min={item.base_health_min} max={item.base_health_max} bind:value={selection.health} onblur={() => selection.health = clamp(selection.health,item.base_health_min,item.base_health_max)}/></label>{#if item.base_armor_min !== null}<label>BASE ARMOR <span>{item.base_armor_min}–{item.base_armor_max}</span><input type="number" min={item.base_armor_min} max={item.base_armor_max} bind:value={selection.armor} onblur={() => selection.armor = clamp(selection.armor,item.base_armor_min,item.base_armor_max)}/></label>{/if}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>VALUES ARE LIMITED TO GEAR RANGES</small></div><button onclick={() => selection.rolls = [...selection.rolls, blankRoll()]} disabled={selection.rolls.length >= 6}>ADD ATTRIBUTE +</button></div>
<div class="builder-rolls">{#each selection.rolls as roll, index}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear')} label={`ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" value={roll.value} min={attribute(roll.statId)?.gear_min ?? 0} max={attribute(roll.statId)?.gear_max ?? 0} onchange={(event) => roll.value = clamp(event.currentTarget.value,attribute(roll.statId)?.gear_min,attribute(roll.statId)?.gear_max)}/></label><button aria-label={`Remove attribute ${index + 1}`} onclick={() => selection.rolls = selection.rolls.filter((_, itemIndex) => itemIndex !== index)}>×</button></div>{/each}</div>
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<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)} 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>
<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-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
{/if}
@@ -249,10 +330,19 @@
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-field-grid"><label>TIER<select value={selection.tier} onchange={(event) => changeWeaponTier(index,Number(event.currentTarget.value))}>{#each item.ranges as range}<option value={range.tier}>TIER {range.tier}</option>{/each}</select></label><div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div></div>
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} showIndicator={false} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}<div class="builder-range-grid weapon">{#each weaponRangeKeys as key}<label>{key.toUpperCase()} <span>{range[`${key}_min`]}–{range[`${key}_max`]}</span><input type="number" min={range[`${key}_min`]} max={range[`${key}_max`]} value={selection[key]} onchange={(event) => selection[key] = clamp(event.currentTarget.value,range[`${key}_min`],range[`${key}_max`])}/></label>{/each}</div>{/if}
<div class="builder-field-grid">{#if item.talent1Ids.length}<label>TALENT 1<select bind:value={selection.talent1Id}>{#each item.talent1Ids as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}{#if item.talent2Ids.length}<label>TALENT 2<select bind:value={selection.talent2Id}>{#each item.talent2Ids as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}</div>
{#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.' : `Ranges scaled from the stored level-${range.reference_level ?? 50} decimal endpoints. Display values are truncated to match the game.`}</p><div class="builder-range-grid weapon">{#each weaponRangeKeys as key}{@const statRange = weaponStatRange(range,key,selection.level)}<RangeValueControl label={key.toUpperCase()} min={statRange.min} max={statRange.max} provisional={statRange.provisional} bind:value={selection[key]}/>{/each}</div>{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}