feat(isac): add game data icon management

This commit is contained in:
JB
2026-07-25 12:38:40 -04:00
parent 9f712e7790
commit 7a12b1d740
16 changed files with 323 additions and 39 deletions
+24 -6
View File
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
const thresholds = [2, 3, 4] as const;
@@ -21,14 +22,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : '';
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get();
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [brands, effects, stats, effectSummary] = await Promise.all([
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
(await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get()
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
]);
return {
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
brands: brands.map((brand) => {
const icon = icons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
}),
stats,
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
pagination,
@@ -40,6 +45,9 @@ export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let iconMutation;
try { iconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { brandError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
@@ -55,7 +63,7 @@ export const actions: Actions = {
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
const brandId = id || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('brand_sets', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
for (const effect of effects) {
@@ -63,7 +71,8 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), effects } });
await applyIconMutation(database, 'brand_set', brandId, iconMutation);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), icon: await gameDataIconMetadata(database, 'brand_set', brandId), effects } });
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
@@ -72,6 +81,15 @@ export const actions: Actions = {
const before = await auditRecord('brand_sets', id);
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
return { brandArchived: true };
return { brandArchived: true, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('brand_sets', id);
if (!before) return fail(404, { brandError: 'Brand set not found.' });
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.unarchived', entityId: id, before, after: await auditRecord('brand_sets', id) });
return { brandUnarchived: true, resultToken: randomUUID() };
}
};
+24 -13
View File
@@ -2,17 +2,23 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
let { data, form } = $props();
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
type Editor = { id: string; name: string; effects: Effect[] };
const blank = (): Editor => ({ id: '', name: '', effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] };
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
let editing = $state<Editor | null>(null);
let handledResultToken = $state('');
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
$effect(() => {
if ((form?.brandArchived || form?.brandUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = null;
return;
}
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.brandMode === 'add' ? blank() : null;
@@ -24,19 +30,24 @@
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Unavailable definitions'},{value:'complete',label:'COMPLETE',count:data.summary.complete,description:'All three bonuses mapped'}]}/>
{#if editing}
<div class="dashboard-editor">
<div class="dashboard-editor brand-set-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id} />
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
{#each [2, 3, 4] as pieces}
{@const effect = effectFor(editing, pieces)}
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><label class="field">ATTRIBUTE<select name={`stat${pieces}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit})</option>{/each}</select></label><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
{/each}
<div class="brand-editor-body">
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON"/>
<div>
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
{#each [2, 3, 4] as pieces}
{@const effect = effectFor(editing, pieces)}
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><label class="field">ATTRIBUTE<select name={`stat${pieces}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit})</option>{/each}</select></label><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
{/each}
</div>
</div>
</div>
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE SET</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}{#if editing.active === 1}<button class="moderation-action danger brand-archive-action" formaction="?/archive" name="id" value={editing.id}><b>▰</b> ARCHIVE SET</button>{:else}<button class="moderation-action restore brand-archive-action" formaction="?/unarchive" name="id" value={editing.id}><b>↥</b> UNARCHIVE SET</button>{/if}{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
</PendingForm>
</div>
{/if}
@@ -44,7 +55,7 @@
<div class="dashboard-table">
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
{#each data.brands as brand}
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div class="registry-icon-identity">{#if brand.iconUrl}<img src={brand.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
{:else}
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
{/each}
+10 -2
View File
@@ -6,6 +6,7 @@ import { getPagination } from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
@@ -30,6 +31,7 @@ async function protocolSnapshot(id: string) {
const talent = await (await database.prepare('SELECT * FROM os_protocol_talents WHERE protocol_id=?')).get(id);
return {
record: await auditRecord('os_protocols', id),
icon: await gameDataIconMetadata(database, 'os_protocol', id),
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
talent: talent ?? null,
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
@@ -73,7 +75,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers] = await Promise.all([
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(),
@@ -82,7 +84,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM os_protocol_talent_triggers')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all()
]);
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
return {
@@ -90,6 +93,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const talent = talents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === protocol.id); return icon ? gameDataIconUrl('os_protocol', String(protocol.id), icon.updated_at) : ''; })(),
primaryEffect: primaryEffects.find((item) => item.protocol_id === protocol.id) ?? null,
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null,
talent: talent ? {
@@ -133,6 +137,9 @@ export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let iconMutation;
try { iconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { protocolError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const quality = String(form.get('quality') ?? '');
@@ -177,6 +184,7 @@ export const actions: Actions = {
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder);
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
+5 -4
View File
@@ -5,6 +5,7 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
@@ -17,7 +18,7 @@
Health: 'Health'
});
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] });
const blank = () => ({ id: '', name: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
@@ -124,11 +125,11 @@
{#if editing}
<div class="dashboard-editor protocol-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
<div class="protocol-identity-grid">
<label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON"/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
@@ -160,6 +161,6 @@
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div class="registry-icon-identity">{#if protocol.iconUrl}<img src={protocol.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
+11 -4
View File
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
type SharedEffect = { statId: string; valueI: number; valueII: number };
@@ -21,7 +22,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : '';
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get();
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners] = await Promise.all([
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
@@ -31,7 +32,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(),
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all(),
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all()
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(),
(await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
@@ -47,6 +49,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return {
talents: talents.map((talent) => ({
...talent,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
category: talent.category === 'Vest'
? 'Body Armor'
: talent.category === 'Weapon' && weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope === 'Exotic'
@@ -129,6 +132,9 @@ export const actions: Actions = {
saveTalent: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let iconMutation;
try { iconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; }
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
@@ -147,7 +153,7 @@ export const actions: Actions = {
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId = id || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('talents', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
if (storedCategory === 'Weapon') {
@@ -174,7 +180,8 @@ export const actions: Actions = {
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
await applyIconMutation(db, 'talent', talentId, iconMutation);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
};
+5 -4
View File
@@ -2,11 +2,12 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
let { data, form } = $props();
type Effect = { statId: string; valueI: string; valueII: string };
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
let handledResultToken = $state('');
const blankTalent = () => ({ id: '', name: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
const one = tier('I'), two = tier('II');
@@ -66,9 +67,9 @@
{#if talent}
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent">
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
<div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div>
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON"/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div></div>
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<p class="label">SHARED ACTIVATION TRIGGERS</p>
@@ -82,4 +83,4 @@
{/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
+11 -3
View File
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
@@ -34,7 +35,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects] = await Promise.all([
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
@@ -57,7 +58,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all()
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
return {
weapons: weapons.map((weapon) => {
@@ -78,6 +80,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
triggerIds: [...new Set([
@@ -117,6 +120,9 @@ export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let exoticIconMutation;
try { exoticIconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { weaponError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? '');
@@ -253,6 +259,7 @@ export const actions: Actions = {
baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId),
@@ -285,6 +292,7 @@ export const actions: Actions = {
for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation);
}
if (id) {
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
@@ -322,7 +330,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
+4 -3
View File
@@ -5,13 +5,14 @@
import KpiFilters from '$lib/components/KpiFilters.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
let { data, form } = $props();
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 });
const blankExoticTalent = () => ({ id: '', name: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: 0, damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let handledResultToken = $state('');
@@ -78,7 +79,7 @@
{#if editing}
<div class="dashboard-editor weapon-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
@@ -92,7 +93,7 @@
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
<section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON"/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div></div>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
<div class="weapon-exotic-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>
@@ -0,0 +1,17 @@
import { getDatabase } from '$lib/server/database';
import { GAME_ICON_TYPES, readGameDataIcon } from '$lib/server/game-data-icons';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
if (!GAME_ICON_TYPES.includes(params.type as (typeof GAME_ICON_TYPES)[number])) error(404, 'Icon not found');
const image = await readGameDataIcon(await getDatabase(), params.type, params.id);
if (!image) error(404, 'Icon not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
};