diff --git a/database/schema.sql b/database/schema.sql index 379194a..1e9326a 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 0ad4425..569e61e 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index f910862..a8e0568 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/src/app.css b/src/app.css index 0838ea9..0aed3bc 100644 --- a/src/app.css +++ b/src/app.css @@ -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}} diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 0e49bce..25bbe57 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -27,7 +27,7 @@ export async function recordIsacAudit(input: { } export async function auditRecord(table: string, id: string): Promise | 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(); diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index 8b7b2bf..c1b72ba 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -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>> = { @@ -18,6 +18,7 @@ const proposalResultKeys: Partial 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; diff --git a/src/routes/isac/gear/+page.svelte b/src/routes/isac/gear/+page.svelte index f5135a5..40a1bcb 100644 --- a/src/routes/isac/gear/+page.svelte +++ b/src/routes/isac/gear/+page.svelte @@ -135,6 +135,6 @@
GEARSLOTTIERBASE RANGESBRAND SETSSTATE
{#each data.gear as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/isac/gearmods/+page.server.ts b/src/routes/isac/gearmods/+page.server.ts new file mode 100644 index 0000000..73018ef --- /dev/null +++ b/src/routes/isac/gearmods/+page.server.ts @@ -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 }; + +function parseJson(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(form.get('qualityValues'), []); + const secondaryRanges = parseJson(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 }; + } +}; diff --git a/src/routes/isac/gearmods/+page.svelte b/src/routes/isac/gearmods/+page.svelte new file mode 100644 index 0000000..ab44124 --- /dev/null +++ b/src/routes/isac/gearmods/+page.svelte @@ -0,0 +1,186 @@ + + +Gear Mods — ISAC + +
+
+

EQUIPMENT MODIFICATION REGISTRY

+

GEAR MODS

+

Configure quality-scaled primary rolls and shared secondary attribute pools.

+
+ +
+ + + +{#if editing} +
+
+

{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}

{editing.id ? editing.name : 'GEAR MOD DEFINITION'}

+ +
+ + + + +
+ +
+ PRIMARY ATTRIBUTE OPTIONS +
+ FIREPOWER + TOUGHNESS + ENGINEERING + HP  HEALTH + AR  ARMOR +
+ One primary attribute is selected while configuring the mod in a build. +
+
+ +
+
+
QUALITY & LEVEL SCALINGPRIMARY ROLLS SCALE LINEARLY FROM LEVEL 1 TO 20
+ 4 QUALITY PROFILES +
+
+
QUALITYPRIMARY · LEVEL 1PRIMARY · LEVEL 20
+ {#each editing.qualityValues as row} +
+ {row.quality.toUpperCase()} +
–
+
–
+
+ {/each} +
+
+
+ + ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/> +
+ {#if previewRow} +
+ SELECTED PRIMARY · LEVEL {levelPreview}{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))} + SECONDARY ATTRIBUTE POOL{editing.secondaryRanges.length} OPTIONS +
+ {/if} +
+
+ +
+
+
SECONDARY ATTRIBUTE POOLTHE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY
+ +
+
+ {#each editing.secondaryRanges as range,index} +
+ {String(index + 1).padStart(2,'0')} + itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/> + {#each data.qualities as quality} + + {/each} + +
+ {/each} +
+
+ + {#if form?.gearModError}

{form.gearModError}

{/if} +
+
{#if editing.id}{/if}
+
+
+
+
+{/if} + + +
+
GEAR MODPRIMARYSECONDARIESQUALITYLEVELSSTATE
+ {#each data.mods as item} +
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}
+ 5 OPTIONS + {item.secondaryRanges.length} POSSIBLE · SELECT 2 + STANDARD → HIGH-END + 01 → 20 + {item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'} + {#if item.pending}{:else}{/if} +
+ {:else} +
NO GEAR MODS REGISTEREDAdd the first quality-scaled Gear Mod definition.
+ {/each} +