feat: add gear and weapon renders
This commit is contained in:
@@ -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”;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||
|
||||
let {
|
||||
currentUrl = '',
|
||||
label = 'ITEM RENDER',
|
||||
resetToken = 0
|
||||
}: { currentUrl?: string | null; label?: string; resetToken?: string | number } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="game-data-render-upload">
|
||||
<legend>{label}</legend>
|
||||
<ImageUploadInput name="render" accept="image/png,image/webp,image/avif,image/jpeg" {currentUrl} {resetToken} emptyLabel="NO RENDER ASSIGNED" browseLabel={currentUrl ? 'REPLACE RENDER' : 'ADD RENDER'}/>
|
||||
{#if currentUrl}<label class="render-remove"><input name="removeRender" type="checkbox" value="1"/><span>REMOVE CURRENT RENDER</span></label>{/if}
|
||||
<small>OPTIONAL · PNG, WEBP, AVIF, OR JPEG · CENTER-CROPPED TO 1000 × 1000 WEBP · NO BACKGROUND REMOVAL</small>
|
||||
</fieldset>
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<Editor | null>(null);
|
||||
let augmentEditing = $state<any | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
@@ -71,13 +72,14 @@
|
||||
{#if editing}
|
||||
<div class="dashboard-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save">
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id} />
|
||||
<div class="gear-identity">
|
||||
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
|
||||
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} required/>
|
||||
</div>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="GEAR RENDER"/>
|
||||
<div class="gear-ranges">
|
||||
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
|
||||
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
|
||||
@@ -142,7 +144,7 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row gear-row table-heading"><SortableColumnHeader label="GEAR" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="TIER" column="tier" sort={data.sort}/><SortableColumnHeader label="BASE RANGES" column="ranges" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="BRAND SETS" column="brands" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.gear as item}
|
||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}><ItemIcon src={brand.iconUrl} alt={brand.name}/></span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`gear-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}><ItemIcon src={brand.iconUrl} alt={brand.name}/></span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`gear-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}<RegistryInlineEditor id={`gear-editor-${item.id}`} label={`Edit ${item.name}`}>{@render gearEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<any | null>(null);
|
||||
let bonusEditing = $state<any | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
@@ -146,6 +147,7 @@
|
||||
<SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/>
|
||||
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
|
||||
</div>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="WEAPON RENDER"/>
|
||||
{#if Number(editing.is_exotic)}
|
||||
<section class="weapon-exotic-talent">
|
||||
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>SELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY</small></div><b>{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}</b></div>
|
||||
@@ -217,7 +219,7 @@
|
||||
<div class="dashboard-table weapon-dashboard-table">
|
||||
<div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.weapons as weapon}
|
||||
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class:config-required={weaponState(weapon) === 'CONFIG REQUIRED'} class="state-dot">{weaponState(weapon)}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div class="registry-render-identity">{#if weapon.renderUrl}<img class="registry-render" src={weapon.renderUrl} alt=""/>{/if}<div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class:config-required={weaponState(weapon) === 'CONFIG REQUIRED'} class="state-dot">{weaponState(weapon)}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === weapon.id}
|
||||
<RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}>
|
||||
{@render weaponEditor()}
|
||||
|
||||
Reference in New Issue
Block a user