Add level-aware builder controls
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'}
|
||||||
|
|||||||
Reference in New Issue
Block a user