From 7a12b1d7401c2863db5b35f35b24ddce0e6149d0 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 12:38:40 -0400 Subject: [PATCH] feat(isac): add game data icon management --- .gitignore | 1 + database/schema.sql | 8 ++ docs/DESIGN_SYSTEM.md | 28 ++++ docs/architecture.md | 15 ++ src/app.css | 3 + src/lib/components/IconUploadField.svelte | 26 ++++ src/lib/server/game-data-icons.ts | 131 ++++++++++++++++++ src/routes/admin/brands/+page.server.ts | 30 +++- src/routes/admin/brands/+page.svelte | 37 +++-- src/routes/admin/protocols/+page.server.ts | 12 +- src/routes/admin/protocols/+page.svelte | 9 +- src/routes/admin/talents/+page.server.ts | 15 +- src/routes/admin/talents/+page.svelte | 9 +- src/routes/admin/weapons/+page.server.ts | 14 +- src/routes/admin/weapons/+page.svelte | 7 +- .../icons/game-data/[type]/[id]/+server.ts | 17 +++ 16 files changed, 323 insertions(+), 39 deletions(-) create mode 100644 src/lib/components/IconUploadField.svelte create mode 100644 src/lib/server/game-data-icons.ts create mode 100644 src/routes/icons/game-data/[type]/[id]/+server.ts diff --git a/.gitignore b/.gitignore index 6a2cd63..4cc2ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ Thumbs.db /data/*.turso-* /data/backups/ /data/avatars/ +/data/game-icons/ /data/avatar-sources/ # Vite diff --git a/database/schema.sql b/database/schema.sql index 42c77c9..92ccdf7 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -404,3 +404,11 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects ( UNIQUE(talent_id, stat_attribute_id), 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) +); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 61e3f6c..4d04159 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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 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 diamonds is the base definition, and each selected upgrade adds one fifth of the base until five upgrades show exactly twice the base effect. diff --git a/docs/architecture.md b/docs/architecture.md index 95276cc..80a1915 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. Server actions validate all relationships and append the full owned graph to 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. diff --git a/src/app.css b/src/app.css index 4a5c50b..cf40dc3 100644 --- a/src/app.css +++ b/src/app.css @@ -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}} .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}} +.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} diff --git a/src/lib/components/IconUploadField.svelte b/src/lib/components/IconUploadField.svelte new file mode 100644 index 0000000..ecc9b81 --- /dev/null +++ b/src/lib/components/IconUploadField.svelte @@ -0,0 +1,26 @@ + + +
+ {label} +
+ {#if previewUrl || currentUrl}Current icon preview{:else}NO ICON{/if} +
+ + {#if currentUrl}{/if} + TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX +
diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts new file mode 100644 index 0000000..ef3600e --- /dev/null +++ b/src/lib/server/game-data-icons.ts @@ -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 { + 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(' { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/routes/admin/brands/+page.server.ts b/src/routes/admin/brands/+page.server.ts index caed44b..c299acb 100644 --- a/src/routes/admin/brands/+page.server.ts +++ b/src/routes/admin/brands/+page.server.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; 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; @@ -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' : ''; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(); 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 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 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 { - 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, summary: { ...summary, effects: Number(effectSummary?.total ?? 0) }, pagination, @@ -40,6 +45,9 @@ export const actions: Actions = { save: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); 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 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.' }); @@ -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); 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 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); 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) { @@ -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` )).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() }; }, archive: async ({ request, cookies }) => { @@ -72,6 +81,15 @@ export const actions: Actions = { 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 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() }; } }; diff --git a/src/routes/admin/brands/+page.svelte b/src/routes/admin/brands/+page.svelte index f3ece1e..91990fb 100644 --- a/src/routes/admin/brands/+page.svelte +++ b/src/routes/admin/brands/+page.svelte @@ -2,17 +2,23 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; + import IconUploadField from '$lib/components/IconUploadField.svelte'; let { data, form } = $props(); type Effect = { pieces_required: number; stat_attribute_id: string; value: number }; - type Editor = { id: string; name: string; effects: Effect[] }; - const blank = (): Editor => ({ id: '', name: '', effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) }); + type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] }; + 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(null); 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 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 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(() => { + if ((form?.brandArchived || form?.brandUnarchived) && form.resultToken && form.resultToken !== handledResultToken) { + handledResultToken = form.resultToken; + editing = null; + return; + } if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) { handledResultToken = form.resultToken; editing = form.brandMode === 'add' ? blank() : null; @@ -24,19 +30,24 @@

EQUIPMENT SYNERGY REGISTRY

BRAND SETS

Map equipment brands to the attributes unlocked at each set threshold.

{#if editing} -
+

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

{editing.id ? editing.name : 'BRAND SET DEFINITION'}

- + - -
- {#each [2, 3, 4] as pieces} - {@const effect = effectFor(editing, pieces)} -
{pieces}{pieces === 4 ? '+ PIECES' : ' PIECES'}
- {/each} +
+ +
+ +
+ {#each [2, 3, 4] as pieces} + {@const effect = effectFor(editing, pieces)} +
{pieces}{pieces === 4 ? '+ PIECES' : ' PIECES'}
+ {/each} +
+
{#if form?.brandError}

{form.brandError}

{/if} -
{#if editing.id}{/if}
+
{#if editing.id}{#if editing.active === 1}{:else}{/if}{/if}
{/if} @@ -44,7 +55,7 @@
BRAND SET2 PIECES3 PIECES4+ PIECESSTATE
{#each data.brands as brand} -
{brand.name}{brand.effects.length}/3 EFFECTS CONFIGURED
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{#if brand.iconUrl}{:else}◇{/if}
{brand.name}{brand.effects.length}/3 EFFECTS CONFIGURED
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
{:else}
NO BRAND SETS REGISTEREDAdd the first set to begin mapping equipment synergy bonuses.
{/each} diff --git a/src/routes/admin/protocols/+page.server.ts b/src/routes/admin/protocols/+page.server.ts index bd3e936..25b64d5 100644 --- a/src/routes/admin/protocols/+page.server.ts +++ b/src/routes/admin/protocols/+page.server.ts @@ -6,6 +6,7 @@ import { getPagination } from '$lib/server/pagination'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; 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 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); return { 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, 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) : [], @@ -73,7 +75,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args) ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers] = await Promise.all([ + 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_protocol_primary_effects')).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_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), - (await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all() + (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) })); return { @@ -90,6 +93,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const talent = talents.find((item) => item.protocol_id === protocol.id); return { ...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, secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null, talent: talent ? { @@ -133,6 +137,9 @@ export const actions: Actions = { save: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); 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 name = String(form.get('name') ?? '').normalize('NFKC').trim(); 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); await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId); for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder); + await 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) }); return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, diff --git a/src/routes/admin/protocols/+page.svelte b/src/routes/admin/protocols/+page.svelte index dc93d47..76764aa 100644 --- a/src/routes/admin/protocols/+page.svelte +++ b/src/routes/admin/protocols/+page.svelte @@ -5,6 +5,7 @@ import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; + import IconUploadField from '$lib/components/IconUploadField.svelte'; let { data, form } = $props(); type TalentEffect = { stat_attribute_id: string; base_value: number | string }; @@ -17,7 +18,7 @@ Health: 'Health' }); 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(null); let profileEditing = $state(null); let talentUpgrade = $state(0); @@ -124,11 +125,11 @@ {#if editing}

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

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

- + ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
- +
QUALITY
{#each qualities as quality}{/each}
TYPE
{#each attributeTypes as type}{/each}
@@ -160,6 +161,6 @@
OS PROTOCOLQUALITYTYPEUNIQUE PRIMARYUNIQUE TALENTSTATE
{#each data.protocols as protocol} -
{protocol.name}LEVEL 1–50 · PRIMARY 2× MAX
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{#if protocol.iconUrl}{:else}◇{/if}
{protocol.name}LEVEL 1–50 · PRIMARY 2× MAX
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
{:else}
NO OS PROTOCOLS REGISTEREDConfigure shared secondary profiles, then add the first protocol.
{/each}
diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index 9680fc5..a5f523a 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; 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 }; @@ -21,7 +22,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : ''; const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(); 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 id,talent_id,tier,description FROM talent_tiers')).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,scope FROM talent_weapon_scopes')).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([ (await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(), @@ -47,6 +49,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { return { talents: talents.map((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' ? 'Body Armor' : 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 }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); 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 descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim(); 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.' }); const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit])); 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); 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') { @@ -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 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() }; } }; diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index 3c6ed5e..b7c0a56 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -2,11 +2,12 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; + import IconUploadField from '$lib/components/IconUploadField.svelte'; let { data, form } = $props(); type Effect = { statId: string; valueI: string; valueII: string }; let talent = $state(null), trigger = $state(null), uptime = $state(false); 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 tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] }; const one = tier('I'), two = tier('II'); @@ -66,9 +67,9 @@ {#if talent}

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

- + -
+
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

SHARED ACTIVATION TRIGGERS

@@ -82,4 +83,4 @@ {/if} -
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{item.name}{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

ACTIVE{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
+
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{#if item.iconUrl}{:else}◇{/if}
{item.name}{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

ACTIVE{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts index 766a360..a1bdffc 100644 --- a/src/routes/admin/weapons/+page.server.ts +++ b/src/routes/admin/weapons/+page.server.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; 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 damageTypes = ['Blast', 'Pierce', 'Shredding']; @@ -34,7 +35,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const whereArgs = requestedWeaponId ? [requestedWeaponId] : []; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects] = 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 weapon_tier_ranges ORDER BY tier')).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 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,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 { weapons: weapons.map((weapon) => { @@ -78,6 +80,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id), exoticTalent: 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 ?? ''), descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''), triggerIds: [...new Set([ @@ -117,6 +120,9 @@ export const actions: Actions = { save: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); 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 name = String(form.get('name') ?? '').normalize('NFKC').trim(); 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), exoticTalent: 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), 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), @@ -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(?,?,?,?,?)')) .run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now); } + await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation); } if (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(?,?,?,?,?)')) .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() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte index f937a06..66929c8 100644 --- a/src/routes/admin/weapons/+page.svelte +++ b/src/routes/admin/weapons/+page.svelte @@ -5,13 +5,14 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; + import IconUploadField from '$lib/components/IconUploadField.svelte'; let { data, form } = $props(); const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const damageTypes = ['Blast', 'Pierce', 'Shredding']; const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const; const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 }); - const blankExoticTalent = () => ({ id: '', name: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); + const 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 }] }); let editing = $state(null); let handledResultToken = $state(''); @@ -78,7 +79,7 @@ {#if editing}

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

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

- +
@@ -92,7 +93,7 @@
DEDICATED EXOTIC TALENTOWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED
{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}
-
+
TIER I PREVIEW

{exoticPreview('I') || 'Description preview'}

TIER II PREVIEW

{exoticPreview('II') || 'Description preview'}

ACTIVATION TRIGGERSSHARED BETWEEN BOTH TIERS
{editing.exoticTalent.triggerIds.length}
diff --git a/src/routes/icons/game-data/[type]/[id]/+server.ts b/src/routes/icons/game-data/[type]/[id]/+server.ts new file mode 100644 index 0000000..16c333c --- /dev/null +++ b/src/routes/icons/game-data/[type]/[id]/+server.ts @@ -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' + } + }); +};