feat(isac): add game data icon management
This commit is contained in:
@@ -29,6 +29,7 @@ Thumbs.db
|
|||||||
/data/*.turso-*
|
/data/*.turso-*
|
||||||
/data/backups/
|
/data/backups/
|
||||||
/data/avatars/
|
/data/avatars/
|
||||||
|
/data/game-icons/
|
||||||
/data/avatar-sources/
|
/data/avatar-sources/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
|
|||||||
@@ -404,3 +404,11 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects (
|
|||||||
UNIQUE(talent_id, stat_attribute_id),
|
UNIQUE(talent_id, stat_attribute_id),
|
||||||
UNIQUE(talent_id, sort_order)
|
UNIQUE(talent_id, sort_order)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS game_data_icons (
|
||||||
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol')),
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(entity_type, entity_id)
|
||||||
|
);
|
||||||
|
|||||||
@@ -416,6 +416,34 @@ disclosure header always exposes the configured total, uses a textual title,
|
|||||||
and rotates the plus indicator when expanded. The full matrix remains keyboard
|
and rotates the plus indicator when expanded. The full matrix remains keyboard
|
||||||
operable and follows the shared hover, focus-visible, and pressed states.
|
operable and follows the shared hover, focus-visible, and pressed states.
|
||||||
|
|
||||||
|
### Game-data icons
|
||||||
|
|
||||||
|
OS Protocols, brand sets, and talents use the shared game-data icon upload
|
||||||
|
control in their ISAC editors. Existing artwork is previewed and may be
|
||||||
|
replaced or explicitly removed. Registry identity cells render a compact
|
||||||
|
thumbnail before the record name and retain a neutral textual fallback when no
|
||||||
|
icon is configured.
|
||||||
|
|
||||||
|
Source artwork must be a transparent SVG, PNG, WebP, or AVIF no larger than
|
||||||
|
2 MiB. SVG is treated only as an upload source and never served directly.
|
||||||
|
Accepted images are normalized asynchronously to a metadata-free transparent
|
||||||
|
256 × 256 WebP with `contain`, preserving the full symbol without avatar-style
|
||||||
|
cropping. Image labels remain visible in the UI, so icons supplement rather
|
||||||
|
than replace textual identity. Upload, replace, remove, pending, validation,
|
||||||
|
focus-visible, and narrow-screen states follow the shared engineered form
|
||||||
|
language.
|
||||||
|
|
||||||
|
For compact editors such as brand sets, place the icon rail beside the record
|
||||||
|
name and its primary configuration rows instead of giving the upload control a
|
||||||
|
full-width band. Keep destructive archive controls isolated on the left side of
|
||||||
|
the action bar and use the established `.moderation-action.danger` treatment;
|
||||||
|
save and cancel remain grouped on the right.
|
||||||
|
|
||||||
|
Archived records that support restoration replace their destructive archive
|
||||||
|
control with a green outlined `.moderation-action.restore` control in the same
|
||||||
|
left-side position. The label must explicitly say “Unarchive” or “Restore”;
|
||||||
|
never communicate restoration by color or icon alone.
|
||||||
|
|
||||||
The dedicated unique-talent panel uses five interactive diamonds: no filled
|
The dedicated unique-talent panel uses five interactive diamonds: no filled
|
||||||
diamonds is the base definition, and each selected upgrade adds one fifth of
|
diamonds is the base definition, and each selected upgrade adds one fifth of
|
||||||
the base until five upgrades show exactly twice the base effect.
|
the base until five upgrades show exactly twice the base effect.
|
||||||
|
|||||||
@@ -163,3 +163,18 @@ The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–
|
|||||||
talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
|
talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
|
||||||
Server actions validate all relationships and append the full owned graph to
|
Server actions validate all relationships and append the full owned graph to
|
||||||
the existing `game_data` audit history.
|
the existing `game_data` audit history.
|
||||||
|
|
||||||
|
# Game-data icon storage
|
||||||
|
|
||||||
|
Talents, brand sets, and OS Protocols reference uploaded artwork through the
|
||||||
|
polymorphic `game_data_icons` metadata table. Original uploads are never
|
||||||
|
retained. The shared server pipeline accepts transparent SVG, PNG, WebP, and
|
||||||
|
AVIF sources, rejects active or externally referenced SVG content, and uses
|
||||||
|
Sharp’s asynchronous native worker pool to rasterize and normalize every
|
||||||
|
accepted icon to a transparent 256 × 256 WebP.
|
||||||
|
|
||||||
|
Normalized files live beneath the ignored `data/game-icons/` directory and are
|
||||||
|
served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable
|
||||||
|
cache headers and MIME sniffing disabled. ISAC save actions process files
|
||||||
|
before mutation, apply upload or removal after record validation, and include
|
||||||
|
only safe icon metadata—not image bytes—in their audit snapshots.
|
||||||
|
|||||||
@@ -117,4 +117,7 @@
|
|||||||
.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}}
|
.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}}
|
.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}}
|
.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}}
|
||||||
|
.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:150px minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{max-width:none}.icon-upload-preview{height:70px}}
|
||||||
|
.brand-editor-body{display:grid;grid-template-columns:122px minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}}
|
||||||
|
.brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
let {
|
||||||
|
currentUrl = '',
|
||||||
|
label = 'ICON'
|
||||||
|
}: { currentUrl?: string | null; label?: string } = $props();
|
||||||
|
let previewUrl = $state('');
|
||||||
|
let fileName = $state('');
|
||||||
|
const choose = (event: Event) => {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = file && file.type !== 'image/svg+xml' ? URL.createObjectURL(file) : '';
|
||||||
|
fileName = file?.name ?? '';
|
||||||
|
};
|
||||||
|
onDestroy(() => { if (previewUrl) URL.revokeObjectURL(previewUrl); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="icon-upload-field">
|
||||||
|
<legend>{label}</legend>
|
||||||
|
<div class="icon-upload-preview">
|
||||||
|
{#if previewUrl || currentUrl}<img src={previewUrl || currentUrl || ''} alt="Current icon preview"/>{:else}<span>NO ICON</span>{/if}
|
||||||
|
</div>
|
||||||
|
<label><input name="icon" type="file" accept="image/svg+xml,image/png,image/webp,image/avif" onchange={choose}/><span>{fileName || (currentUrl ? 'REPLACE ICON' : 'UPLOAD ICON')}</span></label>
|
||||||
|
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
|
||||||
|
<small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol'] as const;
|
||||||
|
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||||
|
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
||||||
|
export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
||||||
|
export const GAME_ICON_SIZE = 256;
|
||||||
|
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||||
|
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol)-[0-9a-f-]{36}\.webp$/i;
|
||||||
|
|
||||||
|
export class GameDataIconValidationError extends Error {}
|
||||||
|
|
||||||
|
export function gameDataIconStorageDirectory(): string {
|
||||||
|
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertType(type: string): asserts type is GameDataIconType {
|
||||||
|
if (!GAME_ICON_TYPES.includes(type as GameDataIconType)) throw new Error('Invalid game-data icon type');
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(type: GameDataIconType, entityId: string): string {
|
||||||
|
if (!ENTITY_ID_PATTERN.test(entityId)) throw new Error('Invalid game-data entity identifier');
|
||||||
|
return `${type}-${entityId}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storagePath(key: string): string {
|
||||||
|
if (!STORAGE_KEY_PATTERN.test(key)) throw new Error('Invalid game-data icon storage key');
|
||||||
|
return path.join(gameDataIconStorageDirectory(), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameDataIconUrl(type: GameDataIconType, entityId: string, updatedAt?: unknown): string {
|
||||||
|
assertType(type);
|
||||||
|
if (!ENTITY_ID_PATTERN.test(entityId)) return '';
|
||||||
|
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
|
||||||
|
return `/icons/game-data/${type}/${entityId}${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processGameDataIconUpload(file: File): Promise<Buffer> {
|
||||||
|
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
|
||||||
|
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
|
||||||
|
}
|
||||||
|
const input = Buffer.from(await file.arrayBuffer());
|
||||||
|
try {
|
||||||
|
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
|
||||||
|
if (looksLikeSvg) {
|
||||||
|
const source = input.toString('utf8');
|
||||||
|
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|
||||||
|
|| /<!ENTITY\b/i.test(source)
|
||||||
|
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|
||||||
|
|| /@import\b/i.test(source)
|
||||||
|
|| /url\(\s*["']?(?!#)/i.test(source)) {
|
||||||
|
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) {
|
||||||
|
throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.');
|
||||||
|
}
|
||||||
|
const stats = await image.stats();
|
||||||
|
if (stats.isOpaque) {
|
||||||
|
throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.');
|
||||||
|
}
|
||||||
|
return await image
|
||||||
|
.rotate()
|
||||||
|
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
|
||||||
|
fit: 'contain',
|
||||||
|
position: 'centre',
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
})
|
||||||
|
.webp({ quality: 88, alphaQuality: 100, effort: 5 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GameDataIconValidationError) throw error;
|
||||||
|
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGameDataIcon(database: Database, type: GameDataIconType, entityId: string, output: Buffer): Promise<string> {
|
||||||
|
const key = storageKey(type, entityId);
|
||||||
|
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
||||||
|
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await (await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
|
||||||
|
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
|
||||||
|
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
||||||
|
.run(type, entityId, key, output.length, now);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
|
||||||
|
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
|
||||||
|
await (await database.prepare('DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?')).run(type, entityId);
|
||||||
|
if (typeof icon?.storage_key === 'string') {
|
||||||
|
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGameDataIcon(database: Database, type: string, entityId: string): Promise<Buffer | null> {
|
||||||
|
assertType(type);
|
||||||
|
if (!ENTITY_ID_PATTERN.test(entityId)) return null;
|
||||||
|
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
|
||||||
|
if (typeof icon?.storage_key !== 'string') return null;
|
||||||
|
try {
|
||||||
|
return await readFile(storagePath(icon.storage_key));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readIconMutation(form: FormData): Promise<{ output: Buffer | null; remove: boolean }> {
|
||||||
|
const file = form.get('icon');
|
||||||
|
const remove = form.get('removeIcon') === '1';
|
||||||
|
if (file instanceof File && file.size > 0) return { output: await processGameDataIconUpload(file), remove: false };
|
||||||
|
return { output: null, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyIconMutation(database: Database, type: GameDataIconType, entityId: string, mutation: { output: Buffer | null; remove: boolean }): Promise<void> {
|
||||||
|
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output);
|
||||||
|
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gameDataIconMetadata(database: Database, type: GameDataIconType, entityId: string) {
|
||||||
|
return await (await database.prepare('SELECT byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId) ?? null;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
|
||||||
const thresholds = [2, 3, 4] as const;
|
const thresholds = [2, 3, 4] as const;
|
||||||
|
|
||||||
@@ -21,14 +22,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
: filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : '';
|
: filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : '';
|
||||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get();
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get();
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [brands, effects, stats, effectSummary] = await Promise.all([
|
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
||||||
(await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
(await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||||
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
|
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).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,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get()
|
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
|
||||||
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
|
brands: brands.map((brand) => {
|
||||||
|
const icon = icons.find((item) => item.entity_id === brand.id);
|
||||||
|
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
|
||||||
|
}),
|
||||||
stats,
|
stats,
|
||||||
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
|
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
|
||||||
pagination,
|
pagination,
|
||||||
@@ -40,6 +45,9 @@ export const actions: Actions = {
|
|||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
let iconMutation;
|
||||||
|
try { iconMutation = await readIconMutation(form); }
|
||||||
|
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { brandError: error.message }); throw error; }
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||||
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
|
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
|
||||||
@@ -55,7 +63,7 @@ export const actions: Actions = {
|
|||||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
||||||
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
||||||
const brandId = id || randomUUID(), now = new Date().toISOString();
|
const brandId = id || randomUUID(), now = new Date().toISOString();
|
||||||
const before = id ? { record: await auditRecord('brand_sets', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
|
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
|
||||||
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||||
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
||||||
for (const effect of effects) {
|
for (const effect of effects) {
|
||||||
@@ -63,7 +71,8 @@ export const actions: Actions = {
|
|||||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
|
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
|
||||||
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
|
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
|
||||||
}
|
}
|
||||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), effects } });
|
await applyIconMutation(database, 'brand_set', brandId, iconMutation);
|
||||||
|
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), icon: await gameDataIconMetadata(database, 'brand_set', brandId), effects } });
|
||||||
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
archive: async ({ request, cookies }) => {
|
||||||
@@ -72,6 +81,15 @@ export const actions: Actions = {
|
|||||||
const before = await auditRecord('brand_sets', id);
|
const before = await auditRecord('brand_sets', id);
|
||||||
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
||||||
return { brandArchived: true };
|
return { brandArchived: true, resultToken: randomUUID() };
|
||||||
|
},
|
||||||
|
unarchive: async ({ request, cookies }) => {
|
||||||
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
|
const id = String((await request.formData()).get('id') ?? '');
|
||||||
|
const before = await auditRecord('brand_sets', id);
|
||||||
|
if (!before) return fail(404, { brandError: 'Brand set not found.' });
|
||||||
|
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||||
|
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.unarchived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
||||||
|
return { brandUnarchived: true, resultToken: randomUUID() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||||
type Editor = { id: string; name: string; effects: Effect[] };
|
type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] };
|
||||||
const blank = (): Editor => ({ id: '', name: '', effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
||||||
let editing = $state<Editor | null>(null);
|
let editing = $state<Editor | null>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
|
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
|
||||||
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
|
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
|
||||||
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
|
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
|
||||||
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if ((form?.brandArchived || form?.brandUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
|
||||||
|
handledResultToken = form.resultToken;
|
||||||
|
editing = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
editing = form.brandMode === 'add' ? blank() : null;
|
editing = form.brandMode === 'add' ? blank() : null;
|
||||||
@@ -24,19 +30,24 @@
|
|||||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div>
|
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div>
|
||||||
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Unavailable definitions'},{value:'complete',label:'COMPLETE',count:data.summary.complete,description:'All three bonuses mapped'}]}/>
|
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Unavailable definitions'},{value:'complete',label:'COMPLETE',count:data.summary.complete,description:'All three bonuses mapped'}]}/>
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor">
|
<div class="dashboard-editor brand-set-editor">
|
||||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||||
<PendingForm action="?/save">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id} />
|
<input type="hidden" name="id" value={editing.id} />
|
||||||
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
<div class="brand-editor-body">
|
||||||
<div class="effect-editor">
|
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON"/>
|
||||||
{#each [2, 3, 4] as pieces}
|
<div>
|
||||||
{@const effect = effectFor(editing, pieces)}
|
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||||
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><label class="field">ATTRIBUTE<select name={`stat${pieces}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit})</option>{/each}</select></label><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
|
<div class="effect-editor">
|
||||||
{/each}
|
{#each [2, 3, 4] as pieces}
|
||||||
|
{@const effect = effectFor(editing, pieces)}
|
||||||
|
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><label class="field">ATTRIBUTE<select name={`stat${pieces}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit})</option>{/each}</select></label><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
|
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
|
||||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE SET</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
|
<div class="form-actions split-actions"><div>{#if editing.id}{#if editing.active === 1}<button class="moderation-action danger brand-archive-action" formaction="?/archive" name="id" value={editing.id}><b>▰</b> ARCHIVE SET</button>{:else}<button class="moderation-action restore brand-archive-action" formaction="?/unarchive" name="id" value={editing.id}><b>↥</b> UNARCHIVE SET</button>{/if}{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -44,7 +55,7 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
||||||
{#each data.brands as brand}
|
{#each data.brands as brand}
|
||||||
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
|
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div class="registry-icon-identity">{#if brand.iconUrl}<img src={brand.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getPagination } from '$lib/server/pagination';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
|
||||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||||
@@ -30,6 +31,7 @@ async function protocolSnapshot(id: string) {
|
|||||||
const talent = await (await database.prepare('SELECT * FROM os_protocol_talents WHERE protocol_id=?')).get(id);
|
const talent = await (await database.prepare('SELECT * FROM os_protocol_talents WHERE protocol_id=?')).get(id);
|
||||||
return {
|
return {
|
||||||
record: await auditRecord('os_protocols', id),
|
record: await auditRecord('os_protocols', id),
|
||||||
|
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
||||||
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
|
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
|
||||||
talent: talent ?? 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) : [],
|
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
|
||||||
@@ -73,7 +75,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
|
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
|
||||||
]);
|
]);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers] = await Promise.all([
|
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons] = 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_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_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_profiles ORDER BY quality,primary_attribute_type')).all(),
|
||||||
@@ -82,7 +84,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await database.prepare('SELECT * FROM os_protocol_talent_triggers')).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 * 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,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()
|
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all()
|
||||||
]);
|
]);
|
||||||
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
|
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
|
||||||
return {
|
return {
|
||||||
@@ -90,6 +93,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const talent = talents.find((item) => item.protocol_id === protocol.id);
|
const talent = talents.find((item) => item.protocol_id === protocol.id);
|
||||||
return {
|
return {
|
||||||
...protocol,
|
...protocol,
|
||||||
|
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === protocol.id); return icon ? gameDataIconUrl('os_protocol', String(protocol.id), icon.updated_at) : ''; })(),
|
||||||
primaryEffect: primaryEffects.find((item) => item.protocol_id === protocol.id) ?? null,
|
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,
|
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null,
|
||||||
talent: talent ? {
|
talent: talent ? {
|
||||||
@@ -133,6 +137,9 @@ export const actions: Actions = {
|
|||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
let iconMutation;
|
||||||
|
try { iconMutation = await readIconMutation(form); }
|
||||||
|
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { protocolError: error.message }); throw error; }
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||||
const quality = String(form.get('quality') ?? '');
|
const quality = String(form.get('quality') ?? '');
|
||||||
@@ -177,6 +184,7 @@ export const actions: Actions = {
|
|||||||
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
|
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);
|
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);
|
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 applyIconMutation(database, 'os_protocol', protocolId, iconMutation);
|
||||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
|
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() };
|
return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
|
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
Health: 'Health'
|
Health: 'Health'
|
||||||
});
|
});
|
||||||
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] });
|
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] });
|
||||||
const blank = () => ({ id: '', name: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
||||||
let editing = $state<any | null>(null);
|
let editing = $state<any | null>(null);
|
||||||
let profileEditing = $state<any | null>(null);
|
let profileEditing = $state<any | null>(null);
|
||||||
let talentUpgrade = $state(0);
|
let talentUpgrade = $state(0);
|
||||||
@@ -124,11 +125,11 @@
|
|||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor protocol-editor">
|
<div class="dashboard-editor protocol-editor">
|
||||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||||
<PendingForm action="?/save">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
|
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
|
||||||
<div class="protocol-identity-grid">
|
<div class="protocol-identity-grid">
|
||||||
<label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
|
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON"/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||||
<div class="protocol-classification-stack">
|
<div class="protocol-classification-stack">
|
||||||
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
|
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
|
||||||
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
|
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
|
||||||
@@ -160,6 +161,6 @@
|
|||||||
<div class="dashboard-table protocol-dashboard-table">
|
<div class="dashboard-table protocol-dashboard-table">
|
||||||
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
||||||
{#each data.protocols as protocol}
|
{#each data.protocols as protocol}
|
||||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{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'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
|
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div class="registry-icon-identity">{#if protocol.iconUrl}<img src={protocol.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{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'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
|
||||||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
: filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : '';
|
: filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : '';
|
||||||
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get();
|
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get();
|
||||||
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
|
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
|
||||||
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners] = await Promise.all([
|
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
|
||||||
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||||
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
|
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
|
||||||
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
|
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
|
||||||
@@ -31,7 +32,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(),
|
(await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(),
|
||||||
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
|
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
|
||||||
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all(),
|
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all(),
|
||||||
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all()
|
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(),
|
||||||
|
(await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
|
||||||
]);
|
]);
|
||||||
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
|
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
|
||||||
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
|
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
|
||||||
@@ -47,6 +49,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
return {
|
return {
|
||||||
talents: talents.map((talent) => ({
|
talents: talents.map((talent) => ({
|
||||||
...talent,
|
...talent,
|
||||||
|
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
|
||||||
category: talent.category === 'Vest'
|
category: talent.category === 'Vest'
|
||||||
? 'Body Armor'
|
? 'Body Armor'
|
||||||
: talent.category === 'Weapon' && weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope === 'Exotic'
|
: talent.category === 'Weapon' && weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope === 'Exotic'
|
||||||
@@ -129,6 +132,9 @@ export const actions: Actions = {
|
|||||||
saveTalent: async ({ request, cookies }) => {
|
saveTalent: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
let iconMutation;
|
||||||
|
try { iconMutation = await readIconMutation(form); }
|
||||||
|
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; }
|
||||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
||||||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
|
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
|
||||||
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
|
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
|
||||||
@@ -147,7 +153,7 @@ export const actions: Actions = {
|
|||||||
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
|
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
|
||||||
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
|
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
|
||||||
const talentId = id || randomUUID(), now = new Date().toISOString();
|
const talentId = id || randomUUID(), now = new Date().toISOString();
|
||||||
const before = id ? { record: await auditRecord('talents', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
|
const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
|
||||||
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
|
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
|
||||||
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
|
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
|
||||||
if (storedCategory === 'Weapon') {
|
if (storedCategory === 'Weapon') {
|
||||||
@@ -174,7 +180,8 @@ export const actions: Actions = {
|
|||||||
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
|
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
|
||||||
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
|
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
|
||||||
}
|
}
|
||||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
|
await applyIconMutation(db, 'talent', talentId, iconMutation);
|
||||||
|
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
|
||||||
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Effect = { statId: string; valueI: string; valueII: string };
|
type Effect = { statId: string; valueI: string; valueII: string };
|
||||||
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
const blankTalent = () => ({ id: '', name: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
|
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
|
||||||
const editTalent = (row: any) => {
|
const editTalent = (row: any) => {
|
||||||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
|
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
|
||||||
const one = tier('I'), two = tier('II');
|
const one = tier('I'), two = tier('II');
|
||||||
@@ -66,9 +67,9 @@
|
|||||||
{#if talent}
|
{#if talent}
|
||||||
<div class="dashboard-editor">
|
<div class="dashboard-editor">
|
||||||
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
||||||
<PendingForm action="?/saveTalent">
|
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
|
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
|
||||||
<div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div>
|
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON"/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div></div>
|
||||||
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
|
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
|
||||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||||
<p class="label">SHARED ACTIVATION TRIGGERS</p>
|
<p class="label">SHARED ACTIVATION TRIGGERS</p>
|
||||||
@@ -82,4 +83,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
@@ -34,7 +35,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
|
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
|
||||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects] = await Promise.all([
|
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons] = 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 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_tier_ranges ORDER BY tier')).all(),
|
||||||
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
|
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
|
||||||
@@ -57,7 +58,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
|
(await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
|
||||||
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
|
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
|
||||||
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
|
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
|
||||||
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all()
|
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
|
||||||
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
weapons: weapons.map((weapon) => {
|
weapons: weapons.map((weapon) => {
|
||||||
@@ -78,6 +80,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
|
baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
|
||||||
exoticTalent: talentId ? {
|
exoticTalent: talentId ? {
|
||||||
id: talentId,
|
id: talentId,
|
||||||
|
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
|
||||||
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
|
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
|
||||||
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
|
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
|
||||||
triggerIds: [...new Set([
|
triggerIds: [...new Set([
|
||||||
@@ -117,6 +120,9 @@ export const actions: Actions = {
|
|||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
let exoticIconMutation;
|
||||||
|
try { exoticIconMutation = await readIconMutation(form); }
|
||||||
|
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { weaponError: error.message }); throw error; }
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||||
const category = String(form.get('category') ?? '');
|
const category = String(form.get('category') ?? '');
|
||||||
@@ -253,6 +259,7 @@ export const actions: Actions = {
|
|||||||
baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses 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),
|
||||||
exoticTalent: existingExoticTalentId ? {
|
exoticTalent: existingExoticTalentId ? {
|
||||||
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
|
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
|
||||||
|
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
|
||||||
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
|
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
|
||||||
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
|
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
|
||||||
triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId),
|
triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId),
|
||||||
@@ -285,6 +292,7 @@ export const actions: Actions = {
|
|||||||
for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
|
for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
|
||||||
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
|
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
|
||||||
}
|
}
|
||||||
|
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation);
|
||||||
}
|
}
|
||||||
if (id) {
|
if (id) {
|
||||||
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
|
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
|
||||||
@@ -322,7 +330,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(?,?,?,?,?)'))
|
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);
|
.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, 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, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
|
||||||
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
archive: async ({ request, cookies }) => {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
|
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 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 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 blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', 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)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
|
||||||
let editing = $state<any | null>(null);
|
let editing = $state<any | null>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor weapon-editor">
|
<div class="dashboard-editor weapon-editor">
|
||||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||||
<PendingForm action="?/save">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
<div class="weapon-identity-grid">
|
<div class="weapon-identity-grid">
|
||||||
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
|
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
|
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
|
||||||
<section class="weapon-exotic-talent">
|
<section class="weapon-exotic-talent">
|
||||||
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
||||||
<div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON"/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div></div>
|
||||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
|
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
|
||||||
<div class="weapon-exotic-grid">
|
<div class="weapon-exotic-grid">
|
||||||
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>
|
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { GAME_ICON_TYPES, readGameDataIcon } from '$lib/server/game-data-icons';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
if (!GAME_ICON_TYPES.includes(params.type as (typeof GAME_ICON_TYPES)[number])) error(404, 'Icon not found');
|
||||||
|
const image = await readGameDataIcon(await getDatabase(), params.type, params.id);
|
||||||
|
if (!image) error(404, 'Icon not found');
|
||||||
|
return new Response(new Uint8Array(image), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/webp',
|
||||||
|
'cache-control': 'public, max-age=31536000, immutable',
|
||||||
|
'x-content-type-options': 'nosniff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user