From aaf9e0ce97e89feddd7009703fc11ce8c0047d1f Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 31 Jul 2026 22:39:00 -0400 Subject: [PATCH] feat: add gear and weapon renders --- docs/DESIGN_SYSTEM.md | 14 ++++ scripts/managed-images-import.mjs | 5 +- src/app.css | 2 + .../GameDataRenderUploadField.svelte | 16 ++++ src/lib/server/managed-images.test.ts | 17 +++- src/lib/server/managed-images.ts | 77 +++++++++++++++++-- src/routes/isac/gear/+page.server.ts | 24 ++++-- src/routes/isac/gear/+page.svelte | 10 ++- src/routes/isac/weapons/+page.server.ts | 25 ++++-- src/routes/isac/weapons/+page.svelte | 6 +- 10 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 src/lib/components/GameDataRenderUploadField.svelte diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 1edf4ea..cf10163 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1047,6 +1047,20 @@ 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. +### Weapon and gear renders + +Weapon and gear editors offer an optional item-render upload separate from the +icon system. Operators may save a definition without artwork, then add, replace, +or remove the render in a later edit. The upload accepts JPEG, PNG, WebP, and +AVIF up to 5 MiB. The server center-crops each upload to 1000 × 1000 and stores +it as WebP. It does not call remove.bg, trim the scene, or add transparency. + +Show the current square render in the upload control and as a compact square +thumbnail before the item name in ISAC registry rows. Keep the image decorative +when the adjacent text identifies the item. The control states the crop and +format contract so contributors do not confuse a render with a transparent +game-data icon. + 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”; diff --git a/scripts/managed-images-import.mjs b/scripts/managed-images-import.mjs index ad15b2c..f9e1422 100644 --- a/scripts/managed-images-import.mjs +++ b/scripts/managed-images-import.mjs @@ -22,10 +22,13 @@ for (const image of bundle.images) { || typeof image.contentBase64 !== 'string') { throw new Error('The bundle contains an invalid managed image.'); } + const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type); if (image.row.source_entity_type && ( typeof image.row.source_entity_id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) - || !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`) + || (isItemRender + ? !image.row.storage_key.startsWith('library-') + : !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`)) )) throw new Error('The bundle contains an invalid linked ISAC image.'); } diff --git a/src/app.css b/src/app.css index f6e8bf0..189342c 100644 --- a/src/app.css +++ b/src/app.css @@ -94,6 +94,8 @@ .avatar-picker{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:10px}.avatar-preset-form,.avatar-preset-form fieldset{min-width:0;height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;min-width:0;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{min-width:0;overflow-wrap:anywhere;font-size:var(--shd-type-telemetry);line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{min-width:0;margin-top:8px}.account-console .image-drop-input{grid-template-columns:64px minmax(0,1fr)}.account-console .image-upload-actions{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;width:100%}.account-console .image-upload-actions button{min-width:0} .image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:var(--shd-type-telemetry);text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:var(--shd-type-telemetry);letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}} .icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:var(--shd-type-telemetry)} +.game-data-render-upload{margin:8px 0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.game-data-render-upload legend{padding:0 5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.game-data-render-upload .image-drop-input{grid-template-columns:84px minmax(0,1fr) auto}.game-data-render-upload .image-upload-preview{width:84px;height:84px}.game-data-render-upload .image-upload-preview img{object-fit:cover}.game-data-render-upload .image-drop-copy{justify-self:stretch;text-align:center}.game-data-render-upload .render-remove{display:flex;gap:6px;align-items:center;margin-top:6px;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.game-data-render-upload .render-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.game-data-render-upload>small{display:block;margin-top:6px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35} +.registry-render-identity{display:flex;align-items:center;gap:8px;min-width:0}.registry-render-identity>div{display:grid;gap:2px;min-width:0}.registry-render{width:42px;height:42px;flex:0 0 auto;object-fit:cover;border:1px solid var(--shd-border);background:#090b0c} .proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:var(--shd-type-telemetry);letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:var(--shd-type-telemetry);color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a.has-pending{grid-template-columns:minmax(0,1fr) 18px 18px}.admin-sidebar nav a.has-pending em{grid-column:2;display:grid;place-items:center;min-width:18px;height:18px;padding:0 5px;border:1px solid #a26c19;background:#211a0e;color:#ffc45d;font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;text-align:center}.admin-sidebar nav a.has-pending b{grid-column:3;justify-self:end}.admin-sidebar nav a.has-pending:hover em,.admin-sidebar nav a.has-pending.active em{border-color:var(--shd-orange);background:#2b2111;color:#fff1d0}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(1120px,100%);max-height:calc(100vh - 32px);overflow:auto}.moderation-metadata{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-metadata b{color:var(--shd-text);font-size:var(--shd-type-telemetry)}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:var(--shd-type-telemetry);white-space:pre-wrap}.proposal-change-preview{margin-top:10px}.proposal-preview-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid var(--shd-border);background:#0d1011}.proposal-preview-head>div{display:grid;gap:3px}.proposal-preview-head span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.proposal-preview-head small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-preview-head>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.proposal-compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:7px}.proposal-editor-preview{min-width:0;border:1px solid var(--shd-border);background:#111415}.proposal-editor-preview.before{border-top:2px solid #596164}.proposal-editor-preview.after{border-top:2px solid var(--shd-orange)}.proposal-editor-preview>header{display:flex;align-items:center;justify-content:space-between;min-height:32px;padding:6px 9px;border-bottom:1px solid var(--shd-border);background:#0d1011}.proposal-editor-preview>header span,.proposal-editor-preview>header b{font-size:var(--shd-type-telemetry);letter-spacing:.9px}.proposal-editor-preview>header b{color:var(--shd-text-muted)}.proposal-editor-preview.after>header b{color:var(--shd-orange)}.proposal-attribute-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;padding:8px}.preview-field{min-width:0;display:grid;align-content:start;gap:5px}.preview-field.identity{grid-column:span 4}.preview-field>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.preview-field>strong,.preview-field.range>div{min-height:34px;display:flex;align-items:center;padding:7px 8px;border:1px solid #363b3c;background:#0d0f10;color:var(--shd-text);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.preview-field.identity>strong{min-height:43px;font-size:13px}.preview-field.range{grid-column:span 2}.preview-field.range>div{display:grid;grid-template-columns:1fr auto 1fr;gap:6px}.preview-field.range strong:last-child{text-align:right}.preview-field.range i{color:#555d5f;font-size:var(--shd-type-telemetry);font-style:normal}.proposal-empty-state{min-height:180px;display:grid;place-content:center;gap:6px;text-align:center}.proposal-empty-state b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px}.proposal-empty-state span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-editor-preview.generic{margin-top:7px}.proposal-field-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.proposal-field-summary>div{min-width:0;display:grid;align-content:start;gap:5px;padding:8px;border:1px solid #303536;background:#0d0f10}.proposal-field-summary span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.proposal-field-summary strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.proposal-field-summary small{max-height:42px;overflow:hidden;color:#687174;font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata,.proposal-compare-grid{grid-template-columns:1fr}.proposal-field-summary{grid-template-columns:1fr 1fr}}@media(max-width:520px){.proposal-attribute-grid,.proposal-field-summary{grid-template-columns:1fr}.preview-field.identity,.preview-field.range{grid-column:auto}} .account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:var(--shd-type-telemetry)}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 var(--shd-font-editorial)}.danger-eyebrow{color:var(--shd-danger)} .account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1} diff --git a/src/lib/components/GameDataRenderUploadField.svelte b/src/lib/components/GameDataRenderUploadField.svelte new file mode 100644 index 0000000..830a5cc --- /dev/null +++ b/src/lib/components/GameDataRenderUploadField.svelte @@ -0,0 +1,16 @@ + + +
+ {label} + + {#if currentUrl}{/if} + OPTIONAL · PNG, WEBP, AVIF, OR JPEG · CENTER-CROPPED TO 1000 × 1000 WEBP · NO BACKGROUND REMOVAL +
diff --git a/src/lib/server/managed-images.test.ts b/src/lib/server/managed-images.test.ts index 24fd70c..93526b1 100644 --- a/src/lib/server/managed-images.test.ts +++ b/src/lib/server/managed-images.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import sharp from 'sharp'; const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })); @@ -8,7 +9,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })); import { GAME_ICON_TYPES } from './game-data-icons'; -import { ManagedImageDeletionError, deleteManagedImage } from './managed-images'; +import { ManagedImageDeletionError, deleteManagedImage, processManagedImage } from './managed-images'; type Row = Record; @@ -137,3 +138,17 @@ describe('deleteManagedImage', () => { expect(rmMock).not.toHaveBeenCalled(); }); }); + +describe('processManagedImage', () => { + it('center-crops game-data renders to 1000 pixel WebP squares without removing the background', async () => { + const input = await sharp({ create: { width: 1296, height: 1000, channels: 3, background: '#263238' } }) + .png() + .toBuffer(); + const result = await processManagedImage(new File([input], 'mask.png', { type: 'image/png' }), false, false, true); + const metadata = await sharp(result.output).metadata(); + + expect(metadata.format).toBe('webp'); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + }); +}); diff --git a/src/lib/server/managed-images.ts b/src/lib/server/managed-images.ts index ffdf71a..4dcb7d8 100644 --- a/src/lib/server/managed-images.ts +++ b/src/lib/server/managed-images.ts @@ -1,4 +1,5 @@ import type { Database } from '@tursodatabase/database'; +import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; @@ -10,6 +11,8 @@ export const MANAGED_IMAGE_CATEGORIES = [ 'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System' ] as const; export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024; +export const GAME_DATA_RENDER_TYPES = ['weapon', 'gear'] as const; +export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number]; const MAX_PIXELS = 24_000_000; const ID_PATTERN = /^[0-9a-f-]{36}$/i; const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i; @@ -43,7 +46,7 @@ function validateSvg(input: Buffer) { } } -export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) { +export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean, square = false) { if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) { throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.'); } @@ -67,10 +70,12 @@ export async function processManagedImage(file: File, icon: boolean, forceWhite: if (metadata.hasAlpha) { pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 }); } - pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, { - fit: 'inside', - withoutEnlargement: !icon - }); + pipeline = square + ? pipeline.resize(1000, 1000, { fit: 'cover', position: 'centre' }) + : pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, { + fit: 'inside', + withoutEnlargement: !icon + }); if (icon && forceWhite) { const normalized = await pipeline .resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) @@ -104,6 +109,63 @@ export async function saveManagedImageFile(id: string, output: Buffer) { return key; } +export async function readGameDataRenderMutation(form: FormData): Promise<{ + file: File | null; + remove: boolean; + processed?: { output: Buffer; width: number; height: number }; +}> { + const file = form.get('render'); + return { + file: file instanceof File && file.size > 0 ? file : null, + remove: form.get('removeRender') === '1' + }; +} + +export async function applyGameDataRender( + database: Database, + type: GameDataRenderType, + entityId: string, + entityName: string, + mutation: { file: File | null; remove: boolean; processed?: { output: Buffer; width: number; height: number } }, + actorId: string +) { + const sourceType = `${type}_render`; + const existing = await (await database.prepare( + 'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?' + )).get(sourceType, entityId); + if (!mutation.file && !mutation.remove) return existing ?? null; + if (mutation.remove) { + if (!existing) return null; + await (await database.prepare('DELETE FROM managed_images WHERE id=?')).run(existing.id); + if (typeof existing.storage_key === 'string') await rm(storagePath(existing.storage_key), { force: true }); + return null; + } + const processed = mutation.processed ?? await processManagedImage(mutation.file!, false, false, true); + const now = new Date().toISOString(); + const category = type === 'weapon' ? 'Weapons' : 'Gear'; + const tags = JSON.stringify(['isac', 'game data', type, 'render']); + if (existing) { + await writeFile(storagePath(String(existing.storage_key)), processed.output, { mode: 0o600 }); + await (await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0, + original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)).run( + category, tags, mutation.file!.name, processed.output.length, processed.width, processed.height, + actorId, now, existing.id + ); + return { ...existing, category, tags_json: tags, byte_size: processed.output.length, width: processed.width, height: processed.height, updated_at: now }; + } + const id = randomUUID(); + const storageKey = await saveManagedImageFile(id, processed.output); + const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`; + await (await database.prepare(`INSERT INTO managed_images + (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, + source_entity_type,source_entity_id,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + id, name, category, tags, 0, 0, storageKey, mutation.file!.name, processed.output.length, + processed.width, processed.height, sourceType, entityId, actorId, now, now + ); + return { id, name, category, tags_json: tags, is_icon: 0, storage_key: storageKey, byte_size: processed.output.length, width: processed.width, height: processed.height, source_entity_type: sourceType, source_entity_id: entityId, updated_at: now }; +} + export async function deleteManagedImage(database: Database, id: string) { const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id); if (!image) return null; @@ -114,11 +176,12 @@ export async function deleteManagedImage(database: Database, id: string) { if (Boolean(sourceType) !== Boolean(sourceId)) { throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.'); } - if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) { + if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType) + && !GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)) { throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.'); } - const sourceIcon = sourceType && sourceId + const sourceIcon = sourceType && sourceId && GAME_ICON_TYPES.includes(sourceType as GameDataIconType) ? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null : null; if (sourceIcon && sourceIcon.storage_key !== image.storage_key) { diff --git a/src/routes/isac/gear/+page.server.ts b/src/routes/isac/gear/+page.server.ts index 49690ea..2c98ebc 100644 --- a/src/routes/isac/gear/+page.server.ts +++ b/src/routes/isac/gear/+page.server.ts @@ -8,6 +8,7 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { getPendingEntities } from '$lib/server/data-moderation'; import { gameDataIconUrl } from '$lib/server/game-data-icons'; +import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images'; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); @@ -76,7 +77,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : []; const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length); const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length); - const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons] = await Promise.all([ + const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons, renders] = await Promise.all([ databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [], (await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(), (await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(), @@ -86,7 +87,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(), - (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all() + (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(), + (await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'")).all() ]); const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand'); const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category))); @@ -116,7 +118,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })), ...gear.map((item) => ({ ...item, brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)), - talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)) + talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)), + renderUrl: (() => { + const render = renders.find((image) => image.source_entity_id === item.id); + return render ? managedImageUrl(String(render.id), render.updated_at) : ''; + })() }))], brands: [...pendingBrands, ...brands.map((brand) => { const icon = brandIcons.find((item) => item.entity_id === brand.id); @@ -209,6 +215,13 @@ export const actions: Actions = { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); + const renderMutation = await readGameDataRenderMutation(form); + try { + if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true); + } catch (error) { + if (error instanceof ManagedImageValidationError) return fail(400, { gearError: error.message }); + throw error; + } const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const slot = String(form.get('slot') ?? ''); const tier = Number(form.get('tier')); @@ -241,7 +254,7 @@ export const actions: Actions = { if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` }); } const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); - const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null; + const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id), render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?")).get(id) ?? null } : null; if (id) { await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`)) .run(name, slot, tier, ...health, ...armor, now, id); @@ -253,7 +266,8 @@ export const actions: Actions = { for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId); await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId); for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds } }); + const render = await applyGameDataRender(database, 'gear', gearId, name, renderMutation, operator.id); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds, render } }); return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/isac/gear/+page.svelte b/src/routes/isac/gear/+page.svelte index 594d4d6..1ddc3dc 100644 --- a/src/routes/isac/gear/+page.svelte +++ b/src/routes/isac/gear/+page.svelte @@ -8,15 +8,16 @@ import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; import ItemIcon from '$lib/components/ItemIcon.svelte'; + import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte'; import type { GameIconName } from '$lib/game-icons'; let { data, form } = $props(); - type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] }; + type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[]; renderUrl: string }; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const slotIcon = (slot: string): GameIconName => slot.toLocaleLowerCase().replaceAll(' ', '_') as GameIconName; const slotOptions = slots.map((slot) => ({ value: slot, label: slot, icon: slotIcon(slot), iconOnly: true })); const tierOptions = [0, 1, 2].map((tier) => ({ value: String(tier), label: `T${tier}` })); const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; - const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: '0', base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); + const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: '0', base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [], renderUrl: '' }); let editing = $state(null); let augmentEditing = $state(null); let handledResultToken = $state(''); @@ -71,13 +72,14 @@ {#if editing}

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

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

- +
changeSlot(values[0])} required/>
+
HPBASE HEALTH
ARBASE ARMOR
@@ -142,7 +144,7 @@
{#each data.gear as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{#if item.renderUrl}{/if}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{#if editing?.id === item.id}{@render gearEditor()}{/if} {:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts index 0f9ee3c..7fd8c0e 100644 --- a/src/routes/isac/weapons/+page.server.ts +++ b/src/routes/isac/weapons/+page.server.ts @@ -7,6 +7,7 @@ import type { Actions, PageServerLoad } from './$types'; import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { gameDataIconUrl } from '$lib/server/game-data-icons'; +import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images'; import { getPendingEntities } from '$lib/server/data-moderation'; import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses'; import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots'; @@ -60,7 +61,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])]; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, pending] = await Promise.all([ + const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, renders, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(), @@ -89,6 +90,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ORDER BY talents.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 entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(), + (await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='weapon_render'")).all(), getPendingEntities(user.id, [ { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, @@ -146,7 +148,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), - baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category) + baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category), + renderUrl: (() => { + const render = renders.find((item) => item.source_entity_id === weapon.id); + return render ? managedImageUrl(String(render.id), render.updated_at) : ''; + })() })); return { weapons: [...pendingWeapons, ...configuredWeapons], @@ -233,6 +239,13 @@ export const actions: Actions = { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); + const renderMutation = await readGameDataRenderMutation(form); + try { + if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true); + } catch (error) { + if (error instanceof ManagedImageValidationError) return fail(400, { weaponError: error.message }); + throw error; + } const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const category = String(form.get('category') ?? ''); const damageType = String(form.get('damageType') ?? ''); @@ -338,14 +351,15 @@ export const actions: Actions = { } } - const weaponId = id || randomUUID(); + const weaponId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const now = new Date().toISOString(); const before = id ? { record: await auditRecord('weapons', id), tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id), talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id), augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id), - modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id) + modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id), + render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='weapon_render' AND source_entity_id=?")).get(id) ?? null } : null; await database.exec('BEGIN'); try { @@ -377,12 +391,13 @@ export const actions: Actions = { await (await database.prepare('INSERT INTO weapon_augmentation_effects(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, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } + const render = await applyGameDataRender(database, 'weapon', weaponId, name, renderMutation, operator.id); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, render, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } }); return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte index 673f73f..499e52e 100644 --- a/src/routes/isac/weapons/+page.svelte +++ b/src/routes/isac/weapons/+page.svelte @@ -8,6 +8,7 @@ import GameIcon from '$lib/components/GameIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte'; import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses'; import { excludedTierZeroWeaponModSlot, @@ -23,7 +24,7 @@ const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true })); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 }); - const 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: '', active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ 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: '', active: 1, renderUrl: '', tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); let editing = $state(null); let bonusEditing = $state(null); let handledResultToken = $state(''); @@ -146,6 +147,7 @@
+ {#if Number(editing.is_exotic)}
DEDICATED EXOTIC TALENTSELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY
{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}
@@ -217,7 +219,7 @@
{#each data.weapons as weapon} -
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weaponState(weapon)}{#if weapon.pending}{:else}{/if}
+
{#if weapon.renderUrl}{/if}
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weaponState(weapon)}{#if weapon.pending}{:else}{/if}
{#if editing?.id === weapon.id} {@render weaponEditor()}