feat: add gear and weapon renders
Validate and deploy production / validate (push) Successful in 21s
Validate and deploy staging / validate (push) Successful in 25s
Validate and deploy production / deploy (push) Successful in 1m17s
Validate and deploy staging / deploy (push) Successful in 35s

This commit is contained in:
jb
2026-07-31 22:39:00 -04:00
parent 3acc08c81d
commit aaf9e0ce97
10 changed files with 171 additions and 25 deletions
+2
View File
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>
+16 -1
View File
@@ -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);
});
});
+70 -7
View File
@@ -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) {
+19 -5
View File
@@ -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 }) => {
+6 -4
View File
@@ -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>
+20 -5
View File
@@ -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 }) => {
+4 -2
View File
@@ -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()}