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
+58
View File
@@ -143,3 +143,61 @@ Database work must follow these rules:
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target Use `npm run db:restore -- <backup-file>` only with an empty, migrated target
database. The restore script intentionally refuses to merge into a populated database. The restore script intentionally refuses to merge into a populated
database. database.
## Low-downtime database migration windows
Friends and contributors may be actively entering game data in ISAC while
development continues. Treat dev-server availability and unsaved contributor
work as operational concerns whenever a schema change requires stopping the
embedded database.
Use this sequence for every planned migration window:
### Prepare while the server remains online
1. Implement the application changes and the complete repeatable migration
before stopping the server.
2. Prefer additive, backward-compatible changes: new tables, new indexes, and
nullable or safely defaulted columns. Avoid table copies, column removal,
database recreation, or migrations that require the new application code to
become active before the schema is ready.
3. Make migrations idempotent by inspecting current schema state before each
alteration. The migration must be safe to rerun after interruption.
4. Batch related schema changes into one window instead of repeatedly stopping
the server during the same task.
5. Run `npm run check` and `npm run build` before the outage. Do not spend the
offline interval editing code, resolving type errors, building assets, or
performing ordinary UI review.
6. Identify the exact environment, database path, migration steps, verification
queries, and rollback/restore backup before stopping anything.
### Keep the offline window short
1. Do not start the window while a known contributor is in the middle of an
unsaved form or submission. When contributor activity is known, allow them
to finish or explicitly coordinate the interruption.
2. Inspect processes and stop only this project's dev-server process tree.
3. Run `npm run db:backup` immediately, verify that it completed, and retain the
reported backup path.
4. Run the already-prepared migration immediately after the backup.
5. Perform only critical database checks while offline: migration success,
expected columns/tables, record counts, and critical entity availability.
6. Restart the dev server as soon as the database is safe for application use.
Browser testing, extended regression checks, formatting, documentation
review, and noncritical diagnostics happen after service is restored.
### Failure and high-risk handling
1. If backup, migration, or critical verification fails, do not repeatedly
improvise against the live database. Preserve logs and the verified backup,
determine whether the existing schema is still safe, and either restart the
previous compatible application or follow the documented empty-database
restore procedure.
2. Separate destructive, data-rewriting, or predictably long migrations from
routine additive windows. Report that a maintenance window is required
before beginning one.
3. Do not claim the service is available until the restarted dev server passes
a basic health/page check.
4. In the handoff, report the backup path, migration result, server restart,
critical verification performed, and approximate offline interval when it
can be determined.
+4
View File
@@ -125,3 +125,7 @@ You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
The shared visual language and dashboard conventions are documented in The shared visual language and dashboard conventions are documented in
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md). [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
Empirically derived weapon and gear level-range calculations, rounding rules,
validation boundaries, and regression observations are documented in
[docs/STAT_LEVEL_SCALING.md](docs/STAT_LEVEL_SCALING.md).
+4
View File
@@ -271,8 +271,10 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
unit TEXT NOT NULL DEFAULT '', unit TEXT NOT NULL DEFAULT '',
gear_min REAL, gear_min REAL,
gear_max REAL, gear_max REAL,
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
weapon_min REAL, weapon_min REAL,
weapon_max REAL, weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
skill_chip_min REAL, skill_chip_min REAL,
skill_chip_max REAL, skill_chip_max REAL,
cap_value REAL, cap_value REAL,
@@ -382,6 +384,7 @@ CREATE TABLE IF NOT EXISTS gear (
base_health_max REAL NOT NULL CHECK(base_health_max >= base_health_min), base_health_max REAL NOT NULL CHECK(base_health_max >= base_health_min),
base_armor_min REAL, base_armor_min REAL,
base_armor_max REAL, base_armor_max REAL,
range_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(range_reference_level BETWEEN 1 AND 50),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -463,6 +466,7 @@ CREATE TABLE IF NOT EXISTS weapon_tier_ranges (
toughness_max REAL NOT NULL CHECK(toughness_max >= toughness_min), toughness_max REAL NOT NULL CHECK(toughness_max >= toughness_min),
engineering_min REAL NOT NULL CHECK(engineering_min >= 0), engineering_min REAL NOT NULL CHECK(engineering_min >= 0),
engineering_max REAL NOT NULL CHECK(engineering_max >= engineering_min), engineering_max REAL NOT NULL CHECK(engineering_max >= engineering_min),
reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50),
PRIMARY KEY(weapon_id, tier) PRIMARY KEY(weapon_id, tier)
); );
CREATE TABLE IF NOT EXISTS weapon_talent_options ( CREATE TABLE IF NOT EXISTS weapon_talent_options (
+42 -6
View File
@@ -62,6 +62,11 @@ from the orange links reserved for SHD navigation and legal resources. Support
and legal links form a compact horizontal rail that may wrap at intermediate and legal links form a compact horizontal rail that may wrap at intermediate
widths and becomes a two-column touch-friendly grid on narrow mobile screens. widths and becomes a two-column touch-friendly grid on narrow mobile screens.
The footer repeats the complete SHD brand lockup used by the global header:
double-ring SHD mark, white `SHD`, orange `.build`, and the restrained
`LOADOUT NETWORK` descriptor. Do not substitute a plain text wordmark or color
the complete product name orange.
Contact notifications use the mandatory ISAC visual identity because they are Contact notifications use the mandatory ISAC visual identity because they are
operational inbound messages. The notification carries a concise preview and a operational inbound messages. The notification carries a concise preview and a
specific action to an authorization-checked full record. Full messages never specific action to an authorization-checked full record. Full messages never
@@ -75,6 +80,38 @@ contact columns stack, destination segments become full-width 44-pixel rows,
attestations remain readable, and reply/recovery actions occupy the available attestations remain readable, and reply/recovery actions occupy the available
width. width.
### Builder range controls
Configurable numeric rolls use the shared range-value control: a descriptive
label and current minimum/maximum above a synchronized range slider and direct
number input. The slider supports fast exploration while the number field
supports exact entry; both clamp to the currently calculated bounds. Focus uses
the SHD action border, active slider feedback uses the standard pressed
brightness treatment, and number fields retain the global select-all-on-focus
behavior.
When an equipment level changes, preserve the configured roll's relative
position within its previous range instead of resetting it arbitrarily. Mark
unverified portions of a level curve with both `ESTIMATED` text and the
High-end yellow caution treatment. Controls stack the slider and direct input
at narrow mobile widths and must never create horizontal page overflow.
Equipment level uses this same synchronized control rather than a standalone
number field. Changing level remaps base and rolled values to the equivalent
relative position in the newly calculated range.
Gear and weapons always expose exactly two required rolled-Attribute positions.
Render both positions as persistent numbered rows; do not offer add or remove
actions. Already selected Attributes are disabled in the other position.
Use compact segmented radio controls for the small finite weapon-tier set.
Use the shared builder radio grid for visually identified Brand Sets and weapon
talents: transparent icons lead each square engineered option, text remains
visible, and the selected state uses the orange action edge plus an explicit
radio state. Talent grids become searchable when their available pool is large.
On narrow viewports option grids reduce to two columns and then one column so
labels and 44-pixel-or-larger targets remain usable.
## Tokens ## Tokens
The canonical tokens live in `src/app.css` under `:root`. The canonical tokens live in `src/app.css` under `:root`.
@@ -987,12 +1024,11 @@ the live totals immediately. Trigger-dependent talents remain explicitly
effects enter combined totals. This textual distinction must remain clear effects enter combined totals. This textual distinction must remain clear
without relying on color. without relying on color.
Gear level is builder-instance state, never part of an ISAC Gear definition. Gear and weapon level are builder-instance state, never part of an ISAC item
Each equipped piece stores its own level from 1–50 in the local or persisted definition. Each equipped item stores its own level from 1–50 in the local or
build. Until an authoritative level-scaling curve is represented in game data, persisted build. Range endpoints are calculated from the documented level
the workbench must state that level scaling is neutral rather than silently curve and stored reference level; provisional portions of that curve must be
inventing a formula; base Health and Armor remain constrained by their identified in text as estimated.
canonical ranges.
### Registry relationship icon rails ### Registry relationship icon rails
+220
View File
@@ -0,0 +1,220 @@
# Weapon and gear stat level scaling
This document records the empirically observed rules for adjusting weapon and
gear stat ranges by item level. The rules apply independently to both endpoints
of a configured range.
These formulas are based on in-game observations rather than published game
mechanics. Preserve source observations when adding new samples and revise the
constants if a larger data set disproves them.
## Numeric representation and display
Stat ranges must retain their unrounded decimal values. The integer shown by the
game is consistent with truncation:
```text
displayedValue = floor(rawValue)
```
Do not use `ceil`, and do not repeatedly level an already displayed integer.
Both approaches accumulate errors. A level-1 value displayed as `0` can still
have a positive fractional raw value that becomes visible at a higher level.
For example, a standard gear item displayed `0 ENG` at level 1 and `2 ENG` at
level 42. With a level-42 multiplier of `3.01`, a raw base near `0.7` explains
both values:
```text
floor(0.7) = 0
floor(0.7 × 3.01) = 2
```
When only displayed integers are available, a prediction can be off by one.
That is expected information loss, not evidence for rounding upward.
## Shared progression model
The stable portion of both progressions is linear, not compounded:
```text
rawStat(level) = hiddenBase × multiplier(level)
```
The multiplier increases by `0.05` per level. Equivalently, each level adds a
fixed `5%` of the hidden base:
```text
rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05
```
Never calculate a new level as `previousDisplayedValue × 1.05`; that describes
compound growth and does not match the observations.
For a range `[minimum, maximum]`, calculate each endpoint separately:
```text
scaledMinimum = hiddenMinimum × multiplier(level)
scaledMaximum = hiddenMaximum × multiplier(level)
```
Only truncate when producing a game-matching display value.
## Gear
The observed stable gear multiplier is:
```text
gearMultiplier(level) = 0.91 + 0.05 × level
```
This rule is validated for levels 10 through 50 across Standard, Specialized,
and High-end samples. Quality changes the hidden stat ranges; the observed
late-level multiplier does not change with quality.
Given hidden level-independent endpoints:
```text
rawMinimum(level) = hiddenMinimum × gearMultiplier(level)
rawMaximum(level) = hiddenMaximum × gearMultiplier(level)
```
Given an unrounded range recorded at a known reference level:
```text
targetRaw =
referenceRaw
× gearMultiplier(targetLevel)
/ gearMultiplier(referenceLevel)
```
### Gear example
At level 42:
```text
gearMultiplier(42) = 0.91 + 0.05 × 42 = 3.01
```
A Standard item with approximate hidden bases of `615.0 HP`, `107.4 AR`,
`10.0 FP`, `11.4 TGH`, and `0.7 ENG` produces:
```text
HP = floor(615.0 × 3.01) = 1851
AR = floor(107.4 × 3.01) = 323
FP = floor(10.0 × 3.01) = 30
TGH = floor(11.4 × 3.01) = 34
ENG = floor(0.7 × 3.01) = 2
```
### Early gear levels
Levels 1 through 9 show a quality-dependent transition and do not follow the
stable multiplier directly. Do not extrapolate that transition from a single
level-1-to-level-2 change. Until the early curves are sampled more completely,
store authoritative ranges for those levels or mark calculated results as
estimates.
## Weapons
Weapon observations from levels 40 through 50 show the same fixed `0.05`
multiplier increase per level, but a different offset:
```text
weaponMultiplier(level) ≈ 1.14 + 0.05 × level
```
This gives approximately:
```text
weaponMultiplier(40) = 3.14
weaponMultiplier(50) = 3.64
```
and predicts a level-40-to-level-50 ratio of:
```text
3.64 / 3.14 ≈ 1.15924
```
Two observed weapons produced ratios of approximately `1.15960` and `1.15936`.
The small difference is consistent with hidden fractional bases and truncated
display values.
Given an unrounded weapon range at a known reference level:
```text
targetRaw =
referenceRaw
× weaponMultiplier(targetLevel)
/ weaponMultiplier(referenceLevel)
```
The `1.14` offset is provisional and is currently validated only for the
level-40-to-level-50 interval. Preserve enough decimal precision to revise it
without migrating rounded data.
### Weapon example
A weapon changed from `753 DMG` at level 40 to `873 DMG` at level 50:
```text
753 × (3.64 / 3.14) ≈ 872.95
floor(872.95) = 872
```
The displayed-source calculation misses by one because `753` is already
truncated. Applying the ratio to the unrounded source value produces the
observed `873`. This is why calculations must use stored decimal endpoints.
The observed changes over those ten levels were linear:
```text
DMG: 753 → 873 (12.0 raw points per level)
FP: 44 → 51 (about 0.7 per level)
TGH: 15 → 18 (about 0.3 per level)
ENG: 45 → 52 (about 0.7 per level)
```
### Early weapon levels
Do not apply the provisional late-level weapon offset to level 1. An Exotic
sample's displayed level-1 values and level-50 values fit a `2.91` endpoint
ratio for most stats, which is incompatible with simply extending the
level-40-to-level-50 offset backward. Weapons therefore also appear to have an
early transition or another quality-dependent input.
Until more intermediate samples are available:
- use the weapon formula only within the validated level 40–50 interval;
- do not derive a hidden base directly from a displayed level-1 value;
- prefer authoritative low-level ranges where exact results are required.
## Implementation requirements
1. Store range endpoints as decimals and retain their reference level.
2. Scale minimum and maximum independently using raw values.
3. Calculate directly from the reference value; never chain per-level rounded
results.
4. Truncate only at the presentation boundary when matching the game display.
5. Clamp supported levels to `1–50`.
6. Treat gear levels below 10 and weapon levels below 40 as provisional until
their curves are verified.
7. Add observed intermediate values as regression fixtures before changing a
multiplier or rounding rule.
## Current regression observations
Useful known outputs include:
- Specialized gear at level 27: `1610 HP`, `280 AR`, `29 FP`, `23 TGH`,
`10 ENG`.
- Standard gear at level 42: `1851 HP`, `323 AR`, `30 FP`, `34 TGH`, `2 ENG`.
- High-end gear: level 40 `2682 HP`, `464 AR`, `47 FP`, `16 TGH`, `36 ENG`;
level 41 `2728 HP`, `472 AR`, `48 FP`, `16 TGH`, `37 ENG`; level 50
`3143 HP`, `544 AR`, `55 FP`, `19 TGH`, `43 ENG`.
- Weapon sample: level 40 `1886 DMG`, `120 FP`, `123 TGH`, `31 ENG`; level 50
`2187 DMG`, `139 FP`, `142 TGH`, `36 ENG`.
- Weapon sample: level 40 `753 DMG`, `44 FP`, `15 TGH`, `45 ENG`; level 50
`873 DMG`, `51 FP`, `18 TGH`, `52 ENG`.
+36
View File
@@ -20,6 +20,12 @@ This project is intentionally being built in vertical slices.
Authorization must be checked on the server for every mutation. Hiding an admin link is not authorization. Authorization must be checked on the server for every mutation. Hiding an admin link is not authorization.
Weapon and gear range calculations must preserve unrounded decimal endpoints
and follow the empirically validated progression rules in
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). In particular, leveling is
linear rather than compounded and game-matching integer values are truncated
only at the presentation boundary.
Local credentials are stored as Argon2id PHC strings with a unique random salt Local credentials are stored as Argon2id PHC strings with a unique random salt
per password. Current work factors follow OWASP's 19 MiB / 2 iteration / per password. Current work factors follow OWASP's 19 MiB / 2 iteration /
single-lane minimum profile and can be increased over time; successful login single-lane minimum profile and can be increased over time; successful login
@@ -84,6 +90,36 @@ This guard prevents accidental merging or overwriting of contributor data.
Stop the application before backup or restore so the embedded database file is Stop the application before backup or restore so the embedded database file is
not locked by another process. not locked by another process.
## Low-downtime development migrations
The development database is a shared working data source: contributors may be
entering ISAC records while application development continues. Database changes
therefore use a prepared migration window rather than taking the server down at
the beginning of implementation.
Before the window, finish the code and idempotent migration, batch related
schema changes, identify verification queries, and run `npm run check` plus
`npm run build` while the server remains available. Prefer additive,
backward-compatible tables and safely defaulted or nullable columns. Coordinate
with known contributors so the stop does not interrupt an unsaved submission.
The offline window is deliberately narrow:
1. Inspect running processes and stop only this project's development server.
2. Run and verify `npm run db:backup`.
3. Run the already-prepared migration.
4. Verify expected schema state, record counts, and critical entities.
5. Restart the development server immediately.
6. Confirm a basic page or health check, then perform extended browser and
regression testing with the service online.
Code editing, builds, formatting, documentation review, and noncritical UI
testing do not belong inside this window. Destructive, data-rewriting, or
long-running migrations require a separately communicated maintenance window
and a verified recovery plan. Every migration handoff reports the backup path,
migration outcome, successful server restart, critical checks, and the
approximate offline interval when available.
# ISAC audit and compensating reversals # ISAC audit and compensating reversals
Every successful ISAC mutation appends an event to `audit_log` through the Every successful ISAC mutation appends an event to `audit_log` through the
+21
View File
@@ -221,6 +221,27 @@ if (gearColumns.some((column) => column.name === 'level')) {
console.log('Moving Gear level from canonical ISAC data to builder-instance state.'); console.log('Moving Gear level from canonical ISAC data to builder-instance state.');
await database.exec('ALTER TABLE gear DROP COLUMN level'); await database.exec('ALTER TABLE gear DROP COLUMN level');
} }
if (!gearColumns.some((column) => column.name === 'range_reference_level')) {
console.log('Recording the reference level for canonical Gear ranges.');
await database.exec('ALTER TABLE gear ADD COLUMN range_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(range_reference_level BETWEEN 1 AND 50)');
}
const weaponRangeColumns = await (await database.prepare("PRAGMA table_info('weapon_tier_ranges')")).all();
if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) {
console.log('Recording the reference level for canonical Weapon ranges.');
await database.exec('ALTER TABLE weapon_tier_ranges ADD COLUMN reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50)');
}
const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all();
for (const [name, definition] of [
['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'],
['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)']
]) {
if (!attributeColumns.some((column) => column.name === name)) {
console.log(`Recording the reference level for Attribute source ranges: ${name}.`);
await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`);
}
}
const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all(); const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all();
for (const [name, definition] of [ for (const [name, definition] of [
+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 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 GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: [Roll, Roll]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string }; 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 ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number }; export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
export type LoadoutDraft = { 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 gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 }); 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 blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' }); 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 blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({ export const createDraft = (): LoadoutDraft => ({
version: 1, 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) => 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; 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()} {@render children()}
<footer class="site-footer"> <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"> <div class="footer-links">
<a class="footer-discord" href="https://discord.gg/dhpd33VwQK" target="_blank" rel="noreferrer"> <a class="footer-discord" href="https://discord.gg/dhpd33VwQK" target="_blank" rel="noreferrer">
<img src="/discord-symbol.svg" alt="" aria-hidden="true" /> <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 GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
import BuilderPicker from '$lib/components/BuilderPicker.svelte'; 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 { import {
blankRoll, clamp, createDraft, gearSlots, scaled, blankRoll, clamp, createDraft, gearSlots, levelScaledRange, remapRangeValue, scaled,
type LoadoutDraft, type Roll type LoadoutDraft, type Roll
} from '$lib/builder/loadout'; } from '$lib/builder/loadout';
@@ -30,10 +33,20 @@
const unit = attribute(statId)?.unit ?? ''; const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${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; roll.statId = id;
const stat = attribute(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); roll.value = Number(min ?? 0);
}; };
const toggleTalent = (key: string) => { const toggleTalent = (key: string) => {
@@ -62,17 +75,47 @@
selection.talentId = item?.talentIds[0] ?? ''; selection.talentId = item?.talentIds[0] ?? '';
selection.health = Number(item?.base_health_min ?? 0); selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0); selection.armor = Number(item?.base_armor_min ?? 0);
selection.rolls = [blankRoll()]; selection.rolls = [blankRoll(), blankRoll()];
selection.augmentSlots = []; 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 configureWeapon = (index: number, id: string) => {
const selection = draft.weapons[index], item = data.weapons.find((entry) => entry.id === id); const selection = draft.weapons[index], item = data.weapons.find((entry) => entry.id === id);
selection.itemId = id; selection.itemId = id;
selection.level = 50;
selection.tier = Number(item?.native_tier ?? 0); selection.tier = Number(item?.native_tier ?? 0);
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier); 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.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent2Id = item?.talent2Ids[0] ?? ''; selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.rolls = [blankRoll(), blankRoll()];
}; };
const configureChip = (index: number, id: string) => { const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id); 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); const selection = draft.weapons[index], item = weaponItem(index);
selection.tier = tier; selection.tier = tier;
const range = item?.ranges.find((entry: any) => Number(entry.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 activeParts = $derived(active.split(':'));
const talentCooldown = (id: string) => { const talentCooldown = (id: string) => {
@@ -139,6 +206,7 @@
if (!item) continue; 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 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 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({ 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) 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); const parsed = JSON.parse(stored);
if (parsed?.version === 1) { if (parsed?.version === 1) {
draft = parsed; 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 = ''; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true; 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> <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"/> <BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
{#if item} {#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> {@const healthRange = gearBaseRange(item, 'health', selection.level)}
<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> {@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<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-equipment-controls">
<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> <RangeValueControl label="GEAR LEVEL" min={1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<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> <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)} {@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 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} {/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"/> <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 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} {#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)} {@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} {#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-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> <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 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} {/if}
{:else if activeParts[0] === 'chip'} {:else if activeParts[0] === 'chip'}