diff --git a/database/schema.sql b/database/schema.sql index f84982f..571d5c7 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -357,7 +357,6 @@ CREATE TABLE IF NOT EXISTS gear ( name TEXT NOT NULL, slot TEXT NOT NULL CHECK(slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2), - level INTEGER NOT NULL CHECK(level BETWEEN 1 AND 50), base_health_min REAL NOT NULL CHECK(base_health_min >= 0), base_health_max REAL NOT NULL CHECK(base_health_max >= base_health_min), base_armor_min REAL, @@ -386,9 +385,9 @@ CREATE TABLE IF NOT EXISTS gear_tactical_augments ( id TEXT PRIMARY KEY, gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), - stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')), + stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, - unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(gear_slot, augment_slot), @@ -396,8 +395,19 @@ CREATE TABLE IF NOT EXISTS gear_tactical_augments ( (gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3) + ), + CHECK( + (augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) + OR + (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0) ) ); +CREATE TABLE IF NOT EXISTS gear_tactical_augment_costs ( + augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE, + gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2), + unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), + PRIMARY KEY(augment_id, gear_tier) +); CREATE TABLE IF NOT EXISTS weapons ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 72ab6ec..b260d3e 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -809,11 +809,20 @@ editor. Successful saves close the profile editor and return to the registry. Gear Tactical Augment profiles use this pattern for all six gear types. Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee -Pads, and Masks expose three. Every slot contains exactly one searchable -Attribute relationship, one numeric effect value, and one non-negative -whole-number unlock cost. At narrow widths profile cards and augment editor -cards reduce from multi-column grids to a single column while retaining -44-pixel interactive targets. +Pads, and Masks expose three. Augment 01 contains one searchable Attribute, +one numeric effect value, and an unlock cost. Later slots expose only their +unlock cost because their effects are fixed base-item upgrades: three-slot +profiles use +50% and +100%, while four-slot profiles use +40%, +70%, and ++100%. Holsters, Masks, and Backpacks modify the equipped item's base Health; +Gloves, Knee Pads, and Body Armor modify that item's base Health and Armor. +These percentages never apply to the overall character total. Every augment +slot stores separate non-negative whole-number unlock costs for item Tiers 0, +1, and 2. Show all three costs together in registry summaries and in a compact +three-column cost fieldset within the editor; the builder shows only the cost +matching the equipped item's tier. Fixed upgrades +use a green inherited-value panel to distinguish governed data from editable +orange actions. At narrow widths profile cards and augment editor cards reduce +to a single column while retaining 44-pixel interactive targets. ### Loadout workbench @@ -837,3 +846,22 @@ the live totals immediately. Trigger-dependent talents remain explicitly `CONDITIONAL` until the user enables `TRIGGER ACTIVE`; only then may their 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. + +### Registry relationship icon rails + +When a table relationship contains a small bounded set of visually identified +records, show a compact icon rail instead of a numeric relationship count. +Rails use 30-pixel square cells with four-pixel spacing, uploaded transparent +icons, full accessible names, and native hover titles. Missing artwork falls +back to a short orange text mark rather than removing the relationship. + +Use this pattern only for small bounded sets that remain scannable, such as the +two-to-four compatible Brand Sets on Gear. Long or unbounded relationships +continue to use counts, searchable inspection, or expanded detail views. diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index bd0b1bd..8b9690e 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -145,6 +145,83 @@ if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'sk await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons'); } +const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); +if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) { + console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.'); + await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_legacy'); + await database.exec(`CREATE TABLE gear_tactical_augments ( + id TEXT PRIMARY KEY, + gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), + augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), + effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(gear_slot, augment_slot), + CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)), + CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)) + )`); + await database.exec(`INSERT INTO gear_tactical_augments + (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,unlock_cost,created_at,updated_at) + SELECT id,gear_slot,augment_slot, + CASE WHEN augment_slot=1 THEN 'Custom' ELSE 'Base Percentage' END, + CASE WHEN augment_slot=1 THEN stat_attribute_id ELSE NULL END, + CASE + WHEN augment_slot=1 THEN value + WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=2 THEN 40 + WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=3 THEN 70 + WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=4 THEN 100 + WHEN augment_slot=2 THEN 50 + ELSE 100 + END, + unlock_cost,created_at,updated_at + FROM gear_tactical_augments_legacy`); + await database.exec('DROP TABLE gear_tactical_augments_legacy'); +} + +const tieredGearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); +if (tieredGearAugmentColumns.some((column) => column.name === 'unlock_cost')) { + console.log('Separating Gear Tactical Augment unlock costs by item tier.'); + await database.exec('DROP TABLE IF EXISTS gear_tactical_augment_costs'); + await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_single_cost'); + await database.exec(`CREATE TABLE gear_tactical_augments ( + id TEXT PRIMARY KEY, + gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), + augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), + effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(gear_slot, augment_slot), + CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)), + CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)) + )`); + await database.exec(`INSERT INTO gear_tactical_augments + (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at) + SELECT id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at + FROM gear_tactical_augments_single_cost`); + await database.exec(`CREATE TABLE gear_tactical_augment_costs ( + augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE, + gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2), + unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), + PRIMARY KEY(augment_id, gear_tier) + )`); + await database.exec(`INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) + SELECT id,0,unlock_cost FROM gear_tactical_augments_single_cost + UNION ALL SELECT id,1,unlock_cost FROM gear_tactical_augments_single_cost + UNION ALL SELECT id,2,unlock_cost FROM gear_tactical_augments_single_cost`); + await database.exec('DROP TABLE gear_tactical_augments_single_cost'); +} + +const gearColumns = await (await database.prepare("PRAGMA table_info('gear')")).all(); +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'); +} + console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { diff --git a/src/app.css b/src/app.css index e19d05a..5ee4cd0 100644 --- a/src/app.css +++ b/src/app.css @@ -142,6 +142,11 @@ /* Gear shared Tactical Augment profiles */ .gear-augment-registry{margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.gear-augment-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.gear-augment-registry>summary::-webkit-details-marker{display:none}.gear-augment-registry>summary:hover,.gear-augment-registry>summary:focus-visible{background:#171b1c}.gear-augment-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.gear-augment-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.gear-augment-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.gear-augment-registry[open]>summary i{transform:rotate(45deg)}.gear-augment-profile-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.gear-augment-profile-grid>button{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.gear-augment-profile-grid>button:hover,.gear-augment-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.gear-augment-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.gear-augment-profile-grid header{display:flex;justify-content:space-between;align-items:center;padding-bottom:6px;border-bottom:1px solid #292e2f}.gear-augment-profile-grid header div{display:grid;gap:2px}.gear-augment-profile-grid header strong{color:#d7d9d5;font-size:9px}.gear-augment-profile-grid header small,.gear-augment-profile-grid header>span{color:#626a6c;font-size:6px;letter-spacing:.6px}.gear-augment-profile-grid>button.configured header>span{color:var(--shd-success)}.gear-augment-profile-grid>button>div{display:grid;margin-top:5px}.gear-augment-profile-grid>button>div span{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:6px;padding:5px 2px;border-top:1px solid #232829}.gear-augment-profile-grid>button>div span:first-child{border-top:0}.gear-augment-profile-grid>button>div small{color:var(--shd-orange);font-size:6px}.gear-augment-profile-grid>button>div b{overflow:hidden;color:#aeb4b5;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.gear-augment-profile-grid>button>div em{color:#626a6c;font-size:6px;font-style:normal}.gear-augment-editor{border-top-color:var(--shd-success)}.gear-augment-slots{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.gear-augment-slots section{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#101314}.gear-augment-slots section>header{display:flex;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid #2a2f30}.gear-augment-slots section>header strong{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.gear-augment-slots section>header span{color:#596164;font-size:6px}.gear-augment-slots .field{margin:7px 0 0}.gear-augment-slots .field>span,.gear-augment-slots label{font-size:7px}@media(max-width:1100px){.gear-augment-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-augment-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.gear-augment-profile-grid,.gear-augment-slots{grid-template-columns:1fr}.gear-augment-profile-grid>button{min-height:44px}} +.registry-icon-rail{display:flex!important;align-items:center;gap:4px}.registry-icon-rail>span{width:30px;height:30px;display:grid;place-items:center;border:1px solid #353b3c;background:#0d1011}.registry-icon-rail img{width:26px;height:26px;object-fit:contain}.registry-icon-rail i{color:var(--shd-orange);font-size:7px;font-style:normal;font-weight:900;letter-spacing:.3px}.registry-icon-rail>span:hover{border-color:#8a672b;background:#171b1c} +.gear-fixed-augment{min-height:70px;display:grid;align-content:center;gap:4px;margin-top:7px;padding:8px;border:1px solid #34473a;border-left:2px solid var(--shd-success);background:#101713}.gear-fixed-augment span,.gear-fixed-augment small{color:#718078;font-size:6px;letter-spacing:.6px}.gear-fixed-augment b{color:var(--shd-success);font-size:17px} +.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:2fr 1fr .65fr} +.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 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 4ceff00..dbb6eab 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -1,5 +1,5 @@ export type Roll = { statId: string; value: number }; -export type GearSelection = { itemId: string; 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[]; augmentSlots: number[] }; export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string }; export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] }; export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number }; @@ -15,7 +15,7 @@ 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: '', brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] }); +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 blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] }); export const createDraft = (): LoadoutDraft => ({ diff --git a/src/routes/admin/gear/+page.server.ts b/src/routes/admin/gear/+page.server.ts index 12459e0..581ab8e 100644 --- a/src/routes/admin/gear/+page.server.ts +++ b/src/routes/admin/gear/+page.server.ts @@ -7,14 +7,22 @@ import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { getPendingEntities } from '$lib/server/data-moderation'; +import { gameDataIconUrl } from '$lib/server/game-data-icons'; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3; +const augmentPercentage = (slot: string, augmentSlot: number) => + slot === 'Backpack' || slot === 'Body Armor' + ? [0, 0, 40, 70, 100][augmentSlot] + : [0, 0, 50, 100][augmentSlot]; async function augmentProfileSnapshot(slot: string) { const database = await getDatabase(); - return (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot); + const augments = await (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot); + const costs = await (await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost + JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)).all(slot); + return augments.map((augment) => ({ ...augment, costs: costs.filter((cost) => cost.augment_id === augment.id) })); } export const load: PageServerLoad = async ({ cookies, url }) => { @@ -29,14 +37,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : ''; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [gear, brandLinks, talentLinks, brands, talents, augmentRows, stats, pending] = await Promise.all([ + const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), (await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(), (await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(), (await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(), (await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(), + (await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), + (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(), getPendingEntities(user.id, [ { module: 'brands', action: 'save', kind: 'brand' }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] } @@ -50,12 +60,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => { brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)), talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)) })), - brands: [...pendingBrands, ...brands], + brands: [...pendingBrands, ...brands.map((brand) => { + const icon = brandIcons.find((item) => item.entity_id === brand.id); + return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' }; + })], talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))], augmentProfiles: slots.map((slot) => ({ slot, requiredSlots: augmentSlotCount(slot), - augments: augmentRows.filter((augment) => augment.gear_slot === slot) + augments: augmentRows.filter((augment) => augment.gear_slot === slot).map((augment) => ({ + ...augment, + costs: augmentCosts.filter((cost) => cost.augment_id === augment.id) + })) })), stats, summary, @@ -80,20 +96,24 @@ export const actions: Actions = { const requiredSlots = augmentSlotCount(slot); const augments = Array.from({ length: requiredSlots }, (_, index) => ({ slot: index + 1, - statId: String(form.get(`statId${index + 1}`) ?? ''), + effectType: index === 0 ? 'Custom' : 'Base Percentage', + statId: index === 0 ? String(form.get('statId1') ?? '') : '', rawValue: String(form.get(`value${index + 1}`) ?? '').trim(), - rawUnlockCost: String(form.get(`unlockCost${index + 1}`) ?? '').trim(), - value: Number(form.get(`value${index + 1}`)), - unlockCost: Number(form.get(`unlockCost${index + 1}`)) + value: index === 0 ? Number(form.get('value1')) : augmentPercentage(slot, index + 1), + costs: [0, 1, 2].map((tier) => ({ + tier, + raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(), + value: Number(form.get(`unlockCost${index + 1}Tier${tier}`)) + })) })); - if (augments.some((augment) => !augment.statId || !augment.rawValue || !Number.isFinite(augment.value))) { - return fail(400, { augmentProfileError: 'Every tactical augment slot requires exactly one attribute and value.' }); + if (!augments[0].statId || !augments[0].rawValue || !Number.isFinite(augments[0].value)) { + return fail(400, { augmentProfileError: 'Tactical Augment 01 requires one custom attribute and value.' }); } - if (augments.some((augment) => !augment.rawUnlockCost || !Number.isInteger(augment.unlockCost) || augment.unlockCost < 0)) { - return fail(400, { augmentProfileError: 'Every tactical augment slot requires a non-negative whole-number unlock cost.' }); + if (augments.some((augment) => augment.costs.some((cost) => !cost.raw || !Number.isInteger(cost.value) || cost.value < 0))) { + return fail(400, { augmentProfileError: 'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.' }); } const database = await getDatabase(); - const statIds = [...new Set(augments.map((augment) => augment.statId))]; + const statIds = [augments[0].statId]; const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds); if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' }); const before = await augmentProfileSnapshot(slot); @@ -102,9 +122,13 @@ export const actions: Actions = { try { await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot); for (const augment of augments) { + const augmentId = randomUUID(); await (await database.prepare(`INSERT INTO gear_tactical_augments - (id,gear_slot,augment_slot,stat_attribute_id,value,unlock_cost,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?)`)).run(randomUUID(), slot, augment.slot, augment.statId, augment.value, augment.unlockCost, now, now); + (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`)).run(augmentId, slot, augment.slot, augment.effectType, augment.statId || null, augment.value, now, now); + for (const cost of augment.costs) { + await (await database.prepare('INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)')).run(augmentId, cost.tier, cost.value); + } } await database.exec('COMMIT'); } catch (error) { @@ -128,12 +152,11 @@ export const actions: Actions = { const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const slot = String(form.get('slot') ?? ''); const tier = Number(form.get('tier')); - const level = Number(form.get('level')); const brandIds = [...new Set(form.getAll('brandIds').map(String))]; const talentIds = [...new Set(form.getAll('talentIds').map(String))]; if (name.length < 2 || name.length > 80) return fail(400, { gearError: 'Gear names must be 2–80 characters.' }); - if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2 || !Number.isInteger(level) || level < 1 || level > 50) { - return fail(400, { gearError: 'Choose a valid slot, tier, and level.' }); + if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2) { + return fail(400, { gearError: 'Choose a valid slot and tier.' }); } if (brandIds.length < 2) return fail(400, { gearError: 'Gear must be available from at least two brand sets.' }); const talentCategory = slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null; @@ -160,11 +183,11 @@ export const actions: Actions = { const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null; if (id) { - await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,level=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`)) - .run(name, slot, tier, level, ...health, ...armor, now, id); + await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`)) + .run(name, slot, tier, ...health, ...armor, now, id); } else { - await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,level,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)) - .run(gearId, name, slot, tier, level, ...health, ...armor, now, now); + await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`)) + .run(gearId, name, slot, tier, ...health, ...armor, now, now); } await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId); for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId); diff --git a/src/routes/admin/gear/+page.svelte b/src/routes/admin/gear/+page.svelte index c7dbc89..fd84abd 100644 --- a/src/routes/admin/gear/+page.svelte +++ b/src/routes/admin/gear/+page.svelte @@ -4,13 +4,14 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; let { data, form } = $props(); - type Editor = { id: string; name: string; slot: string; tier: number; level: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] }; + type Editor = { id: string; name: string; slot: string; tier: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] }; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; - const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); + const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); let editing = $state(null); let augmentEditing = $state(null); let handledResultToken = $state(''); + const compatibleBrands = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)).filter(Boolean); const stat = (id: string) => data.stats.find((item) => item.id === id); const format = (value: number | string, statId: string) => { const numeric = Number(value); @@ -23,12 +24,24 @@ const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; }; const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot); const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; }; + const baseTargets = (slot: string) => ['Gloves', 'Knee Pads', 'Body Armor'].includes(slot) ? 'BASE HEALTH + ARMOR' : 'BASE HEALTH'; + const fixedPercentage = (slot: string, augmentSlot: number) => ['Backpack', 'Body Armor'].includes(slot) + ? [0, 0, 40, 70, 100][augmentSlot] + : [0, 0, 50, 100][augmentSlot]; const editAugments = (profile: any) => { augmentEditing = { ...profile, augments: Array.from({ length: profile.requiredSlots }, (_, index) => { const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1); - return augment ? { statId: augment.stat_attribute_id, value: augment.value, unlockCost: augment.unlock_cost } : { statId: '', value: '', unlockCost: '' }; + return augment + ? { + effectType: augment.effect_type, statId: augment.stat_attribute_id ?? '', value: augment.value, + costs: Object.fromEntries([0,1,2].map((tier) => [tier, augment.costs.find((cost: any) => Number(cost.gear_tier) === tier)?.unlock_cost ?? ''])) + } + : { + effectType: index === 0 ? 'Custom' : 'Base Percentage', statId: '', + value: index === 0 ? '' : fixedPercentage(profile.slot, index + 1), costs: { 0: '', 1: '', 2: '' } + }; }) }; editing = null; @@ -53,7 +66,7 @@ {#each data.augmentProfiles as profile} {/each} @@ -64,14 +77,18 @@ -

These {augmentEditing.requiredSlots} augment slots apply to every {augmentEditing.slot} item. Each slot grants exactly one attribute and unlocks at its configured tactical-augment cost.

+

Augment 01 grants a custom attribute. Later slots increase {baseTargets(augmentEditing.slot).toLocaleLowerCase()} on the equipped item itself, before those item values enter agent totals.

{#each augmentEditing.augments as augment: any, index}
-
TACTICAL AUGMENT 0{index + 1}SHARED SLOT
-
ATTRIBUTE
- - +
TACTICAL AUGMENT 0{index + 1}{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}
+ {#if index === 0} +
ATTRIBUTE
+ + {:else} +
{baseTargets(augmentEditing.slot)}+{augment.value}%APPLIES TO THIS ITEM'S CONFIGURED BASE VALUE
+ {/if} +
UNLOCK COST BY ITEM TIER
{#each [0,1,2] as tier}{/each}
{/each}
@@ -90,7 +107,6 @@ -
HPBASE HEALTH
@@ -111,8 +127,8 @@ {/if}
-
GEARSLOTPROGRESSIONBASE RANGESBRAND SETSSTATE
+
GEARSLOTTIERBASE RANGESBRAND SETSSTATE
{#each data.gear as item} -
{item.name}{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · LVL {item.level}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}{item.brandIds.length} COMPATIBLE{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{item.name}{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
{:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index 6fbfc1f..6799883 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -5,7 +5,7 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { const database = await getDatabase(); const [ - attributes, gear, gearBrands, gearTalents, gearAugments, brands, brandEffects, + attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects, weapons, weaponRanges, weaponTalentOptions, weaponBonuses, talents, talentTiers, talentEffects, talentTemplates, chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects, @@ -17,6 +17,7 @@ export const load: PageServerLoad = async () => { (await database.prepare('SELECT * FROM gear_brand_sets')).all(), (await database.prepare('SELECT * FROM gear_talents')).all(), (await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(), + (await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(), (await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(), (await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(), @@ -61,7 +62,10 @@ export const load: PageServerLoad = async () => { brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)), talentIds: gearTalents.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)) })), - gearAugments, + gearAugments: gearAugments.map((augment) => ({ + ...augment, + costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id) + })), brands: brands.map((brand) => ({ ...brand, iconUrl: iconUrl('brand_set', brand.id), diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index e71d9eb..a27cc05 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -57,6 +57,7 @@ const configureGear = (slot: string, id: string) => { const selection = draft.gear[slot], item = data.gear.find((entry) => entry.id === id); selection.itemId = id; + selection.level = 50; selection.brandId = item?.brandIds[0] ?? ''; selection.talentId = item?.talentIds[0] ?? ''; selection.health = Number(item?.base_health_min ?? 0); @@ -110,11 +111,13 @@ for (const slot of gearSlots) { const selection = draft.gear[slot], item = gearItem(slot); if (!item) continue; - add(byName('Health'), selection.health, `${slot} base`); - add(byName('Armor'), selection.armor, `${slot} base`); + const enabledAugments = data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot))); + const basePercentage = enabledAugments.filter((entry) => entry.effect_type === 'Base Percentage').reduce((total, entry) => total + Number(entry.value), 0); + add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`); + if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`); for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`); if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1); - for (const augment of data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)))) { + for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) { add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`); } if (selection.talentId) equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, tier: Number(item.tier), source: slot, description: talentDescription(selection.talentId, Number(item.tier)) }); @@ -195,6 +198,7 @@ const parsed = JSON.parse(stored); if (parsed?.version === 1) { draft = parsed; + for (const slot of gearSlots) if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = ''; saved = true; } @@ -223,15 +227,16 @@
{#if activeParts[0] === 'gear'} {@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)} -
GEAR SLOT

{slot.toUpperCase()}

{item ? `TIER ${item.tier} · LEVEL ${item.level}` : 'UNASSIGNED'}
+
GEAR SLOT

{slot.toUpperCase()}

{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}
entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/> {#if item} -
{#if item.talentIds.length}{/if}
+
{#if item.talentIds.length}{/if}
+

Gear level belongs to this build instance. Base-range level scaling will remain neutral until the authoritative game scaling curve is configured.

{#if item.base_armor_min !== null}{/if}
ROLLED ATTRIBUTESVALUES ARE LIMITED TO GEAR RANGES
{#each selection.rolls as roll, index}
setRollStat(roll,id,'gear')} label={`ATTRIBUTE ${index + 1}`}/>
{/each}
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)} - {#if augments.length}
TACTICAL AUGMENTSENABLE COLLECTED UPGRADES
{selection.augmentSlots.length} / {augments.length} ACTIVE
{#each augments as augment}{/each}
{/if} + {#if augments.length}
TACTICAL AUGMENTSENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY
{selection.augmentSlots.length} / {augments.length} ACTIVE
{#each augments as augment}{/each}
{/if} {/if} {:else if activeParts[0] === 'weapon'} {@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}