From 9f712e77903eae554361f5fe52902a32f22f46cd Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 05:20:25 -0400 Subject: [PATCH] feat(isac): add OS protocol management --- AGENTS.md | 5 +- database/schema.sql | 56 ++++++ docs/DESIGN_SYSTEM.md | 61 +++++++ docs/architecture.md | 19 ++ src/app.css | 12 +- src/lib/components/GameIcon.svelte | 18 ++ src/lib/game-icons.ts | 17 ++ src/lib/server/audit.ts | 2 +- src/routes/admin/+layout.svelte | 5 +- src/routes/admin/metrics/+page.server.ts | 2 +- src/routes/admin/protocols/+page.server.ts | 192 +++++++++++++++++++++ src/routes/admin/protocols/+page.svelte | 165 ++++++++++++++++++ src/routes/admin/weapons/+page.svelte | 9 +- src/routes/builder/+page.svelte | 3 +- static/blast.png | Bin 0 -> 24634 bytes static/bulwark.png | Bin 0 -> 39336 bytes static/combat_medic.png | Bin 0 -> 60443 bytes static/demolitionist.png | Bin 0 -> 59365 bytes static/engineering.png | Bin 0 -> 5469 bytes static/firepower.png | Bin 0 -> 26074 bytes static/legendary.png | Bin 0 -> 31203 bytes static/pierce.png | Bin 0 -> 24776 bytes static/shd_ticket.png | Bin 0 -> 29166 bytes static/shredding.png | Bin 0 -> 25734 bytes static/tech_operator.png | Bin 0 -> 43702 bytes static/toughness.png | Bin 0 -> 4004 bytes static/vanguard.png | Bin 0 -> 42573 bytes 27 files changed, 555 insertions(+), 11 deletions(-) create mode 100644 src/lib/components/GameIcon.svelte create mode 100644 src/lib/game-icons.ts create mode 100644 src/routes/admin/protocols/+page.server.ts create mode 100644 src/routes/admin/protocols/+page.svelte create mode 100644 static/blast.png create mode 100644 static/bulwark.png create mode 100644 static/combat_medic.png create mode 100644 static/demolitionist.png create mode 100644 static/engineering.png create mode 100644 static/firepower.png create mode 100644 static/legendary.png create mode 100644 static/pierce.png create mode 100644 static/shd_ticket.png create mode 100644 static/shredding.png create mode 100644 static/tech_operator.png create mode 100644 static/toughness.png create mode 100644 static/vanguard.png diff --git a/AGENTS.md b/AGENTS.md index 95934a5..2b3f2cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,10 @@ with `npm run db:backup`. Database work must follow these rules: 1. Confirm which environment and exact database path are in scope. -2. Stop processes that hold the embedded database lock before backup or restore. +2. Before every database backup or restore, inspect running processes and stop + only this project's development-server process tree. The embedded Turso + database holds an exclusive file lock while the dev server is running, so do + not attempt the backup first and wait for a locking failure. 3. Create and verify a JSON backup before destructive work. 4. Never overwrite or delete the only available backup. 5. Keep backups out of Git; they can contain user records and password hashes. diff --git a/database/schema.sql b/database/schema.sql index a0c9443..42c77c9 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -348,3 +348,59 @@ CREATE TABLE IF NOT EXISTS weapon_base_attribute_bonuses ( UNIQUE(weapon_id, stat_attribute_id), UNIQUE(weapon_id, sort_order) ); +CREATE TABLE IF NOT EXISTS os_protocols ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Standard', 'Specialized', 'Superior', 'High-end')), + primary_attribute_type TEXT NOT NULL CHECK(primary_attribute_type IN ('Firepower', 'Toughness', 'Engineering')), + 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 os_protocols_name_nocase_idx ON os_protocols(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS os_protocol_primary_effects ( + id TEXT PRIMARY KEY, + protocol_id TEXT NOT NULL UNIQUE REFERENCES os_protocols(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + base_value REAL NOT NULL CHECK(base_value >= 0) +); +CREATE TABLE IF NOT EXISTS os_protocol_secondary_profiles ( + id TEXT PRIMARY KEY, + quality TEXT NOT NULL CHECK(quality IN ('Standard', 'Specialized', 'Superior', 'High-end')), + primary_attribute_type TEXT NOT NULL CHECK(primary_attribute_type IN ('Firepower', 'Toughness', 'Engineering')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(quality, primary_attribute_type) +); +CREATE TABLE IF NOT EXISTS os_protocol_secondary_effects ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES os_protocol_secondary_profiles(id) ON DELETE CASCADE, + effect_role TEXT NOT NULL CHECK(effect_role IN ('Core', 'Damage', 'Health')), + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL CHECK(value >= 0), + UNIQUE(profile_id, effect_role), + UNIQUE(profile_id, stat_attribute_id) +); +CREATE TABLE IF NOT EXISTS os_protocol_talents ( + id TEXT PRIMARY KEY, + protocol_id TEXT NOT NULL UNIQUE REFERENCES os_protocols(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description_template TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS os_protocol_talents_name_nocase_idx ON os_protocol_talents(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS os_protocol_talent_triggers ( + talent_id TEXT NOT NULL REFERENCES os_protocol_talents(id) ON DELETE CASCADE, + trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), + PRIMARY KEY(talent_id, trigger_id) +); +CREATE TABLE IF NOT EXISTS os_protocol_talent_effects ( + id TEXT PRIMARY KEY, + talent_id TEXT NOT NULL REFERENCES os_protocol_talents(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + base_value REAL NOT NULL, + sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 12), + UNIQUE(talent_id, stat_attribute_id), + UNIQUE(talent_id, sort_order) +); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 2fdc61f..61e3f6c 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -366,6 +366,67 @@ control at the data boundary instead of presenting cross-slot choices. Weapon talents are classified once as Talent 1 / Top or Talent 2 / Bottom in the talent registry; weapon editors only curate subsets from the matching slot. +### Game-semantic iconography + +Canonical game icons are registered in `src/lib/game-icons.ts` and rendered +through `GameIcon.svelte`; do not repeat asset paths in individual features. +Use damage icons beside Blast, Pierce, and Shredding classifications, and core +attribute icons beside Firepower, Toughness, and Engineering values. The same +registry reserves the Bulwark, Combat Medic, Demolitionist, Tech Operator, +Vanguard, Legendary, and SHD-ticket assets for controls or data that explicitly +represent those concepts. Do not use semantic icons as unrelated decoration. + +Interactive icon choices retain a visible text label, square engineered cells, +orange selected state, and hover, pressed, and focus-visible feedback. Icons +that repeat an adjacent visible label are decorative with an empty alternative; +standalone icons expose the registry's human-readable label. Preserve each +asset's aspect ratio with `object-fit: contain` and scale through the shared +`.game-icon` class rather than modifying source artwork. + +### Item quality + +Item quality is a global semantic scale: Standard uses +`--quality-standard` green, Specialized uses `--quality-specialized` blue, +Superior uses `--quality-superior` purple, and High-end uses +`--quality-high-end` yellow. Apply the matching `.quality-*` utility wherever +an item’s persisted quality is shown. Registry labels use `.quality-chip` so +quality remains explicit in text and never depends on color alone. These colors +identify item quality; orange remains reserved for actions and current +selection. + +### OS Protocol editor + +OS Protocol definitions separate shared secondary profiles from item-owned +progression. The twelve quality + attribute-type combinations are configured +once in a compact matrix and inherited by every matching protocol. Each profile +contains three fixed stat identities and editable values. Individual protocol +editors show the inherited profile read-only and store one unique primary stat +with a level-1 base value. That primary value scales linearly to exactly twice +its base at level 50. + +Protocol identity forms stack the four-choice quality radio band above the +three-choice secondary-profile type band, with the protocol name occupying the +larger adjacent column. Quality choices use their global semantic color plus an +explicit text label and selected underline; profile types retain their +icon-backed labels. Both controls collapse below the name on narrow screens. + +The shared secondary-profile matrix is collapsed by default because it is +configuration infrastructure rather than a frequent protocol-editing task. Its +disclosure header always exposes the configured total, uses a textual title, +and rotates the plus indicator when expanded. The full matrix remains keyboard +operable and follows the shared hover, focus-visible, and pressed states. + +The dedicated unique-talent panel uses five interactive diamonds: no filled +diamonds is the base definition, and each selected upgrade adds one fifth of +the base until five upgrades show exactly twice the base effect. + +Diamonds are both a progression preview and an explicit numeric state; expose +their upgrade number and `aria-pressed` state rather than relying on fill color. +Protocol talents own their effects but reuse the global stat and trigger +registries. Shared secondary values do not scale with the individual protocol’s +level. Store only the unique primary and talent base values, and derive their +progression consistently in calculation and display code. + ### ISAC editor lifecycle Successful saves have a consistent outcome throughout data management: diff --git a/docs/architecture.md b/docs/architecture.md index 448c65b..95276cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,3 +144,22 @@ talent ID not already owned by the edited weapon and rejects any talent shared by another weapon. Converting a weapon back to standard clears the relationship and archives its now-unowned exotic talent. Exotic talent creation and editing are rejected by the shared Talent Registry so ownership cannot be bypassed. + +# OS Protocol progression + +OS Protocols use dedicated normalized tables because their talent progression +has six calculated states (base plus five upgrades) rather than the shared +two-tier talent model. Each protocol owns one `os_protocol_primary_effects` +record and one `os_protocol_talents` record. Talent effects and reusable +triggers live in their respective join tables. + +The three secondary attributes are normalized separately. One +`os_protocol_secondary_profiles` record exists for each configured quality + +attribute-type combination, with its three fixed stat/value pairs in +`os_protocol_secondary_effects`. Every matching protocol inherits that profile; +secondary values are not copied onto individual protocol records. + +The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50, while +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. diff --git a/src/app.css b/src/app.css index 2913ab3..4a5c50b 100644 --- a/src/app.css +++ b/src/app.css @@ -13,6 +13,10 @@ --shd-success:#75c08c; --shd-danger:#df8069; --shd-info:#70b9d6; + --quality-standard:#69b66f; + --quality-specialized:#4f9ed8; + --quality-superior:#9b68d4; + --quality-high-end:#e4bd3f; --shd-radius:0px; --shd-gap-xs:5px; --shd-gap-sm:8px; @@ -35,7 +39,7 @@ .loadout-preview{min-height:520px;position:relative;border:1px solid #343738;background:radial-gradient(circle at 50% 46%,#30312c 0,#151819 33%,#0d0f10 68%);clip-path:polygon(5% 0,100% 0,100% 92%,95% 100%,0 100%,0 8%)}.scan-lines{position:absolute;inset:0;opacity:.15;background:repeating-linear-gradient(0deg,transparent,transparent 5px,#fff 6px)}.preview-top{padding:22px;display:flex;justify-content:space-between;font-size:9px;letter-spacing:2px;color:#8d9294}.preview-top b{color:#f6a821}.agent{height:385px;position:relative;display:grid;place-items:center}.agent-shape{width:145px;height:285px;background:linear-gradient(90deg,#1d2121,#737269 46%,#292c2b 53%,#101212);clip-path:polygon(33% 0,67% 0,78% 14%,72% 32%,89% 58%,78% 100%,22% 100%,12% 58%,28% 32%,22% 14%);filter:drop-shadow(0 10px 18px #000);display:grid;place-items:center}.agent-shape span{color:#f6a821;font-weight:900;opacity:.5;transform:rotate(-90deg);letter-spacing:6px}.orbit{position:absolute;border:1px solid #555;border-radius:50%}.orbit-one{width:330px;height:330px}.orbit-two{width:250px;height:250px;border-color:#f6a82144} .gear-node{position:absolute;width:74px;background:#141718;border:1px solid #555;border-left:2px solid #f6a821;padding:8px;display:flex;flex-direction:column}.gear-node small{color:#777;font-size:7px;letter-spacing:1px}.gear-node b{font-size:12px;margin-top:3px}.n-0{left:8%;top:12%}.n-1{right:5%;top:24%}.n-2{left:4%;bottom:22%}.n-3{right:8%;bottom:12%}.preview-name{position:absolute;bottom:22px;left:25px;display:flex;flex-direction:column}.preview-name small{color:#f6a821;font-size:8px}.preview-name strong{font-size:18px;margin:4px 0}.preview-name span{font-size:8px;color:#777} .featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:10px;letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:9px;letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:9px;color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:8px;color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:8px;border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:8px;color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821} - .app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:9px;color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{color:#999;font-size:11px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:11px;margin:14px 0}.mini span{color:#888}.notice{font-size:11px;line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px} + .app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:9px;color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{display:flex;align-items:center;gap:7px;color:#999;font-size:11px}.stat span .game-icon{width:21px;height:21px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:11px;margin:14px 0}.mini span{color:#888}.notice{font-size:11px;line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px} .admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:11px;color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:11px}.data-row.labels{font-size:8px;color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:9px}.status{color:#73bd8a;font-size:8px} .auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:11px;font-weight:bold;letter-spacing:1px}.divider{display:flex;align-items:center;gap:12px;margin:30px 0 20px;text-align:center}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#333}.divider span{flex:0 0 auto;font-size:8px;color:#666}.auth-page label{display:block;font-size:9px;letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:11px;font-weight:bold} @media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}} @@ -96,7 +100,7 @@ .site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.footer-brand .brand-mark{width:30px;height:30px;font-size:7px}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px Georgia,serif}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.fan-disclaimer{margin:0;color:#555d5f;font-size:6px;line-height:1.6;letter-spacing:.55px}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 Georgia,serif}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:9px;font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 Georgia,serif}.legal-document a{color:var(--shd-orange)}@media(max-width:800px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} .dashboard-row>.role-summary{display:flex;flex-wrap:wrap;align-items:center;gap:3px}.role-summary b{flex:0 0 auto;width:max-content;padding:3px 5px;border:1px solid #6e5226;color:var(--shd-orange);font-size:6px;letter-spacing:.6px} -.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px} +.game-icon{display:block;width:24px;height:24px;object-fit:contain}.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:8px;font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:5px;letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:65px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:9px}.weapon-range-row>strong .game-icon{width:20px;height:20px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.weapon-dashboard-table{margin-top:10px} .assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px} .registry-pagination{width:100%;height:auto;min-height:48px;margin:8px 0 0;padding:7px 9px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;border:1px solid #303536;border-bottom:0;background:#0f1213}.pagination-range{display:flex;align-items:center;gap:6px;color:#697174;font-size:7px;letter-spacing:.8px}.pagination-range b{color:#d7d9d5;font-size:9px}.pagination-pages{display:flex;align-items:center;justify-content:center;gap:4px}.pagination-pages a,.pagination-sizes a{min-width:30px;height:30px;display:grid;place-items:center;border:1px solid #343a3b;background:#111415;color:#8c9395;font-size:7px;font-weight:900;letter-spacing:.5px;cursor:pointer}.pagination-pages a:first-child,.pagination-pages a:last-child{min-width:58px;padding:0 8px}.pagination-pages a:hover,.pagination-pages a:focus-visible,.pagination-sizes a:hover,.pagination-sizes a:focus-visible{border-color:#8a672b;color:#f6a821}.pagination-pages a.active,.pagination-sizes a.active{border-color:#f6a821;background:#211a0e;color:#f6a821}.pagination-pages a:active,.pagination-sizes a:active{transform:translateY(1px);filter:brightness(.88)}.pagination-pages a.disabled{opacity:.35;pointer-events:none}.pagination-gap{color:#555d5f;font-size:8px}.pagination-sizes{display:flex;align-items:center;gap:4px}.pagination-sizes>span{margin-right:3px;color:#697174;font-size:7px;letter-spacing:1px}.pagination-sizes a{min-width:34px}.registry-pagination a:focus-visible{outline:2px solid #f6a821;outline-offset:2px} .registry-pagination+.dashboard-table{margin-top:0} @@ -108,5 +112,9 @@ .audit-toolbar>div:last-child{display:block}.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-filter button{display:inline-flex;align-items:center;justify-content:center;padding:0 14px;line-height:1}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:7px;letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:10px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}} .audit-revert-bar{min-height:56px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:7px;letter-spacing:.8px}.audit-revert-button{min-width:174px;height:40px;padding:0 14px}.audit-revert-button>b{font-size:15px;line-height:1}.audit-revert-button:hover,.audit-revert-button:focus-visible{box-shadow:inset 3px 0 var(--shd-danger)}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 Georgia,serif}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:6px;letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:8px;white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-button{width:100%}.audit-revert-summary{grid-template-columns:1fr}} .metrics-auto{height:40px;display:flex!important;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--shd-border);background:#0d1011;color:#747c7e;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer}.metrics-auto input{width:auto;margin:0;accent-color:var(--shd-orange)}.metrics-auto:has(input:checked){border-color:#6e5226;background:#211a0e;color:var(--shd-orange)}.metrics-refresh{min-width:154px}.metrics-kpis strong{font-size:18px}.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.metrics-panel{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.metrics-panel>header{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:11px 13px;border-bottom:1px solid var(--shd-border);background:#0d1011}.metrics-panel h3{margin:4px 0 0;font-size:14px;letter-spacing:.5px}.metrics-panel>header>b{color:var(--shd-orange);font-size:7px;letter-spacing:1px}.metric-lines{display:grid}.metric-lines>div{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 12px;border-top:1px solid #252a2b}.metric-lines>div:first-child{border-top:0}.metric-lines span{color:#697174;font-size:7px;letter-spacing:.9px}.metric-lines b{max-width:65%;overflow:hidden;color:#d5d8d5;font-size:9px;text-align:right;text-overflow:ellipsis;white-space:nowrap}.metrics-foot{margin:10px 0 0;padding:9px;border:1px solid #292e2f;color:#596164;font-size:6px;letter-spacing:.8px}@media(max-width:850px){.metrics-grid{grid-template-columns:1fr}}@media(max-width:560px){.metrics-auto,.metrics-refresh{width:100%}.metric-lines b{max-width:58%}} +.quality-chip{display:inline-flex;width:max-content;align-items:center;padding:4px 6px;border:1px solid currentColor;background:#1115;color:var(--quality-standard);font-size:6px;letter-spacing:.7px;text-transform:uppercase}.quality-standard{color:var(--quality-standard)!important}.quality-specialized{color:var(--quality-specialized)!important}.quality-superior{color:var(--quality-superior)!important}.quality-highend{color:var(--quality-high-end)!important} +.protocol-editor{border-top-color:var(--quality-specialized)}.protocol-identity-grid{display:grid;grid-template-columns:1.4fr .7fr 1.5fr;gap:8px}.protocol-type-picker{min-width:0;margin:0;padding:0;border:0}.protocol-type-picker legend{color:var(--shd-text-muted);font-size:8px;font-weight:bold;letter-spacing:1px}.protocol-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.protocol-type-picker label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.protocol-type-picker label:first-child{border-left:0}.protocol-type-picker input{position:absolute;opacity:0;pointer-events:none}.protocol-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:4px;color:#737a7c}.protocol-type-picker .game-icon{width:18px;height:18px;opacity:.55}.protocol-type-picker b{font-size:6px}.protocol-type-picker label:hover span{background:#171b1c;color:var(--shd-text)}.protocol-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.protocol-type-picker input:checked+span{background:#211a0e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.protocol-type-picker input:checked+span .game-icon{opacity:1}.protocol-type-picker input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.protocol-level-preview{display:grid;grid-template-columns:minmax(220px,1fr);gap:3px;padding:8px 10px;border:1px solid var(--shd-border);background:#0d1011}.protocol-level-preview input{width:100%;margin:0;accent-color:var(--shd-orange)}.protocol-level-preview>div{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:7px;color:#626a6c;font-size:6px}.protocol-level-preview i{height:2px;background:var(--shd-orange)}.protocol-attribute-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:7px}.protocol-attribute-card{display:grid;gap:6px;padding:8px;border:1px solid var(--shd-border);background:#101314}.protocol-attribute-card header{min-height:38px;display:flex;align-items:center;gap:6px;border-bottom:1px solid #282e2f}.protocol-attribute-card header .game-icon{width:25px;height:25px}.protocol-attribute-card header div{display:grid;gap:2px}.protocol-attribute-card header span{color:#626a6c;font-size:6px;letter-spacing:.8px}.protocol-attribute-card header b{font-size:8px}.protocol-attribute-card header>strong{margin-left:auto;color:var(--shd-orange);font-size:12px}.protocol-attribute-card .field{margin:0}.protocol-attribute-card>small{color:#626a6c;font-size:6px;text-align:right}.protocol-talent{margin-top:12px;padding:8px;border:1px solid #71552a;background:#0d1011}.protocol-talent-identity{display:grid;grid-template-columns:.7fr 1.3fr;gap:8px}.protocol-talent-identity textarea{min-height:65px}.protocol-upgrade-preview{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;margin:8px 0}.diamond-control{display:flex;align-items:stretch;border:1px solid var(--shd-border)}.diamond-control button{min-width:42px;display:grid;place-items:center;padding:5px;border:0;border-left:1px solid var(--shd-border);background:#111415;color:#596164;cursor:pointer}.diamond-control button:first-child{border-left:0;font-size:6px;font-weight:900}.diamond-control button span{font-size:15px}.diamond-control button small{font-size:5px}.diamond-control button:hover,.diamond-control button:focus-visible{background:#171b1c;color:var(--shd-text)}.diamond-control button:active{transform:translateY(1px);filter:brightness(.88)}.diamond-control button.active{background:#211a0e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.protocol-upgrade-preview>div:last-child{padding:8px 10px;border:1px solid var(--shd-border);background:#101314}.protocol-upgrade-preview>div:last-child span{color:var(--shd-orange);font-size:6px;letter-spacing:.8px}.protocol-upgrade-preview p{margin:6px 0 0;color:#d7d9d5;font-size:10px;line-height:1.45}.protocol-talent-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.protocol-talent-effects{display:grid;border:1px solid var(--shd-border)}.protocol-talent-effects>div{display:grid;grid-template-columns:28px minmax(140px,1fr) 90px 80px 28px;gap:5px;align-items:center;padding:5px;border-top:1px solid #282e2f}.protocol-talent-effects>div:first-child{border-top:0}.protocol-talent-effects strong{display:grid;place-items:center;height:100%;background:#171b1c;color:var(--shd-orange);font-size:7px}.protocol-talent-effects select,.protocol-talent-effects input{min-width:0;margin:0;padding:7px;font-size:8px}.protocol-talent-effects span{color:#626a6c;font-size:6px;text-align:center}.protocol-talent-effects button{height:28px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.protocol-talent-effects button:disabled{opacity:.3;cursor:not-allowed}.protocol-row{min-width:1050px;grid-template-columns:1.2fr .65fr .8fr .7fr 1.1fr .55fr .3fr}.protocol-type-label{display:flex!important;align-items:center;gap:5px}.protocol-type-label .game-icon{width:20px;height:20px}.mini-diamonds{color:var(--shd-orange);letter-spacing:1px}@media(max-width:1000px){.protocol-identity-grid{grid-template-columns:1fr 1fr}.protocol-type-picker{grid-column:1/3}.protocol-attribute-grid,.protocol-talent-grid{grid-template-columns:1fr}.protocol-upgrade-preview{grid-template-columns:1fr}}@media(max-width:650px){.protocol-identity-grid,.protocol-talent-identity{grid-template-columns:1fr}.protocol-type-picker{grid-column:auto}.protocol-type-picker b{font-size:5px}.diamond-control{overflow-x:auto}.protocol-talent-effects>div{grid-template-columns:26px 1fr}.protocol-talent-effects input,.protocol-talent-effects span,.protocol-talent-effects button{grid-column:2}} +.protocol-profile-registry{margin-bottom:10px;border:1px solid var(--shd-border);background:#0d1011}.protocol-profile-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.protocol-profile-registry>summary::-webkit-details-marker{display:none}.protocol-profile-registry>summary:hover,.protocol-profile-registry>summary:focus-visible{background:#171b1c}.protocol-profile-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.protocol-profile-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.protocol-profile-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.protocol-profile-registry[open]>summary i{transform:rotate(45deg)}.protocol-profile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;padding:8px}.protocol-profile-grid>button{min-width:0;padding:7px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.protocol-profile-grid>button:hover,.protocol-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.protocol-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.protocol-profile-grid header{display:flex;align-items:center;gap:6px;padding-bottom:6px;border-bottom:1px solid #292e2f}.protocol-profile-grid header .game-icon{width:23px;height:23px}.protocol-profile-grid header div{display:grid;gap:2px}.protocol-profile-grid header b{font-size:6px;text-transform:uppercase}.protocol-profile-grid header strong{color:#d7d9d5;font-size:8px}.protocol-profile-grid header>span{margin-left:auto;color:#6b7274;font-size:5px;font-weight:900;letter-spacing:.5px}.protocol-profile-grid>button.configured header>span{color:var(--shd-success)}.protocol-profile-grid>button>div{display:grid;gap:4px;margin-top:6px}.protocol-profile-grid>button>div span{display:flex;justify-content:space-between;gap:6px}.protocol-profile-grid>button>div small{overflow:hidden;color:#626a6c;font-size:5px;text-overflow:ellipsis;white-space:nowrap}.protocol-profile-grid>button>div b{color:#b8bdbe;font-size:6px}.protocol-profile-editor{border-top-color:var(--shd-success)}.shared-secondary-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.selected-secondary-profile{margin:8px 0;border:1px solid #36563f;background:#101713}.selected-secondary-profile.unconfigured{border-color:#65403a;background:#1c1111}.selected-secondary-profile header{display:flex;justify-content:space-between;padding:7px 9px;border-bottom:1px solid currentColor;color:var(--shd-success);font-size:6px;letter-spacing:.8px}.selected-secondary-profile.unconfigured header{color:var(--shd-danger)}.selected-secondary-profile>div{display:grid;grid-template-columns:repeat(3,1fr)}.selected-secondary-profile>div span{display:flex;justify-content:space-between;padding:8px 9px;border-left:1px solid #2b3d30}.selected-secondary-profile>div span:first-child{border-left:0}.selected-secondary-profile small{color:#6f7b72;font-size:6px}.selected-secondary-profile b{font-size:8px}.selected-secondary-profile p{margin:0;padding:10px;color:#a87870;font-size:8px}.protocol-primary-editor{display:grid;grid-template-columns:1fr 180px 180px;gap:7px;margin-top:7px;padding:8px;border:1px solid var(--shd-border);background:#101314}.protocol-primary-editor .field{margin:0}.protocol-primary-editor>div{display:grid;align-content:center;gap:4px;padding:0 10px;border-left:2px solid var(--shd-orange)}.protocol-primary-editor>div span,.protocol-primary-editor>div small{color:#626a6c;font-size:6px}.protocol-primary-editor>div b{color:var(--shd-orange);font-size:15px}.protocol-row{grid-template-columns:1.2fr .65fr .8fr 1fr 1.1fr .55fr .3fr}@media(max-width:1100px){.protocol-profile-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:1000px){.protocol-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.protocol-primary-editor{grid-template-columns:1fr 1fr}.protocol-primary-editor>div{grid-column:1/3}}@media(max-width:650px){.protocol-profile-grid,.selected-secondary-profile>div,.protocol-primary-editor,.shared-secondary-editor{grid-template-columns:1fr}.selected-secondary-profile>div span{border-left:0;border-top:1px solid #2b3d30}.protocol-primary-editor>div{grid-column:auto}} .weapon-exotic-talent{margin:10px 0;padding:10px;border:1px solid #765426;border-top:2px solid var(--shd-orange);background:#151208}.exotic-talent-identity{grid-template-columns:minmax(180px,.65fr) minmax(320px,1.35fr)!important}.exotic-talent-identity textarea{min-height:64px}.exotic-talent-identity small{display:block;margin-top:5px;color:#69665e;font-size:7px}.weapon-exotic-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:8px;margin-top:8px}.exotic-effect-list{display:grid;gap:5px}.exotic-effect-row{display:grid;grid-template-columns:28px minmax(150px,1fr) 85px 85px 28px;gap:5px;align-items:center}.exotic-effect-row>strong{height:32px;display:grid;place-items:center;background:#211a0e;color:var(--shd-orange);font-size:8px}.exotic-effect-row select,.exotic-effect-row input{height:32px;min-width:0;margin:0;padding:0 7px;font-size:8px}.exotic-effect-row button{height:32px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.exotic-effect-row button:disabled{opacity:.35;cursor:not-allowed}@media(max-width:1050px){.weapon-exotic-grid{grid-template-columns:1fr}}@media(max-width:700px){.exotic-talent-identity{grid-template-columns:1fr!important}.exotic-effect-row{grid-template-columns:28px 1fr 1fr}.exotic-effect-row select{grid-column:2/4}.exotic-effect-row button{grid-column:3}} +.protocol-identity-grid{grid-template-columns:minmax(280px,1.35fr) minmax(420px,1fr);gap:12px}.protocol-classification-stack{display:grid;gap:7px}.protocol-quality-picker{min-width:0;margin:0;padding:0;border:0}.protocol-quality-picker legend{color:var(--shd-text-muted);font-size:8px;font-weight:bold;letter-spacing:1px}.protocol-quality-picker>div{height:32px;display:grid;grid-template-columns:repeat(4,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.protocol-quality-picker label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.protocol-quality-picker label:first-child{border-left:0}.protocol-quality-picker input{position:absolute;opacity:0;pointer-events:none}.protocol-quality-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:4px}.protocol-quality-picker span i{width:5px;height:5px;background:currentColor}.protocol-quality-picker b{font-size:6px;text-transform:uppercase}.protocol-quality-picker label:hover span{background:#171b1c;filter:brightness(1.2)}.protocol-quality-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.protocol-quality-picker input:checked+span{background:#171b1c;box-shadow:inset 0 -2px currentColor}.protocol-quality-picker input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}@media(max-width:1000px){.protocol-identity-grid{grid-template-columns:1fr}}@media(max-width:650px){.protocol-quality-picker b{font-size:5px}} diff --git a/src/lib/components/GameIcon.svelte b/src/lib/components/GameIcon.svelte new file mode 100644 index 0000000..57edd5c --- /dev/null +++ b/src/lib/components/GameIcon.svelte @@ -0,0 +1,18 @@ + + +{decorative diff --git a/src/lib/game-icons.ts b/src/lib/game-icons.ts new file mode 100644 index 0000000..ee7935b --- /dev/null +++ b/src/lib/game-icons.ts @@ -0,0 +1,17 @@ +export const gameIcons = { + blast: { src: '/blast.png', label: 'Blast damage' }, + pierce: { src: '/pierce.png', label: 'Pierce damage' }, + shredding: { src: '/shredding.png', label: 'Shredding damage' }, + firepower: { src: '/firepower.png', label: 'Firepower' }, + toughness: { src: '/toughness.png', label: 'Toughness' }, + engineering: { src: '/engineering.png', label: 'Engineering' }, + bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' }, + combat_medic: { src: '/combat_medic.png', label: 'Combat Medic specialization' }, + demolitionist: { src: '/demolitionist.png', label: 'Demolitionist specialization' }, + tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' }, + vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' }, + legendary: { src: '/legendary.png', label: 'Legendary difficulty' }, + shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' } +} as const; + +export type GameIconName = keyof typeof gameIcons; diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 370cea6..392a250 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', 'roles'].includes(table)) { + if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'os_protocols', 'roles'].includes(table)) { throw new Error('Unsupported audit table'); } const database = await getDatabase(); diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 5c60bd3..6a273c9 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -7,7 +7,8 @@ { href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' }, { href: '/admin/brands', label: 'Brand sets', code: 'BRND' }, { href: '/admin/gear', label: 'Gear', code: 'GEAR' }, - { href: '/admin/weapons', label: 'Weapons', code: 'WPN' } + { href: '/admin/weapons', label: 'Weapons', code: 'WPN' }, + { href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR' } ]; const userSections = [ { href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' }, @@ -33,7 +34,7 @@ {/if} {#if can('data.manage')} - + diff --git a/src/routes/admin/metrics/+page.server.ts b/src/routes/admin/metrics/+page.server.ts index 12f7934..08bef7f 100644 --- a/src/routes/admin/metrics/+page.server.ts +++ b/src/routes/admin/metrics/+page.server.ts @@ -43,7 +43,7 @@ export const load: PageServerLoad = async ({ cookies }) => { (SELECT COUNT(*) FROM comments) AS comments, (SELECT COUNT(*) FROM audit_log) AS audit_events, (SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+ - (SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons) AS game_records`)).get(new Date().toISOString()), + (SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)).get(new Date().toISOString()), stat(databasePath).catch(() => null) ]); const pages = numericPragma(pageCount); diff --git a/src/routes/admin/protocols/+page.server.ts b/src/routes/admin/protocols/+page.server.ts new file mode 100644 index 0000000..bd3e936 --- /dev/null +++ b/src/routes/admin/protocols/+page.server.ts @@ -0,0 +1,192 @@ +import { auditRecord, recordIsacAudit } from '$lib/server/audit'; +import { requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { getPagination } 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']; +const attributeTypes = ['Firepower', 'Toughness', 'Engineering']; +const secondaryRoles = ['Core', 'Damage', 'Health']; +const expectedSecondaryAttributes: Record> = { + Firepower: { Core: 'Firepower', Damage: 'All Weapon Damage', Health: 'Health' }, + Toughness: { Core: 'Toughness', Damage: 'All Weapon Damage', Health: 'Health' }, + Engineering: { Core: 'Engineering', Damage: 'Skill Damage', Health: 'Health' } +}; + +const parseList = (value: FormDataEntryValue | null): any[] => { + try { + const parsed = JSON.parse(String(value ?? '[]')); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +async function protocolSnapshot(id: string) { + const database = await getDatabase(); + const talent = await (await database.prepare('SELECT * FROM os_protocol_talents WHERE protocol_id=?')).get(id); + return { + record: await auditRecord('os_protocols', id), + primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null, + talent: talent ?? null, + talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [], + talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [] + }; +} + +async function profileSnapshot(id: string) { + const database = await getDatabase(); + return { + record: await (await database.prepare('SELECT * FROM os_protocol_secondary_profiles WHERE id=?')).get(id) ?? null, + effects: await (await database.prepare('SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role')).all(id) + }; +} + +export const load: PageServerLoad = async ({ cookies, url }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const database = await getDatabase(); + const requestedFilter = url.searchParams.get('filter') ?? ''; + const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter) + ? requestedFilter : ''; + const conditions: string[] = []; + const args: unknown[] = []; + if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`); + else if (qualities.some((quality) => quality.toLowerCase() === filter)) { + conditions.push('quality=?'); + args.push(qualities.find((quality) => quality.toLowerCase() === filter)); + } else if (attributeTypes.some((type) => type.toLowerCase() === filter)) { + conditions.push('primary_attribute_type=?'); + args.push(attributeTypes.find((type) => type.toLowerCase() === filter)); + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [summary, filtered] = 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 quality='Standard' THEN 1 ELSE 0 END) AS standard, + SUM(CASE WHEN quality='Specialized' THEN 1 ELSE 0 END) AS specialized, + SUM(CASE WHEN quality='Superior' THEN 1 ELSE 0 END) AS superior, + SUM(CASE WHEN quality='High-end' THEN 1 ELSE 0 END) AS high_end + FROM os_protocols`)).get(), + (await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args) + ]); + const pagination = getPagination(url, Number(filtered?.total ?? 0)); + const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers] = await Promise.all([ + (await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), + (await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(), + (await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(), + (await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(), + (await database.prepare('SELECT * FROM os_protocol_talents')).all(), + (await database.prepare('SELECT * FROM os_protocol_talent_triggers')).all(), + (await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).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,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all() + ]); + const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) })); + return { + protocols: protocols.map((protocol) => { + const talent = talents.find((item) => item.protocol_id === protocol.id); + return { + ...protocol, + primaryEffect: primaryEffects.find((item) => item.protocol_id === protocol.id) ?? null, + secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null, + talent: talent ? { + ...talent, + triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)), + effects: talentEffects.filter((item) => item.talent_id === talent.id) + } : null + }; + }), + profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) => + profile.quality === quality && profile.primary_attribute_type === attributeType + ) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })), + stats, triggers, summary, filter, pagination + }; +}; + +export const actions: Actions = { + saveProfile: async ({ request, cookies }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); + const form = await request.formData(); + const quality = String(form.get('quality') ?? ''); + const attributeType = String(form.get('attributeType') ?? ''); + if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { profileError: 'Choose a valid quality and attribute type.' }); + const database = await getDatabase(); + const expected = expectedSecondaryAttributes[attributeType]; + const statRows = await (await database.prepare(`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`)).all(expected.Core, expected.Damage, expected.Health); + const statByName = new Map(statRows.map((stat) => [String(stat.name).toLowerCase(), String(stat.id)])); + if (statRows.length !== 3) return fail(400, { profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.` }); + const effects = secondaryRoles.map((role) => ({ role, statId: statByName.get(expected[role].toLowerCase()) ?? '', value: Number(form.get(`value${role}`)) })); + if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) return fail(400, { profileError: 'Every shared secondary attribute requires a non-negative value.' }); + const existing = await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType); + const profileId = String(existing?.id ?? randomUUID()), now = new Date().toISOString(); + const before = existing ? await profileSnapshot(profileId) : null; + if (existing) await (await database.prepare('UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?')).run(now, profileId); + else await (await database.prepare('INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)')).run(profileId, quality, attributeType, now, now); + await (await database.prepare('DELETE FROM os_protocol_secondary_effects WHERE profile_id=?')).run(profileId); + for (const effect of effects) await (await database.prepare('INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)')).run(randomUUID(), profileId, effect.role, effect.statId, effect.value); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: existing ? 'os_protocol_profile.updated' : 'os_protocol_profile.created', entityId: profileId, before, after: await profileSnapshot(profileId) }); + return { profileSaved: true, resultToken: randomUUID() }; + }, + 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 quality = String(form.get('quality') ?? ''); + const attributeType = String(form.get('attributeType') ?? ''); + const primaryStatId = String(form.get('primaryStatId') ?? ''); + const primaryBaseValue = Number(form.get('primaryBaseValue')); + const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim(); + const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim(); + const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))]; + const talentEffects = parseList(form.get('talentEffects')).map((item, index) => ({ + statId: String(item.statId ?? ''), baseValue: Number(item.baseValue), sortOrder: index + 1 + })); + if (name.length < 2 || name.length > 80) return fail(400, { protocolError: 'Protocol names must be 2–80 characters.' }); + if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { protocolError: 'Choose a valid quality and primary attribute type.' }); + if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0) return fail(400, { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.' }); + if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !talentEffects.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, trigger, and at least one effect.' }); + if (talentEffects.length > 12 || talentEffects.some((item) => !item.statId || !Number.isFinite(item.baseValue))) return fail(400, { protocolError: 'Talent effects require valid attributes and base values.' }); + if (new Set(talentEffects.map((item) => item.statId)).size !== talentEffects.length) return fail(400, { protocolError: 'Each talent effect may reference an attribute only once.' }); + const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); + if (placeholders.some((index) => index < 1 || index > talentEffects.length)) return fail(400, { protocolError: 'Description placeholders must reference configured talent effects.' }); + const database = await getDatabase(); + if (!await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType)) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.' }); + if (await (await database.prepare('SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { protocolError: 'That OS Protocol already exists.' }); + const existingTalent = id ? await (await database.prepare('SELECT id FROM os_protocol_talents WHERE protocol_id=?')).get(id) : null; + if (await (await database.prepare('SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { protocolError: 'That protocol talent name is already in use.' }); + const statIds = [...new Set([primaryStatId, ...talentEffects.map((item) => item.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, { protocolError: 'One or more selected attributes are unavailable.' }); + const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds); + if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { protocolError: 'One or more selected triggers are unavailable.' }); + const protocolId = id || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString(); + const before = id ? await protocolSnapshot(id) : null; + if (id) { + if (!await auditRecord('os_protocols', id)) return fail(404, { protocolError: 'OS Protocol not found.' }); + await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id); + } else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now); + await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?) + ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue); + await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) + ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now); + await (await database.prepare('DELETE FROM os_protocol_talent_triggers WHERE talent_id=?')).run(talentId); + for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId); + await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId); + for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) }); + return { protocolSaved: true, protocolMode: 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 protocolSnapshot(id); + if (!before.record) return fail(404, { protocolError: 'OS Protocol not found.' }); + await (await (await getDatabase()).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'os_protocol.archived', entityId: id, before, after: await protocolSnapshot(id) }); + return { protocolArchived: true }; + } +}; diff --git a/src/routes/admin/protocols/+page.svelte b/src/routes/admin/protocols/+page.svelte new file mode 100644 index 0000000..dc93d47 --- /dev/null +++ b/src/routes/admin/protocols/+page.svelte @@ -0,0 +1,165 @@ + + +OS Protocols — SHD.build admin +

OPERATING SYSTEM MODIFICATION REGISTRY

OS PROTOCOLS

Configure shared secondary profiles, unique level-scaled primary stats, and five-stage talents.

+ +
+
SHARED ATTRIBUTE PROFILESONE PROFILE PER QUALITY + ATTRIBUTE TYPE · REUSED BY EVERY MATCHING PROTOCOL
{data.profiles.filter((profile) => profile.effects.length === 3).length} / 12 CONFIGURED
+
+ {#each data.profiles as profile} + + {/each} +
+
+ +{#if profileEditing} +
+

SHARED PROFILE

{profileEditing.quality.toUpperCase()} · {profileEditing.primary_attribute_type.toUpperCase()}

+ + + +

These values apply to every {profileEditing.quality} {profileEditing.primary_attribute_type} OS Protocol. They do not scale with an individual protocol’s level.

+
{#each secondaryRoles as role}{/each}
+ {#if form?.profileError}

{form.profileError}

{/if} +
+
+
+{/if} + + + +{#if editing} +
+

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

{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}

+ + + ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} /> +
+ +
+
QUALITY
{#each qualities as quality}{/each}
+
TYPE
{#each attributeTypes as type}{/each}
+
+
+ {@const selectedProfile = profileFor(editing.quality, editing.primary_attribute_type)} +
INHERITED PROFILE{editing.quality.toUpperCase()} · {editing.primary_attribute_type.toUpperCase()}
{#if configuredProfile()}
{#each secondaryRoles as role}{@const effect = profileEffect(selectedProfile, role)}{secondaryNames(editing.primary_attribute_type)[role]}{format(effect.value, effect.stat_attribute_id)}{/each}
{:else}

Configure this quality and type combination in Shared Secondary Attribute Profiles before saving.

{/if}
+ +
UNIQUE PRIMARY ATTRIBUTELEVEL 1 BASE VALUE · LINEAR TO 2× AT LEVEL 50
LEVEL {protocolLevel}
+
0150
+
LEVEL {protocolLevel} PREVIEW{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}
+ +
+
UNIQUE PROTOCOL TALENTOWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES
{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}
+
+
{#each [1, 2, 3, 4, 5] as upgrade}{/each}
UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}

{talentPreview(talentUpgrade) || 'Talent description preview'}

+
+
ACTIVATION TRIGGERSREUSABLE TRIGGER DEFINITIONS
{editing.talent.triggerIds.length}
+
TALENT EFFECTSBASE VALUE AT ZERO UPGRADES
{#each editing.talent.effects as effect, index}
{index + 1}{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5
{/each}
+
+
+ {#if form?.protocolError}

{form.protocolError}

{/if} +
{#if editing.id}{/if}
+
+
+{/if} + + +
+
OS PROTOCOLQUALITYTYPEUNIQUE PRIMARYUNIQUE TALENTSTATE
+ {#each data.protocols as protocol} +
{protocol.name}LEVEL 1–50 · PRIMARY 2× MAX
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+ {:else}
NO OS PROTOCOLS REGISTEREDConfigure shared secondary profiles, then add the first protocol.
{/each} +
diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte index 92e3bb0..f937a06 100644 --- a/src/routes/admin/weapons/+page.svelte +++ b/src/routes/admin/weapons/+page.svelte @@ -3,10 +3,13 @@ import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; + import GameIcon from '$lib/components/GameIcon.svelte'; + import type { GameIconName } from '$lib/game-icons'; let { data, form } = $props(); const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const damageTypes = ['Blast', 'Pierce', 'Shredding']; const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const; + const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 }); const blankExoticTalent = () => ({ id: '', name: '', 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 }] }); @@ -81,7 +84,7 @@ - +
DAMAGE TYPE
{#each damageTypes as damageType}{/each}
{#if Number(editing.is_exotic)} @@ -117,7 +120,7 @@ {#each [0, 1, 2] as tier}
TIER {tier} {#each ranges as range} -
{range[1]}{range[2]}
+
{#if range[0] !== 'damage'}{/if}{range[1]}{range[2]}
{/each}
{/each} @@ -145,6 +148,6 @@
WEAPONCLASSFIXED ATTRIBUTESTIER RANGETALENT POOLSAUGMENTSSTATE
{#each data.weapons as weapon} -
{weapon.name}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES
{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/3 CONFIGURED{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{weapon.name}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES
{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/3 CONFIGURED{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
{:else}
NO WEAPONS REGISTEREDAdd active weapon talents and attributes, then create the first armament definition.
{/each}
diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index a4120d9..0448c8d 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -1,5 +1,6 @@