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
+36
View File
@@ -684,6 +684,42 @@ CREATE TABLE IF NOT EXISTS weapon_mod_effect_quality_values (
value REAL NOT NULL,
PRIMARY KEY(effect_id,quality)
);
CREATE TABLE IF NOT EXISTS gear_mods (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS gear_mods_name_nocase_idx ON gear_mods(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
primary_level_1_min REAL NOT NULL,
primary_level_1_max REAL NOT NULL,
primary_level_20_min REAL NOT NULL,
primary_level_20_max REAL NOT NULL,
PRIMARY KEY(gear_mod_id,quality),
CHECK(primary_level_1_max>=primary_level_1_min),
CHECK(primary_level_20_max>=primary_level_20_min),
CHECK(primary_level_20_min>=primary_level_1_min),
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(gear_mod_id,stat_attribute_id)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
gear_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
);
CREATE TABLE IF NOT EXISTS game_data_icons (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
entity_id TEXT NOT NULL,
+14
View File
@@ -1208,3 +1208,17 @@ its own unique quality: reveal only the required exotic-weapon assignment,
remove weapon-category and regular-quality controls, and replace random ranges
with fixed level-1 and level-20 attribute values. Keep an explicit Exotic
Quality state panel visible so the change in data semantics is unmistakable.
### Gear Mod registry
Gear Mods reuse the compact quality matrix, level preview, and pooled
secondary-attribute rows established by Weapon Mods. Do not render exotic,
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
Present the five primary options as an always-visible informational band
because the actual primary is selected on the build instance, not persisted on
the canonical definition.
Use explicit textual tier capacity wherever Gear tier is summarized: `1 MOD
SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity band
and preview controls stack, while repeated quality data may scroll rather than
compressing numeric controls below usable sizes.
+20
View File
@@ -261,6 +261,26 @@ availability is not tied to a numbered slot in canonical data. Mod Effects are
triggerless, hold one or two attribute references, and store fixed per-quality
values in `weapon_mod_effect_quality_values`.
### Gear Mods and tier slot capacity
Gear Mod definitions are standard-quality records only; there is no exotic
Gear Mod branch. Every definition supplies primary roll ranges for Standard,
Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
those endpoints scale linearly. A build instance selects one primary attribute
from Firepower, Toughness, Engineering, Health, or Armor.
The two secondary slots draw distinct attributes from one shared option pool.
Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration:
T0 gear has one Gear Mod slot, while T1 and T2 gear have two.
Canonical data is stored in `gear_mods`, `gear_mod_quality_values`,
`gear_mod_secondary_options`, and `gear_mod_secondary_quality_values`.
Successful mutations use `gear_mod.created`, `gear_mod.updated`,
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
submissions use the `gearmods` proposal module and remain non-canonical until
approved by Oversight.
# Skill Chip quality scaling
Skill Chip sets support Superior and High-end chip instances. Each set owns one
+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>