diff --git a/database/schema.sql b/database/schema.sql index 3a0b083..379194a 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -221,7 +221,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS data_change_proposals ( id TEXT PRIMARY KEY, - module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')), + module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')), action TEXT NOT NULL, entity_id TEXT, draft_entity_id TEXT, @@ -611,6 +611,79 @@ CREATE TABLE IF NOT EXISTS skill_chip_talent_effects ( UNIQUE(talent_id, stat_attribute_id), UNIQUE(talent_id, sort_order) ); +CREATE TABLE IF NOT EXISTS weapon_mods ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')), + exotic_weapon_id TEXT REFERENCES weapons(id), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS weapon_mod_compatible_categories ( + weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, + weapon_category TEXT NOT NULL CHECK(weapon_category IN ('shotgun','rifle','mmr','ar','lmg','smg')), + PRIMARY KEY(weapon_mod_id,weapon_category) +); +CREATE TABLE IF NOT EXISTS weapon_mod_slots ( + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2), + slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')), + PRIMARY KEY(weapon_id,tier,slot_type) +); +CREATE TABLE IF NOT EXISTS weapon_mod_quality_values ( + weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, + quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), + primary_level_1_min REAL NOT NULL, + primary_level_1_max REAL NOT NULL, + primary_level_20_min REAL NOT NULL, + primary_level_20_max REAL NOT NULL, + PRIMARY KEY(weapon_mod_id,quality), + CHECK(primary_level_1_max>=primary_level_1_min), + CHECK(primary_level_20_max>=primary_level_20_min), + CHECK(primary_level_20_min>=primary_level_1_min), + CHECK(primary_level_20_max>=primary_level_1_max) +); +CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options ( + weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + PRIMARY KEY(weapon_mod_id,stat_attribute_id) +); +CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values ( + weapon_mod_id TEXT NOT NULL, + stat_attribute_id TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality), + FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE, + CHECK(max_value>=min_value) +); +CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes ( + id TEXT PRIMARY KEY, + weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + level_1_value REAL NOT NULL, + level_20_value REAL NOT NULL, + sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 12), + UNIQUE(weapon_mod_id,stat_attribute_id), + UNIQUE(weapon_mod_id,sort_order) +); +CREATE TABLE IF NOT EXISTS weapon_mod_effects ( + id TEXT PRIMARY KEY, + weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 2), + UNIQUE(weapon_mod_id,stat_attribute_id), + UNIQUE(weapon_mod_id,sort_order) +); +CREATE TABLE IF NOT EXISTS weapon_mod_effect_quality_values ( + effect_id TEXT NOT NULL REFERENCES weapon_mod_effects(id) ON DELETE CASCADE, + quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), + value REAL NOT NULL, + PRIMARY KEY(effect_id,quality) +); CREATE TABLE IF NOT EXISTS game_data_icons ( entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')), entity_id TEXT NOT NULL, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 480a5e1..0ad4425 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1171,3 +1171,40 @@ state in plain language. Show a share-page action only after a non-private build has been saved. Public and unlisted build pages are read-only tactical summaries; owner editing returns to the builder. On narrow layouts, persistence controls and shared-loadout columns stack vertically. + +### Tiered compatibility editors + +When compatibility depends on two independent dimensions, keep those +dimensions explicit rather than presenting a flattened list. Weapon +definitions configure their available Weapon Mod slots per tier. T0 requires +four distinct slots; T1 and T2 require five. Weapon Mod definitions then +configure one slot plus one or more compatible weapon categories. The UI and +validation must require both dimensions to match. + +Weapon Mod definitions use the shared segmented-choice control for the bounded +eight-slot vocabulary, with a full-width row on desktop and two columns on +narrow screens. Weapon category compatibility uses the same control in +checkbox mode with weapon icons and retained accessible names. Show configured +versus required slot counts in text. Disabled tiers remain visibly unavailable +and must not submit slot assignments. + +Quality-scaled registry editors use a compact matrix when the same numeric +shape repeats across all four quality levels. Apply global quality colors only +to the quality labels, retain textual quality names, and keep numeric fields +neutral. On narrow screens the matrix may scroll horizontally while its +surrounding editor and previews stack. + +When two item slots draw from the same canonical option pool, edit that pool +once rather than rendering two duplicated selectors. Weapon Mods use a +repeatable secondary-attribute row: one searchable attribute reference plus +four quality range pairs. Show that the builder selects two distinct options +from the pool. Primary core type is likewise builder-selected and is displayed +as the three always-available core icons rather than a persisted ISAC choice. + +Exotic Weapon Mod status is a separate single-icon toggle using the exotic +icon and the standard pressed, focus-visible, and selected states. Selecting +it must switch the editor immediately, before a weapon is assigned. Exotic is +its own unique quality: reveal only the required exotic-weapon assignment, +remove weapon-category and regular-quality controls, and replace random ranges +with fixed level-1 and level-20 attribute values. Keep an explicit Exotic +Quality state panel visible so the change in data semantics is unmistakable. diff --git a/docs/architecture.md b/docs/architecture.md index 68ab9a8..f910862 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -233,6 +233,34 @@ talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades. Server actions validate all relationships and append the full owned graph to the existing `game_data` audit history. +### Weapon Mods and tiered weapon slots + +`weapon_mod_slots` records each weapon's available slot vocabulary per tier. +Canonical validation requires four unique slots for T0 and five for T1/T2 for +every tier at or above the weapon's native tier. Regular Weapon Mods declare a +single slot type and one or more compatible base weapon categories through +`weapon_mod_compatible_categories`; eligibility requires both a category match +and the slot to exist on the equipped weapon at its selected tier. + +Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry +regular category compatibility, are considered permanently equipped, and may +only be leveled. They do not use quality profiles, random primary rolls, +secondary pools, or regular Mod Effects. Their canonical contributions are +fixed attribute references with explicit level-1 and level-20 values in +`weapon_mod_exotic_attributes`; intermediate values interpolate linearly. + +All regular Weapon Mods permit Firepower, Toughness, or Engineering as a +builder-time primary choice. `weapon_mod_quality_values` stores the shared +level-1 and level-20 primary roll endpoints for each quality; primary values +interpolate linearly from levels 1–20. + +`weapon_mod_secondary_options` is the shared pool from which a build selects +two distinct secondary attributes. Every option owns a minimum and maximum at +each quality through `weapon_mod_secondary_quality_values`; secondary +availability is not tied to a numbered slot in canonical data. Mod Effects are +triggerless, hold one or two attribute references, and store fixed per-quality +values in `weapon_mod_effect_quality_values`. + # Skill Chip quality scaling Skill Chip sets support Superior and High-end chip instances. Each set owns one diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index e906e4e..246d536 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -127,6 +127,40 @@ await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_cre await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +const weaponModProposalDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" +)).get(); +if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDefinition.sql.includes("'weaponmods'")) { + console.log('Adding Weapon Mods to moderated data modules.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_weaponmods ( + id TEXT PRIMARY KEY, + module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')), + action TEXT NOT NULL, + entity_id TEXT, + draft_entity_id TEXT, + applied_entity_id TEXT, + summary TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), + proposed_by TEXT NOT NULL REFERENCES users(id), + reviewed_by TEXT REFERENCES users(id), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT, + applied_at TEXT + )`); + await database.exec(`INSERT INTO data_change_proposals_weaponmods + SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + FROM data_change_proposals`); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec('ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals'); + await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); + await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); + await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +} + const iconDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" )).get(); @@ -255,6 +289,54 @@ for (const [name, definition] of [ } } +const weaponModColumns = await (await database.prepare("PRAGMA table_info('weapon_mods')")).all(); +if (weaponModColumns.some((column) => column.name === 'primary_attribute_type')) { + console.log('Converting Weapon Mods to builder-selected primary types and pooled secondary attributes.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) + SELECT id,secondary_stat_1_id FROM weapon_mods + UNION SELECT id,secondary_stat_2_id FROM weapon_mods`); + await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_quality_values(weapon_mod_id,stat_attribute_id,quality,min_value,max_value) + SELECT values_row.weapon_mod_id,mods.secondary_stat_1_id,values_row.quality,values_row.secondary_1_min,values_row.secondary_1_max + FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id + UNION ALL + SELECT values_row.weapon_mod_id,mods.secondary_stat_2_id,values_row.quality,values_row.secondary_2_min,values_row.secondary_2_max + FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id`); + await database.exec(`CREATE TABLE weapon_mod_quality_values_pooled ( + weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, + quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), + primary_level_1_min REAL NOT NULL, + primary_level_1_max REAL NOT NULL, + primary_level_20_min REAL NOT NULL, + primary_level_20_max REAL NOT NULL, + PRIMARY KEY(weapon_mod_id,quality), + CHECK(primary_level_1_max>=primary_level_1_min), + CHECK(primary_level_20_max>=primary_level_20_min), + CHECK(primary_level_20_min>=primary_level_1_min), + CHECK(primary_level_20_max>=primary_level_1_max) + )`); + await database.exec(`INSERT INTO weapon_mod_quality_values_pooled + SELECT weapon_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max + FROM weapon_mod_quality_values`); + await database.exec('DROP TABLE weapon_mod_quality_values'); + await database.exec('ALTER TABLE weapon_mod_quality_values_pooled RENAME TO weapon_mod_quality_values'); + await database.exec(`CREATE TABLE weapon_mods_pooled ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')), + exotic_weapon_id TEXT REFERENCES weapons(id), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + await database.exec(`INSERT INTO weapon_mods_pooled(id,name,slot_type,exotic_weapon_id,active,created_at,updated_at) + SELECT id,name,slot_type,exotic_weapon_id,active,created_at,updated_at FROM weapon_mods`); + await database.exec('DROP TABLE weapon_mods'); + await database.exec('ALTER TABLE weapon_mods_pooled RENAME TO weapon_mods'); + await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE)'); + await database.exec('PRAGMA foreign_keys=ON'); +} + const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) { console.log('Adding persistent builder loadout snapshots.'); diff --git a/src/app.css b/src/app.css index 714d5f0..0838ea9 100644 --- a/src/app.css +++ b/src/app.css @@ -191,4 +191,8 @@ .notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:5px;white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:5px;font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:6px;letter-spacing:1px}.notification-detail-sender b{font-size:10px}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:6px;letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:7px}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 Georgia,serif}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}} .notification-command{padding:0 0 18px}.notification-command-actions{display:flex!important;align-items:stretch!important;gap:8px!important}.notification-unread-summary{min-width:86px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--shd-border);background:#0d1011}.notification-unread-summary strong{grid-row:auto!important;font-size:25px!important;line-height:1}.notification-unread-summary span{align-self:center;line-height:1.2}.notification-command-actions>button,.notification-toolbar button{min-height:40px;display:inline-flex;align-items:center;justify-content:center;margin:0!important;padding:0 18px;line-height:1;white-space:nowrap}.notification-command-actions>button>span{font-size:10px}.notification-toolbar{align-items:center;padding:10px 0}.notification-toolbar form{align-items:center;margin:0}.notification-toolbar select{min-width:125px;height:40px}.notification-toolbar form:first-child button{min-width:150px}.notification-toolbar>form:last-child{margin-left:auto}@media(max-width:700px){.notification-command-actions{width:100%;display:grid!important;grid-template-columns:auto 1fr}.notification-command-actions>button{width:100%}.notification-toolbar{align-items:stretch}.notification-toolbar>form:last-child{margin-left:0}} .profile-row>a{color:var(--shd-orange);font-size:8px;font-weight:700}.builder-save-form{display:inline-block}.builder-save-form fieldset{display:flex}.builder-persistence{display:grid;grid-template-columns:minmax(300px,520px) 1fr auto;gap:14px;align-items:end;padding:11px 2px;border-top:1px solid var(--shd-border)}.builder-persistence p{margin:0 0 9px;color:var(--shd-text-muted);font-size:9px}.builder-share-link{align-self:center;color:var(--shd-orange);font-size:8px;font-weight:700;letter-spacing:1px}.catalogue-empty{grid-column:1/-1;display:grid;justify-items:center;gap:12px;padding:60px;border:1px solid var(--shd-border);background:var(--shd-panel);text-align:center}.catalogue-empty>span{color:var(--shd-orange);font-size:34px}.catalogue-empty p{margin:0;color:var(--shd-text-muted)}.build-open{display:block;text-align:center}.shared-build-page{padding-top:28px}.shared-build-command{display:flex;justify-content:space-between;gap:24px;align-items:end;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-command h1{margin:8px 0;font-size:clamp(30px,4vw,52px)}.shared-build-command p:last-child{margin:0;color:var(--shd-text-muted);font-size:10px}.shared-build-command>div:last-child{display:flex;gap:8px}.shared-build-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}.shared-build-grid>section{border:1px solid var(--shd-border);background:var(--shd-panel)}.shared-build-grid>section>header{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:8px;letter-spacing:1.2px}.shared-build-grid article{display:grid;gap:4px;min-height:70px;padding:12px;border-bottom:1px solid var(--shd-border)}.shared-build-grid article:last-child{border-bottom:0}.shared-build-grid article small{color:var(--shd-text-muted);font-size:7px;letter-spacing:1px}.shared-build-grid article strong{font-size:12px}.shared-build-grid article span{color:var(--shd-text-muted);font-size:8px}@media(max-width:760px){.builder-command .page-title,.shared-build-command{align-items:stretch;flex-direction:column}.builder-command .actions,.shared-build-command>div:last-child{flex-wrap:wrap}.builder-persistence{grid-template-columns:1fr}.shared-build-grid{grid-template-columns:1fr}} +.weapon-mod-slot-config{margin-top:7px;padding-top:6px;border-top:1px solid var(--shd-border)}.weapon-mod-slot-config .assignment-picker{margin-top:5px}.weapon-mod-editor{border-top-color:var(--shd-orange)}.weapon-mod-identity{display:grid;grid-template-columns:minmax(300px,1.4fr) 52px minmax(280px,1fr);align-items:end;gap:8px}.weapon-mod-identity>.field,.weapon-mod-identity>.segmented-choice{margin:0}.weapon-mod-identity>.segmented-choice{grid-column:1/-1}.weapon-mod-identity>.segmented-choice>div{height:38px}.weapon-mod-assignment-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}.weapon-mod-secondary{display:grid;grid-template-columns:1fr 1fr;gap:7px}.weapon-mod-quality-section,.weapon-mod-effect-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-quality-section{border-left:2px solid var(--quality-high-end)}.weapon-mod-quality-table{display:grid;margin-top:7px;border:1px solid var(--shd-border)}.weapon-mod-quality-row{display:grid;grid-template-columns:110px repeat(4,minmax(180px,1fr));align-items:center;border-top:1px solid var(--shd-border)}.weapon-mod-quality-row:first-child{border-top:0}.weapon-mod-quality-row.heading{min-height:29px;background:#111415;color:var(--shd-text-muted);font-size:6px;letter-spacing:.8px}.weapon-mod-quality-row>span,.weapon-mod-quality-row>strong{padding:8px}.weapon-mod-quality-row>strong{font-size:7px;letter-spacing:.7px}.weapon-mod-quality-row>div{display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:4px;padding:5px;border-left:1px solid var(--shd-border)}.weapon-mod-quality-row input{height:30px;margin:0;padding:0 6px;font-size:8px}.weapon-mod-quality-row i{color:#596164;font-size:7px;font-style:normal;text-align:center}.weapon-mod-preview{display:grid;grid-template-columns:minmax(360px,.8fr) minmax(420px,1.2fr);align-items:end;gap:8px;margin-top:8px}.weapon-mod-preview>div:first-child{display:grid;grid-template-columns:1fr 1.2fr;align-items:end;gap:8px}.weapon-mod-preview>div:first-child>label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:.8px}.weapon-mod-preview input[type=range]{height:32px;margin:0;padding:0;accent-color:var(--shd-orange)}.weapon-mod-preview-values{display:grid;grid-template-columns:1.2fr 1fr 1fr;border:1px solid var(--shd-border)}.weapon-mod-preview-values>span{min-height:48px;display:grid;grid-template-columns:auto 1fr;grid-template-rows:1fr 1fr;align-items:center;gap:2px 7px;padding:7px;border-left:1px solid var(--shd-border)}.weapon-mod-preview-values>span:first-child{border-left:0}.weapon-mod-preview-values .game-icon{grid-row:1/3;width:28px;height:28px}.weapon-mod-preview-values small{align-self:end;color:var(--shd-text-muted);font-size:5px}.weapon-mod-preview-values b{align-self:start;color:var(--shd-orange);font-size:10px}.weapon-mod-effects{display:grid;gap:6px;margin-top:7px}.weapon-mod-effects>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(90px,.45fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-effects>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:8px}.weapon-mod-effects label{display:grid;gap:4px}.weapon-mod-effects label span{font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-effects input{height:32px;margin:0;padding:0 6px}.weapon-mod-effects>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-effects>div>button:disabled{opacity:.3;cursor:not-allowed}.weapon-mod-rule{padding:10px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d;color:#a89568;font-size:8px}.weapon-mod-rule b{color:var(--quality-high-end)}.weapon-mod-row{min-width:1240px;grid-template-columns:1.15fr .55fr .65fr 1fr 1fr .55fr .5fr .3fr}.weapon-mod-primary{display:flex;align-items:center;gap:5px}.weapon-mod-primary .game-icon{width:22px;height:22px}.weapon-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:1100px){.weapon-mod-identity{grid-template-columns:1fr 52px 1fr}.weapon-mod-assignment-grid,.weapon-mod-preview{grid-template-columns:1fr}.weapon-mod-quality-table{overflow-x:auto}.weapon-mod-quality-row{min-width:900px}.weapon-mod-effects>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-effects label:nth-of-type(3),.weapon-mod-effects label:nth-of-type(4),.weapon-mod-effects>div>button{grid-column:auto}}@media(max-width:700px){.weapon-mod-identity,.weapon-mod-assignment-grid,.weapon-mod-secondary,.weapon-mod-preview>div:first-child{grid-template-columns:1fr}.weapon-mod-identity>.segmented-choice{grid-column:auto}.weapon-mod-identity>.segmented-choice>div{height:auto;grid-template-columns:repeat(2,1fr)}.weapon-mod-identity>.segmented-choice span{min-height:44px}.weapon-mod-effects>div{grid-template-columns:30px 1fr}.weapon-mod-effects label,.weapon-mod-effects>div>button{grid-column:2}.weapon-mod-preview-values{grid-template-columns:1fr}.weapon-mod-preview-values>span{border-left:0;border-top:1px solid var(--shd-border)}.weapon-mod-preview-values>span:first-child{border-top:0}} +.weapon-mod-primary-types{min-height:58px;display:grid;align-content:center;gap:5px;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-primary-types>span{color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:.8px}.weapon-mod-primary-types>div{display:flex;gap:8px}.weapon-mod-primary-types b{display:flex;align-items:center;gap:3px;color:#aab0b1;font-size:6px}.weapon-mod-primary-types .game-icon{width:18px;height:18px}.weapon-mod-primary-types small{color:#596164;font-size:5px}.weapon-mod-compatibility{margin-top:10px}.weapon-mod-quality-row{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-quality-row.heading{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-preview-values{grid-template-columns:1.3fr .7fr}.weapon-mod-preview-values .core-icon-stack{grid-row:1/3}.weapon-mod-preview-values .core-icon-stack .game-icon{grid-row:auto;width:22px;height:22px}.weapon-mod-secondary-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--quality-specialized);background:#0d1011}.weapon-mod-secondary-options{display:grid;gap:6px;margin-top:7px}.weapon-mod-secondary-options>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(150px,.65fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-secondary-options>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:8px}.weapon-mod-secondary-options label{display:grid;gap:4px}.weapon-mod-secondary-options label>span{font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-secondary-options label>div{display:grid;grid-template-columns:1fr 12px 1fr;align-items:center}.weapon-mod-secondary-options input{height:32px;margin:0;padding:0 5px}.weapon-mod-secondary-options i{color:#596164;font-size:6px;font-style:normal;text-align:center}.weapon-mod-secondary-options>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-secondary-options>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:1100px){.weapon-mod-quality-row,.weapon-mod-quality-row.heading{min-width:580px}.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-secondary-options label:nth-of-type(3),.weapon-mod-secondary-options label:nth-of-type(4){grid-column:auto}}@media(max-width:700px){.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr}.weapon-mod-secondary-options label,.weapon-mod-secondary-options>div>button{grid-column:2}} +.weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:7px;letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:5px;letter-spacing:.35px} +.weapon-mod-exotic-toggle>span{width:44px;min-height:42px;padding:0}.weapon-mod-exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.weapon-mod-exotic-toggle .game-icon{width:30px;height:30px}.weapon-mod-compatibility .segmented-choice>div{height:48px}.weapon-mod-compatibility .segmented-choice .game-icon{width:34px;height:34px}.weapon-mod-exotic-section{margin-top:10px;padding:9px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#0d1011}.weapon-mod-exotic-attributes{display:grid;gap:6px;margin-top:7px}.weapon-mod-exotic-attributes>div{display:grid;grid-template-columns:30px minmax(240px,1fr) 150px 150px 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-exotic-attributes>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--quality-high-end);font-size:8px}.weapon-mod-exotic-attributes label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-exotic-attributes input{height:32px;margin:0;padding:0 6px}.weapon-mod-exotic-attributes>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-exotic-attributes>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:700px){.weapon-mod-exotic-attributes>div{grid-template-columns:30px 1fr}.weapon-mod-exotic-attributes label,.weapon-mod-exotic-attributes>div>button{grid-column:2}.weapon-mod-compatibility .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.weapon-mod-compatibility .segmented-choice span{min-height:50px}} diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 8b0b6b2..0e49bce 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -27,7 +27,7 @@ export async function recordIsacAudit(input: { } export async function auditRecord(table: string, id: string): Promise | null> { - if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) { + if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) { throw new Error('Unsupported audit table'); } const database = await getDatabase(); diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index adee810..8b7b2bf 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -5,7 +5,7 @@ import type { AuthUser } from '$lib/server/auth/session'; import { recordIsacAudit } from '$lib/server/audit'; import { getContributorMode } from '$lib/server/contributor-mode'; -export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const; +export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'] as const; export type DataModule = typeof DATA_MODULES[number]; const proposalResultKeys: Partial>> = { @@ -17,6 +17,7 @@ const proposalResultKeys: Partial value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase(); - const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase(); + const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value.toUpperCase(); const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) }))); const openReview = (proposal: any) => { reviewing = proposal; @@ -24,7 +24,7 @@ })); }; const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value }))); - const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{'); + const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses', 'qualityValues'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{'); $effect(() => { if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) { handledToken = form.moderationToken; diff --git a/src/routes/isac/weaponmods/+page.server.ts b/src/routes/isac/weaponmods/+page.server.ts new file mode 100644 index 0000000..14fb47d --- /dev/null +++ b/src/routes/isac/weaponmods/+page.server.ts @@ -0,0 +1,245 @@ +import { auditRecord, recordIsacAudit } from '$lib/server/audit'; +import { requireDataView, requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { getPendingEntities } from '$lib/server/data-moderation'; +import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const; +const slots = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const; +const weaponCategories = ['shotgun', 'rifle', 'mmr', 'ar', 'lmg', 'smg'] as const; +type QualityValue = { + quality: string; + primaryLevel1Min: number; + primaryLevel1Max: number; + primaryLevel20Min: number; + primaryLevel20Max: number; +}; +type Effect = { statId: string; values: Record }; +type SecondaryRange = { statId: string; values: Record }; +type ExoticAttribute = { statId: string; level1Value: number; level20Value: number }; + +function parseJson(value: FormDataEntryValue | null, fallback: T): T { + try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; } +} + +async function snapshot(id: string) { + const database = await getDatabase(); + const effects = await (await database.prepare('SELECT * FROM weapon_mod_effects WHERE weapon_mod_id=? ORDER BY sort_order')).all(id); + return { + record: await auditRecord('weapon_mods', id), + compatibleCategories: await (await database.prepare('SELECT weapon_category FROM weapon_mod_compatible_categories WHERE weapon_mod_id=? ORDER BY weapon_category')).all(id), + qualityValues: await (await database.prepare('SELECT * FROM weapon_mod_quality_values WHERE weapon_mod_id=? ORDER BY quality')).all(id), + secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM weapon_mod_secondary_options WHERE weapon_mod_id=? ORDER BY stat_attribute_id')).all(id), + secondaryQualityValues: await (await database.prepare('SELECT * FROM weapon_mod_secondary_quality_values WHERE weapon_mod_id=? ORDER BY stat_attribute_id,quality')).all(id), + exoticAttributes: await (await database.prepare('SELECT * FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=? ORDER BY sort_order')).all(id), + effects: await Promise.all(effects.map(async (effect) => ({ + ...effect, + qualityValues: await (await database.prepare('SELECT quality,value FROM weapon_mod_effect_quality_values WHERE effect_id=? ORDER BY quality')).all(effect.id) + }))) + }; +} + +export const load: PageServerLoad = async ({ cookies, url }) => { + const user = await requireDataView(await getSessionUser(cookies)); + const database = await getDatabase(); + const requestedFilter = url.searchParams.get('filter') ?? ''; + const filter = ['active', 'archived', 'regular', 'exotic'].includes(requestedFilter) ? requestedFilter : ''; + const { search, pattern } = getRegistrySearch(url); + const conditions = [ + ...(filter === 'active' ? ['mods.active=1'] : filter === 'archived' ? ['mods.active=0'] + : filter === 'regular' ? ['mods.exotic_weapon_id IS NULL'] : filter === 'exotic' ? ['mods.exotic_weapon_id IS NOT NULL'] : []), + ...(search ? ["mods.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) + ]; + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const args = search ? [pattern] : []; + const [summary, filtered, pendingStats, pendingWeapons, pendingMods] = await Promise.all([ + (await database.prepare(`SELECT COUNT(*) AS total, + SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, + SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, + SUM(CASE WHEN exotic_weapon_id IS NULL THEN 1 ELSE 0 END) AS regular, + SUM(CASE WHEN exotic_weapon_id IS NOT NULL THEN 1 ELSE 0 END) AS exotic + FROM weapon_mods`)).get(), + (await database.prepare(`SELECT COUNT(*) AS total FROM weapon_mods mods ${where}`)).get(...args), + getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }]), + getPendingEntities(user.id, [{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'isExotic'] }]), + getPendingEntities(user.id, [{ module: 'weaponmods', action: 'save', kind: 'weaponmod', fields: [ + 'slotType', 'isExotic', 'exoticWeaponId', 'compatibleCategories', 'qualityValues', 'secondaryRanges', 'effects', 'exoticAttributes' + ] }]) + ]); + const pendingRows = filter === 'archived' ? [] : pendingMods.filter((item) => + (!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) && + (filter !== 'regular' || !item.exoticWeaponId) && (filter !== 'exotic' || Boolean(item.exoticWeaponId)) + ); + const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length); + const [mods, compatibility, qualityRows, secondaryOptions, secondaryValues, exoticAttributes, effects, effectValues, stats, weapons] = await Promise.all([ + (await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods + LEFT JOIN weapons ON weapons.id=mods.exotic_weapon_id ${where} + ORDER BY mods.active DESC,mods.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), + (await database.prepare('SELECT * FROM weapon_mod_compatible_categories')).all(), + (await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(), + (await database.prepare('SELECT * FROM weapon_mod_secondary_options')).all(), + (await database.prepare('SELECT * FROM weapon_mod_secondary_quality_values')).all(), + (await database.prepare('SELECT * FROM weapon_mod_exotic_attributes ORDER BY sort_order')).all(), + (await database.prepare('SELECT * FROM weapon_mod_effects ORDER BY sort_order')).all(), + (await database.prepare('SELECT * FROM weapon_mod_effect_quality_values')).all(), + (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), + (await database.prepare('SELECT id,name,category,is_exotic FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all() + ]); + const availableWeapons = [...pendingWeapons.map((item) => ({ ...item, is_exotic: Number(item.isExotic) })), ...weapons]; + const parsedPending = pendingRows.map((item) => ({ + ...item, + slot_type: item.slotType, + exotic_weapon_id: item.exoticWeaponId, + exotic_weapon_name: availableWeapons.find((weapon) => weapon.id === item.exoticWeaponId)?.name ?? '', + compatibleCategories: Array.isArray(item.compatibleCategories) ? item.compatibleCategories : [item.compatibleCategories].filter(Boolean), + qualityValues: parseJson(String(item.qualityValues), []), + secondaryRanges: parseJson(String(item.secondaryRanges), []), + exoticAttributes: parseJson(String(item.exoticAttributes), []), + effects: parseJson(String(item.effects), []) + })); + return { + mods: [...parsedPending, ...mods.map((mod) => ({ + ...mod, + compatibleCategories: compatibility.filter((link) => link.weapon_mod_id === mod.id).map((link) => String(link.weapon_category)), + qualityValues: qualityRows.filter((row) => row.weapon_mod_id === mod.id).map((row) => ({ + quality: row.quality, + primaryLevel1Min: row.primary_level_1_min, primaryLevel1Max: row.primary_level_1_max, + primaryLevel20Min: row.primary_level_20_min, primaryLevel20Max: row.primary_level_20_max + })), + secondaryRanges: secondaryOptions.filter((option) => option.weapon_mod_id === mod.id).map((option) => ({ + statId: String(option.stat_attribute_id), + values: Object.fromEntries(secondaryValues.filter((row) => row.weapon_mod_id === mod.id && row.stat_attribute_id === option.stat_attribute_id) + .map((row) => [String(row.quality), { min: Number(row.min_value), max: Number(row.max_value) }])) + })), + exoticAttributes: exoticAttributes.filter((row) => row.weapon_mod_id === mod.id).map((row) => ({ + statId: String(row.stat_attribute_id), level1Value: Number(row.level_1_value), level20Value: Number(row.level_20_value) + })), + effects: effects.filter((effect) => effect.weapon_mod_id === mod.id).map((effect) => ({ + statId: String(effect.stat_attribute_id), + values: Object.fromEntries(effectValues.filter((value) => value.effect_id === effect.id).map((value) => [String(value.quality), Number(value.value)])) + })) + }))], + stats: [...pendingStats, ...stats], + weapons: availableWeapons, + slots: slots.map((name) => ({ id: name, name })), + qualities, + weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })), + summary: { ...summary, total: Number(summary?.total ?? 0) + pendingMods.length, pending: pendingMods.length }, + filter, pagination, search + }; +}; + +export const actions: Actions = { + save: async ({ request, cookies }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); + const form = await request.formData(); + const id = String(form.get('id') ?? ''); + const name = String(form.get('name') ?? '').normalize('NFKC').trim(); + const slotType = String(form.get('slotType') ?? ''); + const isExotic = form.get('isExotic') === '1'; + const exoticWeaponId = String(form.get('exoticWeaponId') ?? ''); + const compatibleCategories = [...new Set(form.getAll('compatibleCategories').map(String).filter(Boolean))]; + const qualityValues = parseJson(form.get('qualityValues'), []); + const secondaryRanges = parseJson(form.get('secondaryRanges'), []); + const effects = parseJson(form.get('effects'), []); + const exoticAttributes = parseJson(form.get('exoticAttributes'), []); + if (name.length < 2 || name.length > 100) return fail(400, { weaponModError: 'Weapon Mod names must be 2–100 characters.' }); + if (!slots.includes(slotType as typeof slots[number])) return fail(400, { weaponModError: 'Choose a valid Weapon Mod slot.' }); + if (isExotic && !exoticWeaponId) return fail(400, { weaponModError: 'Select the exotic weapon this unique mod belongs to.' }); + if (!isExotic && exoticWeaponId) return fail(400, { weaponModError: 'Exotic weapon assignment is only available for Exotic Weapon Mods.' }); + if (!isExotic && (secondaryRanges.length < 2 || secondaryRanges.length > 50 || new Set(secondaryRanges.map((range) => range.statId)).size !== secondaryRanges.length || + secondaryRanges.some((range) => !range.statId || qualities.some((quality) => { + const values = range.values?.[quality]; + return !values || !Number.isFinite(Number(values.min)) || !Number.isFinite(Number(values.max)) || Number(values.max) < Number(values.min); + })))) return fail(400, { weaponModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' }); + if (isExotic && compatibleCategories.length) return fail(400, { weaponModError: 'Exotic Weapon Mods are locked to one exotic weapon and cannot use regular category compatibility.' }); + if (!isExotic && !compatibleCategories.length) return fail(400, { weaponModError: 'Assign at least one compatible weapon category.' }); + if (compatibleCategories.some((category) => !weaponCategories.includes(category as typeof weaponCategories[number]))) return fail(400, { weaponModError: 'One or more weapon categories are invalid.' }); + if (!isExotic && (qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length || + qualityValues.some((row) => !qualities.includes(row.quality as typeof qualities[number]) || + [row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max].some((value) => !Number.isFinite(Number(value))) || + Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) || Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) || + Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) || Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max)))) { + return fail(400, { weaponModError: 'Configure valid primary level ranges for all four qualities.' }); + } + if (!isExotic && (effects.length < 1 || effects.length > 2 || effects.some((effect) => !effect.statId || + qualities.some((quality) => !Number.isFinite(Number(effect.values?.[quality])))) || + new Set(effects.map((effect) => effect.statId)).size !== effects.length)) { + return fail(400, { weaponModError: 'Configure one or two unique Mod Effect attributes with a value for every quality.' }); + } + if (isExotic && (exoticAttributes.length < 1 || exoticAttributes.length > 12 || + new Set(exoticAttributes.map((attribute) => attribute.statId)).size !== exoticAttributes.length || + exoticAttributes.some((attribute) => !attribute.statId || !Number.isFinite(Number(attribute.level1Value)) || + !Number.isFinite(Number(attribute.level20Value)) || Number(attribute.level20Value) < Number(attribute.level1Value)))) { + return fail(400, { weaponModError: 'Configure one or more unique exotic attributes with fixed Level 1 and Level 20 values.' }); + } + const database = await getDatabase(); + if (await (await database.prepare('SELECT id FROM weapon_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { weaponModError: 'That Weapon Mod name is already registered.' }); + const statIds = [...new Set(isExotic + ? exoticAttributes.map((attribute) => attribute.statId) + : [...secondaryRanges.map((range) => range.statId), ...effects.map((effect) => effect.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, { weaponModError: 'One or more selected attributes are unavailable.' }); + if (isExotic) { + const exotic = await (await database.prepare('SELECT id FROM weapons WHERE id=? AND active=1 AND is_exotic=1')).get(exoticWeaponId); + if (!exotic) return fail(400, { weaponModError: 'Choose an active exotic weapon for this locked mod.' }); + } + const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); + const before = id ? await snapshot(id) : null; + if (id && !before?.record) return fail(404, { weaponModError: 'Weapon Mod not found.' }); + const now = new Date().toISOString(); + if (id) await (await database.prepare(`UPDATE weapon_mods SET name=?,slot_type=?, + exotic_weapon_id=?,active=1,updated_at=? WHERE id=?`)) + .run(name, slotType, exoticWeaponId || null, now, id); + else await (await database.prepare(`INSERT INTO weapon_mods + (id,name,slot_type,exotic_weapon_id,created_at,updated_at) + VALUES(?,?,?,?,?,?)`)).run(modId, name, slotType, exoticWeaponId || null, now, now); + await (await database.prepare('DELETE FROM weapon_mod_compatible_categories WHERE weapon_mod_id=?')).run(modId); + for (const category of compatibleCategories) await (await database.prepare('INSERT INTO weapon_mod_compatible_categories(weapon_mod_id,weapon_category) VALUES(?,?)')).run(modId, category); + await (await database.prepare('DELETE FROM weapon_mod_quality_values WHERE weapon_mod_id=?')).run(modId); + for (const row of isExotic ? [] : qualityValues) await (await database.prepare(`INSERT INTO weapon_mod_quality_values + (weapon_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max) + VALUES(?,?,?,?,?,?)`)).run(modId,row.quality,row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max); + await (await database.prepare('DELETE FROM weapon_mod_secondary_options WHERE weapon_mod_id=?')).run(modId); + for (const range of isExotic ? [] : secondaryRanges) { + await (await database.prepare('INSERT INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId,range.statId); + for (const quality of qualities) await (await database.prepare(`INSERT INTO weapon_mod_secondary_quality_values + (weapon_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)) + .run(modId,range.statId,quality,range.values[quality].min,range.values[quality].max); + } + await (await database.prepare('DELETE FROM weapon_mod_effects WHERE weapon_mod_id=?')).run(modId); + for (const [index, effect] of (isExotic ? [] : effects).entries()) { + const effectId = randomUUID(); + await (await database.prepare('INSERT INTO weapon_mod_effects(id,weapon_mod_id,stat_attribute_id,sort_order) VALUES(?,?,?,?)')).run(effectId,modId,effect.statId,index+1); + for (const quality of qualities) await (await database.prepare('INSERT INTO weapon_mod_effect_quality_values(effect_id,quality,value) VALUES(?,?,?)')).run(effectId,quality,effect.values[quality]); + } + await (await database.prepare('DELETE FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=?')).run(modId); + for (const [index, attribute] of (isExotic ? exoticAttributes : []).entries()) await (await database.prepare(`INSERT INTO weapon_mod_exotic_attributes + (id,weapon_mod_id,stat_attribute_id,level_1_value,level_20_value,sort_order) VALUES(?,?,?,?,?,?)`)) + .run(randomUUID(),modId,attribute.statId,attribute.level1Value,attribute.level20Value,index+1); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon_mod.updated' : 'weapon_mod.created', entityId: modId, before, after: await snapshot(modId) }); + return { weaponModSaved: true, weaponModMode: id ? 'edit' : 'add', resultToken: randomUUID() }; + }, + archive: async ({ request, cookies }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); + const id = String((await request.formData()).get('id') ?? ''); + const before = await snapshot(id); + if (!before.record) return fail(404, { weaponModError: 'Weapon Mod not found.' }); + await (await (await getDatabase()).prepare('UPDATE weapon_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'weapon_mod.archived', entityId: id, before, after: await snapshot(id) }); + return { weaponModArchived: true }; + }, + unarchive: async ({ request, cookies }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); + const id = String((await request.formData()).get('id') ?? ''); + const before = await snapshot(id); + if (!before.record) return fail(404, { weaponModError: 'Weapon Mod not found.' }); + await (await (await getDatabase()).prepare('UPDATE weapon_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'weapon_mod.unarchived', entityId: id, before, after: await snapshot(id) }); + return { weaponModUnarchived: true }; + } +}; diff --git a/src/routes/isac/weaponmods/+page.svelte b/src/routes/isac/weaponmods/+page.svelte new file mode 100644 index 0000000..2570dd4 --- /dev/null +++ b/src/routes/isac/weaponmods/+page.svelte @@ -0,0 +1,157 @@ + + +Weapon Mods — ISAC +

ARMAMENT MODIFICATION REGISTRY

WEAPON MODS

Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.

+ + + +{#if editing} +
+

{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}

{editing.id ? editing.name : 'WEAPON MOD DEFINITION'}

+ + + + + + + +
+ + + {#if isExotic}
EXOTIC QUALITYUNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS
{:else}
PRIMARY ATTRIBUTE OPTIONS
{#each ['firepower','toughness','engineering'] as primary}{primary.toUpperCase()}{/each}
Selected by the agent while configuring a build.
{/if} + +
+ +
+
+
{isExotic ? 'EXOTIC WEAPON LOCK' : 'WEAPON CATEGORY COMPATIBILITY'}{isExotic ? 'PRE-EQUIPPED · CANNOT BE REMOVED' : 'SLOT TYPE AND CATEGORY MUST BOTH MATCH'}
{isExotic ? (editing.exotic_weapon_id ? 1 : 0) : editing.compatibleCategories.length}
+ {#if isExotic} + {:else}{/if} +
+
+ + {#if !isExotic} +
+
QUALITY & LEVEL SCALINGPRIMARY ROLLS SCALE LINEARLY FROM LEVEL 1 TO 20 · SECONDARY RANGES VARY BY QUALITY
4 QUALITY PROFILES
+
+
QUALITYPRIMARY · LEVEL 1PRIMARY · LEVEL 20
+ {#each editing.qualityValues as row} +
+ {row.quality.toUpperCase()} +
–
+
–
+
+ {/each} +
+
+
({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/>
+ {#if previewRow}
SELECTED PRIMARY · LEVEL {levelPreview}{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}
SECONDARY ATTRIBUTE POOL{editing.secondaryRanges.length} OPTIONS
{/if} +
+
+ +
+
SECONDARY ATTRIBUTE POOLTHE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY
+
+ {#each editing.secondaryRanges as range,index} +
{String(index+1).padStart(2,'0')}itemIndex!==index).map((item: any)=>item.statId)} bind:value={range.statId}/>{#each data.qualities as quality}{/each}
+ {/each} +
+
+ +
+
MOD EFFECTNO TRIGGER · FIXED ATTRIBUTE VALUES VARY BY QUALITY, NOT LEVEL
+
{#each editing.effects as effect, index}
0{index+1} itemIndex !== index).map((item: any) => item.statId)} bind:value={effect.statId}/>{#each data.qualities as quality}{/each}
{/each}
+
+ {:else} +
+
FIXED EXOTIC ATTRIBUTESNO RANDOM ROLLS OR QUALITY RANGES · VALUES SCALE LINEARLY FROM LEVEL 1 TO 20
+
{#each editing.exoticAttributes as attribute,index}
{String(index+1).padStart(2,'0')}itemIndex!==index).map((item:any)=>item.statId)} bind:value={attribute.statId}/>
{/each}
+

EXOTIC LOCK This mod is permanently equipped on {weapon(editing.exotic_weapon_id)?.name ?? 'the selected exotic weapon'}. Its fixed attributes increase with mod level and cannot be rerolled.

+
+ {/if} + {#if form?.weaponModError}

{form.weaponModError}

{/if} +
{#if editing.id}{/if}
+
+
+{/if} + + +
+
WEAPON MODSLOTPRIMARYSECONDARIESCOMPATIBILITYMOD EFFECTSTATE
+ {#each data.mods as item} +
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}
{item.slot_type}{#if item.exotic_weapon_id}FIXED{:else}ANY CORE{/if}{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`} · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}{#if item.pending}{:else}{/if}
+ {:else}
NO WEAPON MODS REGISTEREDAdd the first slot-compatible Weapon Mod definition.
{/each} +
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts index f7beb70..93939c2 100644 --- a/src/routes/isac/weapons/+page.server.ts +++ b/src/routes/isac/weapons/+page.server.ts @@ -12,6 +12,7 @@ import { getPendingEntities } from '$lib/server/data-moderation'; const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const damageTypes = ['Blast', 'Pierce', 'Shredding']; const rangeNames = ['damage'] as const; +const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const; type ExoticEffect = { statId: string; valueI: number; valueII: number }; const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`; @@ -44,9 +45,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])]; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([ + const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), + (await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(), (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(), @@ -74,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, - { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids'] } + { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots'] } ]) ]); const pendingTalents = pending.filter((item) => item.kind === 'talent'); @@ -92,6 +94,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { reload_speed: weapon.reloadSpeed, baseBonuses: [], tiers: [], + modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(), talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean), talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean), augmentEffects: [], @@ -110,6 +113,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { return { ...weapon, tiers: tierRanges.filter((range) => range.weapon_id === weapon.id), + modSlots: [0,1,2].map((tier) => ({ tier, slots: modSlots.filter((slot) => slot.weapon_id === weapon.id && Number(slot.tier) === tier).map((slot) => String(slot.slot_type)) })), talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), @@ -142,6 +146,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { filter, search, requestedWeaponId + ,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name })) }; }; @@ -183,6 +188,12 @@ export const actions: Actions = { }; const talent1Ids = [...new Set(form.getAll('talent1Ids').map(String))]; const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))]; + const modSlots = (() => { + try { + const parsed = JSON.parse(String(form.get('modSlots') ?? '[]')); + return Array.isArray(parsed) ? parsed.map((row) => ({ tier: Number(row.tier), slots: [...new Set(Array.isArray(row.slots) ? row.slots.map(String) : [])] })) : []; + } catch { return []; } + })(); const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : ''; const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : ''; const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : ''; @@ -213,6 +224,13 @@ export const actions: Actions = { if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) { return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' }); } + for (let tier = nativeTier; tier <= 2; tier++) { + const configured = modSlots.find((row) => row.tier === tier); + const required = tier === 0 ? 4 : 5; + if (!configured || configured.slots.length !== required || configured.slots.some((slot) => !modSlotTypes.includes(slot as typeof modSlotTypes[number]))) { + return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` }); + } + } if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) { return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' }); } @@ -310,6 +328,7 @@ export const actions: Actions = { talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id), augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id), baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id), + modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id), exoticTalent: existingExoticTalentId ? { record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId), icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId), @@ -376,6 +395,10 @@ export const actions: Actions = { await (await database.prepare('DELETE FROM weapon_talent_options WHERE weapon_id=?')).run(weaponId); for (const talentId of talent1Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 1, talentId); for (const talentId of talent2Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 2, talentId); + await (await database.prepare('DELETE FROM weapon_mod_slots WHERE weapon_id=?')).run(weaponId); + for (const row of modSlots.filter((row) => row.tier >= nativeTier)) { + for (const slot of row.slots) await (await database.prepare('INSERT INTO weapon_mod_slots(weapon_id,tier,slot_type) VALUES(?,?,?)')).run(weaponId,row.tier,slot); + } await (await database.prepare('DELETE FROM weapon_augmentation_effects WHERE weapon_id=?')).run(weaponId); for (const effect of augmentEffects) { await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) @@ -386,7 +409,7 @@ export const actions: Actions = { await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); } - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } }); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } }); return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte index af4625b..848a115 100644 --- a/src/routes/isac/weapons/+page.svelte +++ b/src/routes/isac/weapons/+page.svelte @@ -16,7 +16,7 @@ const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true })); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 }); const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); - const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); + const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); let editing = $state(null); let handledResultToken = $state(''); let iconResetToken = $state(0); @@ -26,9 +26,11 @@ const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier); return { ...blankTier(tier), ...saved }; }); - editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() }; + const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] })); + editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() }; }; const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier); + const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier); const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute'; const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent'; const setCategory = (category: string) => { @@ -92,6 +94,7 @@

{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}

{editing.id ? editing.name : 'WEAPON DEFINITION'}

+
setCategory(values[0])} required/> @@ -132,9 +135,11 @@
TIER PROGRESSIONDAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION
3 TIERS REQUIRED
{#each [0, 1, 2] as tier} + {@const slotConfig = modSlotsAt(tier)}
TIER {tier} {#if tier < Number(editing.native_tier)}

BELOW NATIVE TIER · NOT AVAILABLE

{/if}
DMG{editing.damage_type.toUpperCase()} DAMAGE
+
WEAPON MOD SLOTS{tier === 0 ? 'EXACTLY FOUR SLOTS' : 'EXACTLY FIVE SLOTS'}
{slotConfig.slots.length} / {tier === 0 ? 4 : 5}
{/each}
@@ -161,6 +166,6 @@
WEAPONCLASSFIXED ATTRIBUTESTIER RANGETALENT POOLSAUGMENTSSTATE
{#each data.weapons as weapon} -
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if weapon.pending}{:else}{/if}
+
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if weapon.pending}{:else}{/if}
{:else}
NO WEAPONS REGISTEREDAdd active weapon talents and attributes, then create the first armament definition.
{/each}