feat(isac): add gear mod data management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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 HEALTH</b>
|
||||
<b>AR 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>
|
||||
Reference in New Issue
Block a user