From 0425fede8e7b88baf6b6059d720c42f37f2550ac Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 20:52:08 -0400 Subject: [PATCH] Add level-aware builder controls --- AGENTS.md | 58 ++++++ README.md | 4 + database/schema.sql | 4 + docs/DESIGN_SYSTEM.md | 48 ++++- docs/STAT_LEVEL_SCALING.md | 220 ++++++++++++++++++++ docs/architecture.md | 36 ++++ scripts/db-migrate.mjs | 21 ++ src/app.css | 2 + src/lib/builder/loadout.ts | 50 ++++- src/lib/components/AppShell.svelte | 8 +- src/lib/components/BuilderRadioGrid.svelte | 52 +++++ src/lib/components/RangeValueControl.svelte | 57 +++++ src/routes/builder/+page.svelte | 120 +++++++++-- 13 files changed, 654 insertions(+), 26 deletions(-) create mode 100644 docs/STAT_LEVEL_SCALING.md create mode 100644 src/lib/components/BuilderRadioGrid.svelte create mode 100644 src/lib/components/RangeValueControl.svelte diff --git a/AGENTS.md b/AGENTS.md index 2070468..2c80f54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,3 +143,61 @@ Database work must follow these rules: Use `npm run db:restore -- ` only with an empty, migrated target database. The restore script intentionally refuses to merge into a populated 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. diff --git a/README.md b/README.md index a401e4d..677ae8c 100644 --- a/README.md +++ b/README.md @@ -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. The shared visual language and dashboard conventions are documented in [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). diff --git a/database/schema.sql b/database/schema.sql index 83c8a92..38df82a 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -271,8 +271,10 @@ CREATE TABLE IF NOT EXISTS stat_attributes ( unit TEXT NOT NULL DEFAULT '', gear_min REAL, gear_max REAL, + gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50), weapon_min 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_max 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_armor_min 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)), created_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), engineering_min REAL NOT NULL CHECK(engineering_min >= 0), 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) ); CREATE TABLE IF NOT EXISTS weapon_talent_options ( diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 3f8693d..8754ce8 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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 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 operational inbound messages. The notification carries a concise preview and a 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 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 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 without relying on color. -Gear level is builder-instance state, never part of an ISAC Gear definition. -Each equipped piece stores its own level from 1–50 in the local or persisted -build. Until an authoritative level-scaling curve is represented in game data, -the workbench must state that level scaling is neutral rather than silently -inventing a formula; base Health and Armor remain constrained by their -canonical ranges. +Gear and weapon level are builder-instance state, never part of an ISAC item +definition. Each equipped item stores its own level from 1–50 in the local or +persisted build. Range endpoints are calculated from the documented level +curve and stored reference level; provisional portions of that curve must be +identified in text as estimated. ### Registry relationship icon rails diff --git a/docs/STAT_LEVEL_SCALING.md b/docs/STAT_LEVEL_SCALING.md new file mode 100644 index 0000000..cacb251 --- /dev/null +++ b/docs/STAT_LEVEL_SCALING.md @@ -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`. + diff --git a/docs/architecture.md b/docs/architecture.md index 6a0781b..55a8926 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. +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 per password. Current work factors follow OWASP's 19 MiB / 2 iteration / 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 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 Every successful ISAC mutation appends an event to `audit_log` through the diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 2b62210..fea3a19 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -221,6 +221,27 @@ if (gearColumns.some((column) => column.name === 'level')) { console.log('Moving Gear level from canonical ISAC data to builder-instance state.'); 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(); for (const [name, definition] of [ diff --git a/src/app.css b/src/app.css index 024465c..0602b54 100644 --- a/src/app.css +++ b/src/app.css @@ -35,6 +35,7 @@ .discord-contact.discord-contact{grid-template-columns:30px minmax(0,1fr) auto;align-items:center;align-content:normal;gap:11px}.discord-contact>img{width:26px;height:20px;object-fit:contain}.discord-contact>span{display:grid;gap:5px}.discord-contact>span small{font-size:6px;font-weight:900;letter-spacing:1.2px}.discord-contact>span strong{font-size:10px;letter-spacing:.8px}.discord-contact.discord-contact>b{grid-column:auto;grid-row:auto;align-self:center} .footer-links .footer-discord{min-height:38px;display:flex;align-items:center;gap:9px;margin-bottom:2px;padding:7px 9px;border:1px solid #5865f2;background:#171a32;color:#fff}.footer-discord img{width:20px;height:16px;object-fit:contain}.footer-discord span{display:grid;gap:3px;font-size:7px;line-height:1}.footer-discord small{color:#aeb6ff;font-size:5px;font-weight:900;letter-spacing:.8px}.footer-links .footer-discord:hover,.footer-links .footer-discord:focus-visible{border-color:#929bff;background:#202446;color:#fff}.footer-links .footer-discord:active{transform:translateY(1px);filter:brightness(.88)} .footer-nav{width:max-content;max-width:520px;height:auto;display:flex;flex-wrap:wrap;align-items:center;gap:0;margin:0}.footer-nav a{display:inline-flex;align-items:center;min-height:24px;padding:0 9px;border-left:1px solid var(--shd-border);color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px}.footer-nav a:first-child{padding-left:0;border-left:0}.footer-nav a:hover,.footer-nav a:focus-visible{color:var(--shd-orange-hover)}@media(max-width:800px){.footer-nav{width:100%;max-width:none}.footer-nav a{min-height:30px}}@media(max-width:500px){.footer-nav{display:grid;grid-template-columns:1fr 1fr}.footer-nav a{padding:0 8px;border-top:1px solid var(--shd-border);border-left:0}.footer-nav a:nth-child(even){border-left:1px solid var(--shd-border)}.footer-nav a:nth-child(-n+2){border-top:0}} + .footer-brand.footer-brand{gap:12px}.footer-brand .brand-mark{position:relative;width:36px;height:36px;font-size:9px}.footer-brand .brand-mark i{position:absolute;inset:3px;border:1px solid #f6a82155;border-radius:50%}.footer-brand .brand-mark b{position:relative;color:var(--shd-orange);font-size:8px}.footer-brand .brand-name{display:block;margin:0;font-size:14px;line-height:1;color:#fff}.footer-brand .brand-name>span{color:var(--shd-orange)}.footer-brand .brand-name small{margin-top:4px;color:var(--shd-text-dim);font-size:5px;letter-spacing:1.3px} @media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}} @media(min-width:2800px){:root{--shd-display-scale:1.3}} @media(min-width:3400px){:root{--shd-display-scale:1.5}} @@ -159,6 +160,7 @@ .gear-tier-costs{margin:8px 0 0;padding:7px;border:1px solid #303536}.gear-tier-costs legend{padding:0 5px;color:#697174;font-size:6px;font-weight:900;letter-spacing:.7px}.gear-tier-costs>div{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.gear-tier-costs .field{margin:0}.gear-augment-profile-grid>button>div em{white-space:nowrap}@media(max-width:560px){.gear-tier-costs>div{grid-template-columns:1fr 1fr 1fr}} .gear-identity{grid-template-columns:minmax(360px,1fr) 200px minmax(150px,180px);align-items:end}.gear-identity>.field,.gear-identity>.segmented-choice{margin:0}.gear-identity .segmented-choice legend{font-size:7px}.gear-identity .segmented-choice>div{margin-top:5px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(6,32px);width:max-content}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:32px}@media(max-width:900px){.gear-identity{grid-template-columns:1fr 1fr}.gear-identity>.field{grid-column:1/3}}@media(max-width:700px){.gear-identity{grid-template-columns:1fr}.gear-identity>.field{grid-column:auto}.gear-identity .segmented-choice>div{height:auto}.gear-identity .segmented-choice span{min-height:44px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(3,44px)}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:44px}} .builder-field-range{float:right;color:#5f6769;font-size:6px}.builder-scaling-note{margin:7px 0 0;padding:7px 9px;border-left:2px solid #667174;background:#101415;color:#727a7c;font-size:7px;line-height:1.45} +.builder-scaling-note.provisional{border-left-color:var(--quality-high-end);background:#18150d;color:#a89568}.range-value-control{min-width:0;display:grid;gap:6px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.range-value-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.range-value-head b{color:#7f8789;font-size:7px;letter-spacing:.7px}.range-value-head small{color:#667072;font-size:6px;letter-spacing:.4px}.range-value-inputs{display:grid;grid-template-columns:minmax(90px,1fr) 76px;align-items:center;gap:8px}.range-value-slider{width:100%;height:22px!important;margin:0!important;padding:0!important;accent-color:var(--shd-orange);cursor:pointer}.range-value-number{width:100%;height:34px!important;margin:0!important;padding:0 7px!important}.range-value-control:focus-within{border-color:#8a672b;background:#121516}.range-value-slider:active{filter:brightness(.86)}.builder-equipment-controls{display:grid;grid-template-columns:minmax(190px,.7fr) minmax(0,1.3fr);align-items:start;gap:7px;margin-top:8px}.builder-equipment-controls>.builder-radio-grid:last-child:nth-child(3){grid-column:1/3}.builder-weapon-controls{display:grid;grid-template-columns:minmax(190px,.85fr) minmax(120px,.55fr) minmax(230px,1fr);align-items:end;gap:7px;margin-top:8px}.builder-weapon-controls>.segmented-choice>div{height:50px}.builder-radio-grid{min-width:0;margin:8px 0 0;padding:0;border:0}.builder-radio-grid legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.builder-radio-search{height:34px!important;margin:0 0 5px!important;padding:0 9px!important;font-size:7px!important}.builder-radio-grid>div{display:grid;grid-template-columns:repeat(var(--builder-radio-columns),minmax(0,1fr));gap:4px}.builder-radio-grid>div>label{min-width:0;cursor:pointer}.builder-radio-grid input[type=radio]{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.builder-radio-grid label>span{min-height:58px;display:grid;grid-template-columns:34px minmax(0,1fr);grid-template-rows:1fr 1fr;align-items:center;gap:1px 7px;padding:6px;border:1px solid #303536;background:#0d1011;color:#747c7e;transition:var(--shd-transition)}.builder-radio-grid img,.builder-radio-grid i{grid-row:1/3;width:32px;height:32px;object-fit:contain}.builder-radio-grid i{display:grid;place-items:center;border:1px solid #343a3b;color:#9a702e;font-size:7px;font-style:normal}.builder-radio-grid b{align-self:end;overflow:hidden;color:#b9bfc0;font-size:7px;line-height:1.25;text-overflow:ellipsis;white-space:nowrap}.builder-radio-grid small{align-self:start;overflow:hidden;color:#626a6c;font-size:5px;text-overflow:ellipsis;white-space:nowrap}.builder-radio-grid label:hover>span{border-color:#765b2e;background:#16191a}.builder-radio-grid label:active>span{transform:translateY(1px);filter:brightness(.88)}.builder-radio-grid input:checked+span{border-color:#9a6c27;background:#1d180e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.builder-radio-grid input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.builder-radio-grid>div>p{grid-column:1/-1;margin:0;padding:12px;border:1px solid var(--shd-border);color:#626a6c;font-size:7px;text-align:center}.builder-talent-grids{display:grid;gap:3px;margin-top:6px}.builder-rolls.required>div{grid-template-columns:minmax(0,1fr) minmax(180px,.65fr)}@media(max-width:650px){.builder-equipment-controls,.builder-weapon-controls{grid-template-columns:1fr}.builder-equipment-controls>.builder-radio-grid:last-child:nth-child(3),.builder-weapon-controls .builder-fixed{grid-column:auto}.builder-radio-grid>div{grid-template-columns:repeat(2,minmax(0,1fr))}.builder-radio-grid label>span{min-height:62px}.builder-weapon-controls>.segmented-choice>div{height:44px}.range-value-inputs{grid-template-columns:minmax(0,1fr) 84px}}@media(max-width:430px){.builder-radio-grid>div{grid-template-columns:1fr}.range-value-inputs{grid-template-columns:1fr}.range-value-number{height:40px!important}} /* Builder workbench */ .loadout-workspace{display:grid;grid-template-columns:minmax(230px,280px) minmax(420px,1fr) minmax(290px,350px);gap:10px;align-items:start}.loadout-slots,.loadout-editor,.loadout-summary{border:1px solid var(--shd-border);background:#111415}.loadout-slots>header,.summary-section>header{display:flex;justify-content:space-between;align-items:center;padding:9px 10px;border-bottom:1px solid var(--shd-border);background:#0d1011;color:#6d7577;font-size:7px;letter-spacing:1px}.loadout-slots>header b,.summary-section>header b{color:var(--shd-orange)}.loadout-slot-group{display:grid;gap:4px;padding:5px;border-bottom:1px solid #292e2f}.loadout-slot-group.weapons{grid-template-columns:1fr 1fr}.loadout-slot-group.gear{grid-template-columns:1fr 1fr}.loadout-slot-group.systems{grid-template-columns:1fr 1fr}.loadout-slot-group button{min-width:0;min-height:65px;display:grid;align-content:center;gap:3px;padding:8px;border:1px solid #2b3031;border-left:2px solid #3b4142;background:#101314;color:#c5c9ca;text-align:left;cursor:pointer}.loadout-slot-group button:hover,.loadout-slot-group button:focus-visible{border-color:#8b682b;background:#171b1c}.loadout-slot-group button:active{transform:translateY(1px);filter:brightness(.88)}.loadout-slot-group button.active{border-left-color:var(--shd-orange);background:#1b170f}.loadout-slot-group small{color:#646c6e;font-size:6px;letter-spacing:.8px}.loadout-slot-group strong{overflow:hidden;font-size:8px;text-overflow:ellipsis;white-space:nowrap}.loadout-slot-group span{overflow:hidden;color:#7c8486;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.loadout-editor{min-height:680px;padding:12px;border-top:2px solid var(--shd-orange)}.loadout-editor-head{display:flex;justify-content:space-between;align-items:end;padding-bottom:9px;border-bottom:1px solid #303536}.loadout-editor-head span{color:#687072;font-size:6px;letter-spacing:1px}.loadout-editor-head h2{margin:3px 0 0;font-size:19px}.loadout-editor-head>b{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.builder-picker{position:relative;margin-top:9px}.builder-picker>span{display:block;margin-bottom:5px;color:#737b7d;font-size:7px;font-weight:800;letter-spacing:.8px}.builder-picker-current{width:100%;min-height:48px;display:grid;grid-template-columns:34px minmax(0,1fr) auto;grid-template-rows:1fr 1fr;align-items:center;column-gap:8px;padding:6px 9px;border:1px solid #343a3b;background:#0e1112;color:#d7d9d5;text-align:left}.builder-picker-current img,.builder-picker-current>i,.builder-picker-panel button img,.builder-picker-panel button>i{grid-row:1/3;width:30px;height:30px;object-fit:contain}.builder-picker-current>i,.builder-picker-panel button>i{display:grid;place-items:center;color:var(--shd-orange);font-style:normal}.builder-picker-current b{align-self:end;font-size:9px}.builder-picker-current small{align-self:start;color:#666e70;font-size:6px}.builder-picker-current em{grid-column:3;grid-row:1/3;color:var(--shd-orange);font-size:14px;font-style:normal}.builder-picker-panel{position:absolute;z-index:30;top:100%;left:0;right:0;padding:6px;border:1px solid #5a4a2d;background:#0b0e0f;box-shadow:0 12px 25px #000b}.builder-picker-panel>input{height:36px;margin:0 0 5px;padding:0 9px;font-size:8px}.builder-picker-panel>div{max-height:250px;overflow:auto}.builder-picker-panel>div>button{width:100%;min-height:42px;display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:7px;padding:5px 7px;border:0;border-top:1px solid #272c2d;background:#111415;color:#c4c8c9;text-align:left}.builder-picker-panel>div>button:hover,.builder-picker-panel>div>button.selected{background:#1b170f}.builder-picker-panel>div>button:disabled{opacity:.35}.builder-picker-panel button span{display:grid;gap:2px}.builder-picker-panel button b{font-size:8px}.builder-picker-panel button small,.builder-picker-panel button em{color:#687072;font-size:6px;font-style:normal}.builder-picker-panel button.selected em{color:var(--shd-orange)}.builder-picker-panel p{padding:12px;color:#626a6c;font-size:7px}.builder-field-grid,.builder-range-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:8px}.builder-field-grid>label,.builder-range-grid>label,.builder-chip-rolls label,.builder-rolls label{color:#737b7d;font-size:7px;font-weight:800;letter-spacing:.7px}.builder-field-grid select,.builder-range-grid input,.builder-chip-rolls input,.builder-rolls input{height:38px;margin-top:5px;padding:0 8px;font-size:9px}.builder-range-grid label>span{float:right;color:#5f6769;font-size:6px}.builder-range-grid.weapon{grid-template-columns:repeat(2,1fr)}.builder-fixed{display:flex;align-items:end;gap:5px;padding-bottom:1px}.builder-fixed span{flex:1;padding:9px 5px;border:1px solid #303536;color:#8e9698;font-size:6px;text-align:center}.builder-section-title{display:flex;justify-content:space-between;align-items:end;margin:13px 0 5px}.builder-section-title>div{display:grid;gap:3px}.builder-section-title span{font-size:8px;font-weight:900;letter-spacing:.8px}.builder-section-title small{color:#626a6c;font-size:6px}.builder-section-title button{border:0;background:transparent;color:var(--shd-orange);font-size:7px}.builder-section-title>b{color:var(--shd-orange);font-size:7px}.builder-rolls{display:grid;gap:5px}.builder-rolls>div,.builder-chip-rolls>div{display:grid;grid-template-columns:minmax(0,1fr) 105px 28px;align-items:end;gap:6px;padding:6px;border:1px solid #2d3233;background:#0f1213}.builder-rolls .builder-picker,.builder-chip-rolls .builder-picker{margin:0}.builder-rolls>div>button{height:38px;border:1px solid #563734;background:#1d1212;color:#da746c}.builder-chip-rolls{display:grid;gap:6px;margin-top:8px}.builder-chip-rolls>div{grid-template-columns:minmax(0,1fr) 105px}.builder-augment-list{display:grid;grid-template-columns:1fr 1fr;gap:5px}.builder-augment-list label{min-height:45px;display:grid;grid-template-columns:18px 1fr auto;align-items:center;gap:7px;padding:7px;border:1px solid #303536;background:#0f1213;cursor:pointer}.builder-augment-list input{width:auto;margin:0;accent-color:var(--shd-orange)}.builder-augment-list span{display:grid;gap:3px}.builder-augment-list b{font-size:7px}.builder-augment-list small{color:#687072;font-size:6px}.builder-augment-list em{color:#a77b31;font-size:6px;font-style:normal}.builder-inherited{display:grid;grid-template-columns:repeat(3,1fr);margin-top:8px;border:1px solid #303536}.builder-inherited span{display:grid;gap:4px;padding:8px;border-left:1px solid #303536}.builder-inherited span:first-child{border-left:0}.builder-inherited small{color:#687072;font-size:6px}.builder-inherited b{color:var(--shd-orange);font-size:9px}.loadout-summary{position:sticky;top:88px;border-top:2px solid var(--shd-orange)}.summary-head{display:flex;justify-content:space-between;align-items:end;padding:12px}.summary-head h2{margin:4px 0 0;font-size:20px}.summary-head>b{color:var(--shd-orange);font-size:22px}.summary-core{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #303536;border-bottom:1px solid #303536}.summary-core>div{display:flex;align-items:center;gap:5px;padding:9px 6px;border-left:1px solid #303536}.summary-core>div:first-child{border-left:0}.summary-core .game-icon{width:22px;height:22px}.summary-core span{display:grid;color:#687072;font-size:5px}.summary-core b{color:#d6d9da;font-size:9px}.summary-scroll{max-height:calc(100vh - 340px);overflow:auto}.summary-section>header{position:sticky;top:0;z-index:2}.summary-stat,.summary-bonus{display:flex;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid #252a2b}.summary-stat>span,.summary-bonus>span{display:grid;gap:3px;font-size:8px}.summary-stat small,.summary-bonus small{color:#626a6c;font-size:5px}.summary-stat>b{color:#d9dbdc;font-size:9px}.summary-bonus em{color:var(--shd-success);font-size:7px;font-style:normal;text-align:right}.summary-section>p{margin:0;padding:12px;color:#626a6c;font-size:7px;line-height:1.5}.summary-talent{display:grid;grid-template-columns:18px 1fr;gap:7px;padding:9px 10px;border-top:1px solid #252a2b;cursor:pointer}.summary-talent input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.summary-talent span{display:grid;gap:3px}.summary-talent b{font-size:8px}.summary-talent small{color:#8a682f;font-size:5px}.summary-talent em{color:#727a7c;font:7px/1.45 Georgia,serif}.loadout-summary .notice{margin:0;padding:11px;border-top:1px solid #303536;border-left:0}.builder-empty-data{display:flex;gap:12px;margin-bottom:10px;padding:12px;border:1px solid #65403a;border-left:3px solid var(--shd-danger);background:#1c1111}.builder-empty-data strong{font-size:8px}.builder-empty-data span{color:#a87870;font-size:8px}@media(max-width:1200px){.loadout-workspace{grid-template-columns:240px minmax(390px,1fr)}.loadout-summary{position:static;grid-column:1/3}.summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,1fr)}.summary-section{border-left:1px solid #303536}.summary-section:first-child{border-left:0}}@media(max-width:800px){.loadout-workspace{grid-template-columns:1fr}.loadout-slots,.loadout-editor,.loadout-summary{grid-column:auto}.loadout-editor{min-height:0}.summary-scroll{display:block}.summary-section{border-left:0}.loadout-summary{order:3}.loadout-slot-group.gear{grid-template-columns:repeat(3,1fr)}}@media(max-width:560px){.loadout-slot-group.gear,.loadout-slot-group.systems{grid-template-columns:1fr 1fr}.builder-field-grid,.builder-range-grid,.builder-range-grid.weapon,.builder-augment-list,.builder-inherited{grid-template-columns:1fr}.builder-inherited span{border-left:0;border-top:1px solid #303536}.builder-rolls>div,.builder-chip-rolls>div{grid-template-columns:1fr}.builder-rolls>div>button{min-height:44px}.builder-picker-current{min-height:52px}.builder-picker-panel>div>button{min-height:46px}} diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index dbb6eab..304e67a 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -1,6 +1,6 @@ export type Roll = { statId: string; value: number }; -export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] }; -export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string }; +export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: [Roll, Roll]; augmentSlots: number[] }; +export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] }; export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] }; export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number }; export type LoadoutDraft = { @@ -15,8 +15,8 @@ export type LoadoutDraft = { export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const; export const blankRoll = (): Roll => ({ statId: '', value: 0 }); -export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] }); -export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' }); +export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] }); +export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] }); export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] }); export const createDraft = (): LoadoutDraft => ({ version: 1, @@ -36,3 +36,45 @@ export const clamp = (value: unknown, min: unknown, max: unknown) => { export const scaled = (min: unknown, max: unknown, progress: number, steps: number) => Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps; + +export type LevelScaledRange = { + rawMin: number; + rawMax: number; + min: number; + max: number; + provisional: boolean; +}; + +const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50)); +export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level); +export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level); + +export function levelScaledRange( + minimum: unknown, + maximum: unknown, + targetLevel: unknown, + referenceLevel: unknown, + source: 'gear' | 'weapon' +): LevelScaledRange { + const target = itemLevel(targetLevel); + const reference = itemLevel(referenceLevel); + const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier; + const ratio = multiplier(target) / multiplier(reference); + const configuredMin = Number(minimum); + const configuredMax = Number(maximum); + const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio; + const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio; + return { + rawMin, + rawMax, + min: Math.floor(rawMin), + max: Math.floor(rawMax), + provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40 + }; +} + +export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) { + const span = from.max - from.min; + const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0; + return Math.round(to.min + (to.max - to.min) * position); +} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 52cab94..7059809 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -132,7 +132,13 @@ {@render children()}