feat(isac): add gear mod data management

This commit is contained in:
JB
2026-07-25 23:40:55 -04:00
parent f0c14d0c9c
commit c24a959e1d
12 changed files with 456 additions and 5 deletions
+1
View File
@@ -195,4 +195,5 @@
.weapon-mod-primary-types{min-height:58px;display:grid;align-content:center;gap:5px;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-primary-types>span{color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:.8px}.weapon-mod-primary-types>div{display:flex;gap:8px}.weapon-mod-primary-types b{display:flex;align-items:center;gap:3px;color:#aab0b1;font-size:6px}.weapon-mod-primary-types .game-icon{width:18px;height:18px}.weapon-mod-primary-types small{color:#596164;font-size:5px}.weapon-mod-compatibility{margin-top:10px}.weapon-mod-quality-row{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-quality-row.heading{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-preview-values{grid-template-columns:1.3fr .7fr}.weapon-mod-preview-values .core-icon-stack{grid-row:1/3}.weapon-mod-preview-values .core-icon-stack .game-icon{grid-row:auto;width:22px;height:22px}.weapon-mod-secondary-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--quality-specialized);background:#0d1011}.weapon-mod-secondary-options{display:grid;gap:6px;margin-top:7px}.weapon-mod-secondary-options>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(150px,.65fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-secondary-options>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:8px}.weapon-mod-secondary-options label{display:grid;gap:4px}.weapon-mod-secondary-options label>span{font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-secondary-options label>div{display:grid;grid-template-columns:1fr 12px 1fr;align-items:center}.weapon-mod-secondary-options input{height:32px;margin:0;padding:0 5px}.weapon-mod-secondary-options i{color:#596164;font-size:6px;font-style:normal;text-align:center}.weapon-mod-secondary-options>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-secondary-options>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:1100px){.weapon-mod-quality-row,.weapon-mod-quality-row.heading{min-width:580px}.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-secondary-options label:nth-of-type(3),.weapon-mod-secondary-options label:nth-of-type(4){grid-column:auto}}@media(max-width:700px){.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr}.weapon-mod-secondary-options label,.weapon-mod-secondary-options>div>button{grid-column:2}}
.weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:7px;letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:5px;letter-spacing:.35px}
.weapon-mod-exotic-toggle>span{width:44px;min-height:42px;padding:0}.weapon-mod-exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.weapon-mod-exotic-toggle .game-icon{width:30px;height:30px}.weapon-mod-compatibility .segmented-choice>div{height:48px}.weapon-mod-compatibility .segmented-choice .game-icon{width:34px;height:34px}.weapon-mod-exotic-section{margin-top:10px;padding:9px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#0d1011}.weapon-mod-exotic-attributes{display:grid;gap:6px;margin-top:7px}.weapon-mod-exotic-attributes>div{display:grid;grid-template-columns:30px minmax(240px,1fr) 150px 150px 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-exotic-attributes>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--quality-high-end);font-size:8px}.weapon-mod-exotic-attributes label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-exotic-attributes input{height:32px;margin:0;padding:0 6px}.weapon-mod-exotic-attributes>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-exotic-attributes>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:700px){.weapon-mod-exotic-attributes>div{grid-template-columns:30px 1fr}.weapon-mod-exotic-attributes label,.weapon-mod-exotic-attributes>div>button{grid-column:2}.weapon-mod-compatibility .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.weapon-mod-compatibility .segmented-choice span{min-height:50px}}
.gear-mod-identity{display:grid;grid-template-columns:minmax(320px,1fr) minmax(560px,1.5fr);align-items:end;gap:8px}.gear-mod-identity>.field{margin:0}.gear-mod-primary-types>div{flex-wrap:wrap}.gear-mod-row{min-width:1120px;grid-template-columns:1.2fr 1fr .9fr 1fr .55fr .55fr .3fr}.gear-mod-row>span{display:flex;align-items:center;gap:6px}.gear-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:900px){.gear-mod-identity{grid-template-columns:1fr}}@media(max-width:700px){.gear-mod-primary-types>div{display:grid;grid-template-columns:1fr 1fr}}
+1 -1
View File
@@ -27,7 +27,7 @@ export async function recordIsacAudit(input: {
}
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'gear_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
+2 -1
View File
@@ -5,7 +5,7 @@ import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'] as const;
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
export type DataModule = typeof DATA_MODULES[number];
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
@@ -18,6 +18,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
talents: {
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
+1
View File
@@ -17,6 +17,7 @@
{ href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
{ href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
{ href: '/isac/weaponmods', label: 'Weapon Mods', code: 'WMOD', module: 'weaponmods' },
{ href: '/isac/gearmods', label: 'Gear Mods', code: 'GMOD', module: 'gearmods' },
{ href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
{ href: '/isac/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
];
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
+1 -1
View File
@@ -14,7 +14,7 @@
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
);
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value.toUpperCase();
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value === 'gearmods' ? 'GEAR MODS' : value.toUpperCase();
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
const openReview = (proposal: any) => {
reviewing = proposal;
+1 -1
View File
@@ -135,6 +135,6 @@
<div class="dashboard-table">
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><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}><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}</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}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</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 onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><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}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</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 onclick={() => edit(item)}>EDIT</button>{/if}</div>
{: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>
+192
View File
@@ -0,0 +1,192 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
type QualityValue = {
quality: string;
primaryLevel1Min: number;
primaryLevel1Max: number;
primaryLevel20Min: number;
primaryLevel20Max: number;
};
type SecondaryRange = { statId: string; values: Record<string, { min: number; max: number }> };
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; }
}
async function snapshot(id: string) {
const database = await getDatabase();
return {
record: await auditRecord('gear_mods', id),
qualityValues: await (await database.prepare('SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality')).all(id),
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id')).all(id),
secondaryQualityValues: await (await database.prepare('SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality')).all(id)
};
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
const { search, pattern } = getRegistrySearch(url);
const conditions = [
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)).get(...args),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }]),
getPendingEntities(user.id, [{ module: 'gearmods', action: 'save', kind: 'gearmod', fields: ['qualityValues', 'secondaryRanges'] }])
]);
const pendingRows = filter === 'archived' || filter === 'active' ? [] : pendingMods.filter((item) =>
!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length);
const pendingOnPage = pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear_mods ${where}
ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
]);
const parsedPending = pendingOnPage.map((item) => ({
...item,
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), [])
}));
return {
mods: [...parsedPending, ...mods.map((mod) => ({
...mod,
qualityValues: qualityRows.filter((row) => row.gear_mod_id === mod.id).map((row) => ({
quality: row.quality,
primaryLevel1Min: Number(row.primary_level_1_min),
primaryLevel1Max: Number(row.primary_level_1_max),
primaryLevel20Min: Number(row.primary_level_20_min),
primaryLevel20Max: Number(row.primary_level_20_max)
})),
secondaryRanges: secondaryOptions.filter((option) => option.gear_mod_id === mod.id).map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(secondaryValues
.filter((row) => row.gear_mod_id === mod.id && row.stat_attribute_id === option.stat_attribute_id)
.map((row) => [String(row.quality), { min: Number(row.min_value), max: Number(row.max_value) }]))
}))
}))],
stats: [...pendingStats, ...stats],
qualities,
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingMods.length,
pending: pendingMods.length
},
filter,
pagination,
search
};
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const qualityValues = parseJson<QualityValue[]>(form.get('qualityValues'), []);
const secondaryRanges = parseJson<SecondaryRange[]>(form.get('secondaryRanges'), []);
if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.' });
if (qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length ||
qualityValues.some((row) => !qualities.includes(row.quality as typeof qualities[number]) ||
[row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max].some((value) => !Number.isFinite(Number(value))) ||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max))) {
return fail(400, { gearModError: 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.' });
}
if (secondaryRanges.length < 2 || secondaryRanges.length > 50 ||
new Set(secondaryRanges.map((range) => range.statId)).size !== secondaryRanges.length ||
secondaryRanges.some((range) => !range.statId || qualities.some((quality) => {
const values = range.values?.[quality];
return !values || !Number.isFinite(Number(values.min)) || !Number.isFinite(Number(values.max)) ||
Number(values.max) < Number(values.min);
}))) {
return fail(400, { gearModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { gearModError: 'That Gear Mod name is already registered.' });
}
const statIds = secondaryRanges.map((range) => range.statId);
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { gearModError: 'One or more selected attributes are unavailable.' });
const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await snapshot(id) : null;
if (id && !before?.record) return fail(404, { gearModError: 'Gear Mod not found.' });
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id) await (await database.prepare('UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
else await (await database.prepare('INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(modId, name, now, now);
await (await database.prepare('DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?')).run(modId);
for (const row of qualityValues) await (await database.prepare(`INSERT INTO gear_mod_quality_values
(gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max)
VALUES(?,?,?,?,?,?)`)).run(modId,row.quality,row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max);
await (await database.prepare('DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?')).run(modId);
for (const range of secondaryRanges) {
await (await database.prepare('INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId, range.statId);
for (const quality of qualities) await (await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`))
.run(modId, range.statId, quality, range.values[quality].min, range.values[quality].max);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'gear_mod.updated' : 'gear_mod.created',
entityId: modId,
before,
after: await snapshot(modId)
});
return { gearModSaved: true, gearModMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.archived', entityId: id, before, after: await snapshot(id) });
return { gearModArchived: true };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.unarchived', entityId: id, before, after: await snapshot(id) });
return { gearModUnarchived: true };
}
};
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import GameIcon from '$lib/components/GameIcon.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const qualityRow = (quality: string) => ({
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
});
const blankSecondary = () => ({
statId: '',
values: Object.fromEntries(data.qualities.map((quality: string) => [quality, { min: 0, max: 0 }]))
});
const blank = () => ({
id: '',
name: '',
active: 1,
qualityValues: data.qualities.map(qualityRow),
secondaryRanges: [blankSecondary(), blankSecondary()]
});
let editing = $state<any | null>(null);
let levelPreview = $state(1);
let qualityPreview = $state('High-end');
let handledResultToken = $state('');
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
const display = (value: unknown) => Number.isFinite(Number(value))
? (Number.isInteger(Number(value)) ? String(Number(value)) : Number(value).toFixed(2).replace(/\.?0+$/,''))
: '—';
const edit = (item: any) => {
editing = {
...item,
qualityValues: data.qualities.map((quality: string) => ({
...qualityRow(quality),
...(item.qualityValues.find((row: any) => row.quality === quality) ?? {})
})),
secondaryRanges: item.secondaryRanges.length
? item.secondaryRanges.map((range: any) => ({ statId: range.statId, values: structuredClone(range.values) }))
: [blankSecondary(), blankSecondary()]
};
levelPreview = 1;
qualityPreview = 'High-end';
};
const addSecondary = () => {
if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges, blankSecondary()];
};
const removeSecondary = (index: number) => {
if (editing.secondaryRanges.length > 2) {
editing.secondaryRanges = editing.secondaryRanges.filter((_: any, itemIndex: number) => itemIndex !== index);
}
};
$effect(() => {
if (form?.gearModSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.gearModMode === 'add' ? blank() : null;
levelPreview = 1;
qualityPreview = 'High-end';
}
});
</script>
<svelte:head><title>Gear Mods — ISAC</title></svelte:head>
<div class="module-toolbar">
<div>
<p class="eyebrow">EQUIPMENT MODIFICATION REGISTRY</p>
<h2>GEAR MODS</h2>
<p>Configure quality-scaled primary rolls and shared secondary attribute pools.</p>
</div>
<button class="primary" onclick={() => editing = blank()}>ADD GEAR MOD <span>+</span></button>
</div>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all Gear Mod definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to the builder'},
{value:'pending',label:'PENDING',count:data.summary.pending,description:'Awaiting Oversight review'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/>
{#if editing}
<div class="dashboard-editor weapon-mod-editor gear-mod-editor">
<div class="editor-title">
<div><p class="eyebrow">{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}</p><h3>{editing.id ? editing.name : 'GEAR MOD DEFINITION'}</h3></div>
<button aria-label="Close editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
<div class="gear-mod-identity">
<label class="field">GEAR MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
<div class="weapon-mod-primary-types gear-mod-primary-types">
<span>PRIMARY ATTRIBUTE OPTIONS</span>
<div>
<b><GameIcon name="firepower" decorative/>FIREPOWER</b>
<b><GameIcon name="toughness" decorative/>TOUGHNESS</b>
<b><GameIcon name="engineering" decorative/>ENGINEERING</b>
<b>HP&nbsp; HEALTH</b>
<b>AR&nbsp; ARMOR</b>
</div>
<small>One primary attribute is selected while configuring the mod in a build.</small>
</div>
</div>
<section class="weapon-mod-quality-section">
<div class="selection-head">
<div><span>QUALITY & LEVEL SCALING</span><small>PRIMARY ROLLS SCALE LINEARLY FROM LEVEL 1 TO 20</small></div>
<b>4 QUALITY PROFILES</b>
</div>
<div class="weapon-mod-quality-table">
<div class="weapon-mod-quality-row heading"><span>QUALITY</span><span>PRIMARY · LEVEL 1</span><span>PRIMARY · LEVEL 20</span></div>
{#each editing.qualityValues as row}
<div class="weapon-mod-quality-row">
<strong class={qualityClass(row.quality)}>{row.quality.toUpperCase()}</strong>
<div><input type="number" step="any" min="0" bind:value={row.primaryLevel1Min} aria-label={`${row.quality} primary Level 1 minimum`} required/><i>–</i><input type="number" step="any" min="0" bind:value={row.primaryLevel1Max} aria-label={`${row.quality} primary Level 1 maximum`} required/></div>
<div><input type="number" step="any" min="0" bind:value={row.primaryLevel20Min} aria-label={`${row.quality} primary Level 20 minimum`} required/><i>–</i><input type="number" step="any" min="0" bind:value={row.primaryLevel20Max} aria-label={`${row.quality} primary Level 20 maximum`} required/></div>
</div>
{/each}
</div>
<div class="weapon-mod-preview">
<div>
<label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label>
<SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/>
</div>
{#if previewRow}
<div class="weapon-mod-preview-values">
<span><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span>
<span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span>
</div>
{/if}
</div>
</section>
<section class="weapon-mod-secondary-section">
<div class="selection-head">
<div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div>
<button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length >= 50}>ADD ATTRIBUTE +</button>
</div>
<div class="weapon-mod-secondary-options">
{#each editing.secondaryRanges as range,index}
<div>
<strong>{String(index + 1).padStart(2,'0')}</strong>
<SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/>
{#each data.qualities as quality}
<label>
<span class={qualityClass(quality)}>{quality.toUpperCase()}</span>
<div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div>
</label>
{/each}
<button type="button" aria-label={`Remove secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={editing.secondaryRanges.length <= 2}>×</button>
</div>
{/each}
</div>
</section>
{#if form?.gearModError}<p class="error">{form.gearModError}</p>{/if}
<div class="form-actions split-actions">
<div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE GEAR MOD' : 'UNARCHIVE GEAR MOD'}</button>{/if}</div>
<div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE GEAR MOD →</button></div>
</div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row gear-mod-row table-heading"><span>GEAR MOD</span><span>PRIMARY</span><span>SECONDARIES</span><span>QUALITY</span><span>LEVELS</span><span>STATE</span><span></span></div>
{#each data.mods as item}
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}>
<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div>
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
<span>STANDARD → HIGH-END</span>
<span>01 → 20</span>
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}
</div>
{:else}
<div class="empty-dashboard"><strong>NO GEAR MODS REGISTERED</strong><span>Add the first quality-scaled Gear Mod definition.</span></div>
{/each}
</div>