feat(isac): add weapon mod data management

This commit is contained in:
JB
2026-07-25 23:25:25 -04:00
parent 7bb4b92965
commit f0c14d0c9c
14 changed files with 668 additions and 12 deletions
+74 -1
View File
@@ -221,7 +221,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS data_change_proposals ( CREATE TABLE IF NOT EXISTS data_change_proposals (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')), module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')),
action TEXT NOT NULL, action TEXT NOT NULL,
entity_id TEXT, entity_id TEXT,
draft_entity_id TEXT, draft_entity_id TEXT,
@@ -611,6 +611,79 @@ CREATE TABLE IF NOT EXISTS skill_chip_talent_effects (
UNIQUE(talent_id, stat_attribute_id), UNIQUE(talent_id, stat_attribute_id),
UNIQUE(talent_id, sort_order) UNIQUE(talent_id, sort_order)
); );
CREATE TABLE IF NOT EXISTS weapon_mods (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')),
exotic_weapon_id TEXT REFERENCES weapons(id),
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 weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS weapon_mod_compatible_categories (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
weapon_category TEXT NOT NULL CHECK(weapon_category IN ('shotgun','rifle','mmr','ar','lmg','smg')),
PRIMARY KEY(weapon_mod_id,weapon_category)
);
CREATE TABLE IF NOT EXISTS weapon_mod_slots (
weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE,
tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2),
slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')),
PRIMARY KEY(weapon_id,tier,slot_type)
);
CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_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(weapon_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 weapon_mod_secondary_options (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
weapon_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(weapon_mod_id,stat_attribute_id,quality),
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
);
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
id TEXT PRIMARY KEY,
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
level_1_value REAL NOT NULL,
level_20_value REAL NOT NULL,
sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 12),
UNIQUE(weapon_mod_id,stat_attribute_id),
UNIQUE(weapon_mod_id,sort_order)
);
CREATE TABLE IF NOT EXISTS weapon_mod_effects (
id TEXT PRIMARY KEY,
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 2),
UNIQUE(weapon_mod_id,stat_attribute_id),
UNIQUE(weapon_mod_id,sort_order)
);
CREATE TABLE IF NOT EXISTS weapon_mod_effect_quality_values (
effect_id TEXT NOT NULL REFERENCES weapon_mod_effects(id) ON DELETE CASCADE,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
value REAL NOT NULL,
PRIMARY KEY(effect_id,quality)
);
CREATE TABLE IF NOT EXISTS game_data_icons ( 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_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
entity_id TEXT NOT NULL, entity_id TEXT NOT NULL,
+37
View File
@@ -1171,3 +1171,40 @@ state in plain language. Show a share-page action only after a non-private
build has been saved. Public and unlisted build pages are read-only tactical build has been saved. Public and unlisted build pages are read-only tactical
summaries; owner editing returns to the builder. On narrow layouts, summaries; owner editing returns to the builder. On narrow layouts,
persistence controls and shared-loadout columns stack vertically. persistence controls and shared-loadout columns stack vertically.
### Tiered compatibility editors
When compatibility depends on two independent dimensions, keep those
dimensions explicit rather than presenting a flattened list. Weapon
definitions configure their available Weapon Mod slots per tier. T0 requires
four distinct slots; T1 and T2 require five. Weapon Mod definitions then
configure one slot plus one or more compatible weapon categories. The UI and
validation must require both dimensions to match.
Weapon Mod definitions use the shared segmented-choice control for the bounded
eight-slot vocabulary, with a full-width row on desktop and two columns on
narrow screens. Weapon category compatibility uses the same control in
checkbox mode with weapon icons and retained accessible names. Show configured
versus required slot counts in text. Disabled tiers remain visibly unavailable
and must not submit slot assignments.
Quality-scaled registry editors use a compact matrix when the same numeric
shape repeats across all four quality levels. Apply global quality colors only
to the quality labels, retain textual quality names, and keep numeric fields
neutral. On narrow screens the matrix may scroll horizontally while its
surrounding editor and previews stack.
When two item slots draw from the same canonical option pool, edit that pool
once rather than rendering two duplicated selectors. Weapon Mods use a
repeatable secondary-attribute row: one searchable attribute reference plus
four quality range pairs. Show that the builder selects two distinct options
from the pool. Primary core type is likewise builder-selected and is displayed
as the three always-available core icons rather than a persisted ISAC choice.
Exotic Weapon Mod status is a separate single-icon toggle using the exotic
icon and the standard pressed, focus-visible, and selected states. Selecting
it must switch the editor immediately, before a weapon is assigned. Exotic is
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.
+28
View File
@@ -233,6 +233,34 @@ talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
Server actions validate all relationships and append the full owned graph to Server actions validate all relationships and append the full owned graph to
the existing `game_data` audit history. the existing `game_data` audit history.
### Weapon Mods and tiered weapon slots
`weapon_mod_slots` records each weapon's available slot vocabulary per tier.
Canonical validation requires four unique slots for T0 and five for T1/T2 for
every tier at or above the weapon's native tier. Regular Weapon Mods declare a
single slot type and one or more compatible base weapon categories through
`weapon_mod_compatible_categories`; eligibility requires both a category match
and the slot to exist on the equipped weapon at its selected tier.
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
regular category compatibility, are considered permanently equipped, and may
only be leveled. They do not use quality profiles, random primary rolls,
secondary pools, or regular Mod Effects. Their canonical contributions are
fixed attribute references with explicit level-1 and level-20 values in
`weapon_mod_exotic_attributes`; intermediate values interpolate linearly.
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
builder-time primary choice. `weapon_mod_quality_values` stores the shared
level-1 and level-20 primary roll endpoints for each quality; primary values
interpolate linearly from levels 1–20.
`weapon_mod_secondary_options` is the shared pool from which a build selects
two distinct secondary attributes. Every option owns a minimum and maximum at
each quality through `weapon_mod_secondary_quality_values`; secondary
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`.
# Skill Chip quality scaling # Skill Chip quality scaling
Skill Chip sets support Superior and High-end chip instances. Each set owns one Skill Chip sets support Superior and High-end chip instances. Each set owns one
+82
View File
@@ -127,6 +127,40 @@ await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_cre
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
const weaponModProposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDefinition.sql.includes("'weaponmods'")) {
console.log('Adding Weapon Mods to moderated data modules.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec(`CREATE TABLE data_change_proposals_weaponmods (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
reviewed_by TEXT REFERENCES users(id),
review_note TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_weaponmods
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec('ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals');
await database.exec('PRAGMA foreign_keys=ON');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
}
const iconDefinition = await (await database.prepare( const iconDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
)).get(); )).get();
@@ -255,6 +289,54 @@ for (const [name, definition] of [
} }
} }
const weaponModColumns = await (await database.prepare("PRAGMA table_info('weapon_mods')")).all();
if (weaponModColumns.some((column) => column.name === 'primary_attribute_type')) {
console.log('Converting Weapon Mods to builder-selected primary types and pooled secondary attributes.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id)
SELECT id,secondary_stat_1_id FROM weapon_mods
UNION SELECT id,secondary_stat_2_id FROM weapon_mods`);
await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_quality_values(weapon_mod_id,stat_attribute_id,quality,min_value,max_value)
SELECT values_row.weapon_mod_id,mods.secondary_stat_1_id,values_row.quality,values_row.secondary_1_min,values_row.secondary_1_max
FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id
UNION ALL
SELECT values_row.weapon_mod_id,mods.secondary_stat_2_id,values_row.quality,values_row.secondary_2_min,values_row.secondary_2_max
FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id`);
await database.exec(`CREATE TABLE weapon_mod_quality_values_pooled (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_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(weapon_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)
)`);
await database.exec(`INSERT INTO weapon_mod_quality_values_pooled
SELECT weapon_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max
FROM weapon_mod_quality_values`);
await database.exec('DROP TABLE weapon_mod_quality_values');
await database.exec('ALTER TABLE weapon_mod_quality_values_pooled RENAME TO weapon_mod_quality_values');
await database.exec(`CREATE TABLE weapon_mods_pooled (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')),
exotic_weapon_id TEXT REFERENCES weapons(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO weapon_mods_pooled(id,name,slot_type,exotic_weapon_id,active,created_at,updated_at)
SELECT id,name,slot_type,exotic_weapon_id,active,created_at,updated_at FROM weapon_mods`);
await database.exec('DROP TABLE weapon_mods');
await database.exec('ALTER TABLE weapon_mods_pooled RENAME TO weapon_mods');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE)');
await database.exec('PRAGMA foreign_keys=ON');
}
const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all();
if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) { if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) {
console.log('Adding persistent builder loadout snapshots.'); console.log('Adding persistent builder loadout snapshots.');
+4
View File
File diff suppressed because one or more lines are too long
+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> { export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) { if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
throw new Error('Unsupported audit table'); throw new Error('Unsupported audit table');
} }
const database = await getDatabase(); 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 { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode'; import { getContributorMode } from '$lib/server/contributor-mode';
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const; export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'] as const;
export type DataModule = typeof DATA_MODULES[number]; export type DataModule = typeof DATA_MODULES[number];
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = { const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
@@ -17,6 +17,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
saveAugmentProfile: { saved: 'augmentProfileSaved' } saveAugmentProfile: { saved: 'augmentProfileSaved' }
}, },
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } }, weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
talents: { talents: {
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' }, saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
saveTalent: { saved: 'talentSaved', mode: 'talentMode' } saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
+1
View File
@@ -16,6 +16,7 @@
{ href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' }, { href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
{ href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' }, { href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
{ href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' }, { href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
{ href: '/isac/weaponmods', label: 'Weapon Mods', code: 'WMOD', module: 'weaponmods' },
{ href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' }, { href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
{ href: '/isac/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' } { 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'; import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed']; const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips']; const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'];
type SerializedEntry = type SerializedEntry =
| { name: string; kind: 'text'; value: string } | { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string }; | { name: string; kind: 'file'; value: string; fileName: string; type: string };
+2 -2
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 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 operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase(); const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value.toUpperCase();
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) }))); const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
const openReview = (proposal: any) => { const openReview = (proposal: any) => {
reviewing = proposal; reviewing = proposal;
@@ -24,7 +24,7 @@
})); }));
}; };
const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value }))); const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value })));
const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{'); const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses', 'qualityValues'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{');
$effect(() => { $effect(() => {
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) { if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
handledToken = form.moderationToken; handledToken = form.moderationToken;
+245
View File
@@ -0,0 +1,245 @@
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;
const slots = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const;
const weaponCategories = ['shotgun', 'rifle', 'mmr', 'ar', 'lmg', 'smg'] as const;
type QualityValue = {
quality: string;
primaryLevel1Min: number;
primaryLevel1Max: number;
primaryLevel20Min: number;
primaryLevel20Max: number;
};
type Effect = { statId: string; values: Record<string, number> };
type SecondaryRange = { statId: string; values: Record<string, { min: number; max: number }> };
type ExoticAttribute = { statId: string; level1Value: number; level20Value: 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();
const effects = await (await database.prepare('SELECT * FROM weapon_mod_effects WHERE weapon_mod_id=? ORDER BY sort_order')).all(id);
return {
record: await auditRecord('weapon_mods', id),
compatibleCategories: await (await database.prepare('SELECT weapon_category FROM weapon_mod_compatible_categories WHERE weapon_mod_id=? ORDER BY weapon_category')).all(id),
qualityValues: await (await database.prepare('SELECT * FROM weapon_mod_quality_values WHERE weapon_mod_id=? ORDER BY quality')).all(id),
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM weapon_mod_secondary_options WHERE weapon_mod_id=? ORDER BY stat_attribute_id')).all(id),
secondaryQualityValues: await (await database.prepare('SELECT * FROM weapon_mod_secondary_quality_values WHERE weapon_mod_id=? ORDER BY stat_attribute_id,quality')).all(id),
exoticAttributes: await (await database.prepare('SELECT * FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=? ORDER BY sort_order')).all(id),
effects: await Promise.all(effects.map(async (effect) => ({
...effect,
qualityValues: await (await database.prepare('SELECT quality,value FROM weapon_mod_effect_quality_values WHERE effect_id=? ORDER BY quality')).all(effect.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', 'archived', 'regular', 'exotic'].includes(requestedFilter) ? requestedFilter : '';
const { search, pattern } = getRegistrySearch(url);
const conditions = [
...(filter === 'active' ? ['mods.active=1'] : filter === 'archived' ? ['mods.active=0']
: filter === 'regular' ? ['mods.exotic_weapon_id IS NULL'] : filter === 'exotic' ? ['mods.exotic_weapon_id IS NOT NULL'] : []),
...(search ? ["mods.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const [summary, filtered, pendingStats, pendingWeapons, 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,
SUM(CASE WHEN exotic_weapon_id IS NULL THEN 1 ELSE 0 END) AS regular,
SUM(CASE WHEN exotic_weapon_id IS NOT NULL THEN 1 ELSE 0 END) AS exotic
FROM weapon_mods`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM weapon_mods mods ${where}`)).get(...args),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }]),
getPendingEntities(user.id, [{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'isExotic'] }]),
getPendingEntities(user.id, [{ module: 'weaponmods', action: 'save', kind: 'weaponmod', fields: [
'slotType', 'isExotic', 'exoticWeaponId', 'compatibleCategories', 'qualityValues', 'secondaryRanges', 'effects', 'exoticAttributes'
] }])
]);
const pendingRows = filter === 'archived' ? [] : pendingMods.filter((item) =>
(!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) &&
(filter !== 'regular' || !item.exoticWeaponId) && (filter !== 'exotic' || Boolean(item.exoticWeaponId))
);
const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length);
const [mods, compatibility, qualityRows, secondaryOptions, secondaryValues, exoticAttributes, effects, effectValues, stats, weapons] = await Promise.all([
(await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods
LEFT JOIN weapons ON weapons.id=mods.exotic_weapon_id ${where}
ORDER BY mods.active DESC,mods.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_mod_compatible_categories')).all(),
(await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(),
(await database.prepare('SELECT * FROM weapon_mod_secondary_options')).all(),
(await database.prepare('SELECT * FROM weapon_mod_secondary_quality_values')).all(),
(await database.prepare('SELECT * FROM weapon_mod_exotic_attributes ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM weapon_mod_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM weapon_mod_effect_quality_values')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all()
]);
const availableWeapons = [...pendingWeapons.map((item) => ({ ...item, is_exotic: Number(item.isExotic) })), ...weapons];
const parsedPending = pendingRows.map((item) => ({
...item,
slot_type: item.slotType,
exotic_weapon_id: item.exoticWeaponId,
exotic_weapon_name: availableWeapons.find((weapon) => weapon.id === item.exoticWeaponId)?.name ?? '',
compatibleCategories: Array.isArray(item.compatibleCategories) ? item.compatibleCategories : [item.compatibleCategories].filter(Boolean),
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), []),
exoticAttributes: parseJson(String(item.exoticAttributes), []),
effects: parseJson(String(item.effects), [])
}));
return {
mods: [...parsedPending, ...mods.map((mod) => ({
...mod,
compatibleCategories: compatibility.filter((link) => link.weapon_mod_id === mod.id).map((link) => String(link.weapon_category)),
qualityValues: qualityRows.filter((row) => row.weapon_mod_id === mod.id).map((row) => ({
quality: row.quality,
primaryLevel1Min: row.primary_level_1_min, primaryLevel1Max: row.primary_level_1_max,
primaryLevel20Min: row.primary_level_20_min, primaryLevel20Max: row.primary_level_20_max
})),
secondaryRanges: secondaryOptions.filter((option) => option.weapon_mod_id === mod.id).map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(secondaryValues.filter((row) => row.weapon_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) }]))
})),
exoticAttributes: exoticAttributes.filter((row) => row.weapon_mod_id === mod.id).map((row) => ({
statId: String(row.stat_attribute_id), level1Value: Number(row.level_1_value), level20Value: Number(row.level_20_value)
})),
effects: effects.filter((effect) => effect.weapon_mod_id === mod.id).map((effect) => ({
statId: String(effect.stat_attribute_id),
values: Object.fromEntries(effectValues.filter((value) => value.effect_id === effect.id).map((value) => [String(value.quality), Number(value.value)]))
}))
}))],
stats: [...pendingStats, ...stats],
weapons: availableWeapons,
slots: slots.map((name) => ({ id: name, name })),
qualities,
weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })),
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 slotType = String(form.get('slotType') ?? '');
const isExotic = form.get('isExotic') === '1';
const exoticWeaponId = String(form.get('exoticWeaponId') ?? '');
const compatibleCategories = [...new Set(form.getAll('compatibleCategories').map(String).filter(Boolean))];
const qualityValues = parseJson<QualityValue[]>(form.get('qualityValues'), []);
const secondaryRanges = parseJson<SecondaryRange[]>(form.get('secondaryRanges'), []);
const effects = parseJson<Effect[]>(form.get('effects'), []);
const exoticAttributes = parseJson<ExoticAttribute[]>(form.get('exoticAttributes'), []);
if (name.length < 2 || name.length > 100) return fail(400, { weaponModError: 'Weapon Mod names must be 2–100 characters.' });
if (!slots.includes(slotType as typeof slots[number])) return fail(400, { weaponModError: 'Choose a valid Weapon Mod slot.' });
if (isExotic && !exoticWeaponId) return fail(400, { weaponModError: 'Select the exotic weapon this unique mod belongs to.' });
if (!isExotic && exoticWeaponId) return fail(400, { weaponModError: 'Exotic weapon assignment is only available for Exotic Weapon Mods.' });
if (!isExotic && (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, { weaponModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' });
if (isExotic && compatibleCategories.length) return fail(400, { weaponModError: 'Exotic Weapon Mods are locked to one exotic weapon and cannot use regular category compatibility.' });
if (!isExotic && !compatibleCategories.length) return fail(400, { weaponModError: 'Assign at least one compatible weapon category.' });
if (compatibleCategories.some((category) => !weaponCategories.includes(category as typeof weaponCategories[number]))) return fail(400, { weaponModError: 'One or more weapon categories are invalid.' });
if (!isExotic && (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, { weaponModError: 'Configure valid primary level ranges for all four qualities.' });
}
if (!isExotic && (effects.length < 1 || effects.length > 2 || effects.some((effect) => !effect.statId ||
qualities.some((quality) => !Number.isFinite(Number(effect.values?.[quality])))) ||
new Set(effects.map((effect) => effect.statId)).size !== effects.length)) {
return fail(400, { weaponModError: 'Configure one or two unique Mod Effect attributes with a value for every quality.' });
}
if (isExotic && (exoticAttributes.length < 1 || exoticAttributes.length > 12 ||
new Set(exoticAttributes.map((attribute) => attribute.statId)).size !== exoticAttributes.length ||
exoticAttributes.some((attribute) => !attribute.statId || !Number.isFinite(Number(attribute.level1Value)) ||
!Number.isFinite(Number(attribute.level20Value)) || Number(attribute.level20Value) < Number(attribute.level1Value)))) {
return fail(400, { weaponModError: 'Configure one or more unique exotic attributes with fixed Level 1 and Level 20 values.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM weapon_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { weaponModError: 'That Weapon Mod name is already registered.' });
const statIds = [...new Set(isExotic
? exoticAttributes.map((attribute) => attribute.statId)
: [...secondaryRanges.map((range) => range.statId), ...effects.map((effect) => effect.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, { weaponModError: 'One or more selected attributes are unavailable.' });
if (isExotic) {
const exotic = await (await database.prepare('SELECT id FROM weapons WHERE id=? AND active=1 AND is_exotic=1')).get(exoticWeaponId);
if (!exotic) return fail(400, { weaponModError: 'Choose an active exotic weapon for this locked mod.' });
}
const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await snapshot(id) : null;
if (id && !before?.record) return fail(404, { weaponModError: 'Weapon Mod not found.' });
const now = new Date().toISOString();
if (id) await (await database.prepare(`UPDATE weapon_mods SET name=?,slot_type=?,
exotic_weapon_id=?,active=1,updated_at=? WHERE id=?`))
.run(name, slotType, exoticWeaponId || null, now, id);
else await (await database.prepare(`INSERT INTO weapon_mods
(id,name,slot_type,exotic_weapon_id,created_at,updated_at)
VALUES(?,?,?,?,?,?)`)).run(modId, name, slotType, exoticWeaponId || null, now, now);
await (await database.prepare('DELETE FROM weapon_mod_compatible_categories WHERE weapon_mod_id=?')).run(modId);
for (const category of compatibleCategories) await (await database.prepare('INSERT INTO weapon_mod_compatible_categories(weapon_mod_id,weapon_category) VALUES(?,?)')).run(modId, category);
await (await database.prepare('DELETE FROM weapon_mod_quality_values WHERE weapon_mod_id=?')).run(modId);
for (const row of isExotic ? [] : qualityValues) await (await database.prepare(`INSERT INTO weapon_mod_quality_values
(weapon_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 weapon_mod_secondary_options WHERE weapon_mod_id=?')).run(modId);
for (const range of isExotic ? [] : secondaryRanges) {
await (await database.prepare('INSERT INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId,range.statId);
for (const quality of qualities) await (await database.prepare(`INSERT INTO weapon_mod_secondary_quality_values
(weapon_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 (await database.prepare('DELETE FROM weapon_mod_effects WHERE weapon_mod_id=?')).run(modId);
for (const [index, effect] of (isExotic ? [] : effects).entries()) {
const effectId = randomUUID();
await (await database.prepare('INSERT INTO weapon_mod_effects(id,weapon_mod_id,stat_attribute_id,sort_order) VALUES(?,?,?,?)')).run(effectId,modId,effect.statId,index+1);
for (const quality of qualities) await (await database.prepare('INSERT INTO weapon_mod_effect_quality_values(effect_id,quality,value) VALUES(?,?,?)')).run(effectId,quality,effect.values[quality]);
}
await (await database.prepare('DELETE FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=?')).run(modId);
for (const [index, attribute] of (isExotic ? exoticAttributes : []).entries()) await (await database.prepare(`INSERT INTO weapon_mod_exotic_attributes
(id,weapon_mod_id,stat_attribute_id,level_1_value,level_20_value,sort_order) VALUES(?,?,?,?,?,?)`))
.run(randomUUID(),modId,attribute.statId,attribute.level1Value,attribute.level20Value,index+1);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon_mod.updated' : 'weapon_mod.created', entityId: modId, before, after: await snapshot(modId) });
return { weaponModSaved: true, weaponModMode: 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, { weaponModError: 'Weapon Mod not found.' });
await (await (await getDatabase()).prepare('UPDATE weapon_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'weapon_mod.archived', entityId: id, before, after: await snapshot(id) });
return { weaponModArchived: 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, { weaponModError: 'Weapon Mod not found.' });
await (await (await getDatabase()).prepare('UPDATE weapon_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'weapon_mod.unarchived', entityId: id, before, after: await snapshot(id) });
return { weaponModUnarchived: true };
}
};
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
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';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
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 blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
const blank = () => ({
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '',
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
});
let editing = $state<any | null>(null);
let levelPreview = $state(1);
let qualityPreview = $state('High-end');
let isExotic = $state(false);
let handledResultToken = $state('');
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const weapon = (id: string) => data.weapons.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,
compatibleCategories: [...item.compatibleCategories],
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()],
exoticAttributes: item.exoticAttributes.length ? item.exoticAttributes.map((attribute: any)=>({...attribute})) : [blankExoticAttribute()],
effects: item.effects.length ? item.effects.map((effect: any) => ({ statId: effect.statId, values: { ...effect.values } })) : [blankEffect()]
};
levelPreview = 1;
qualityPreview = 'High-end';
isExotic = Boolean(item.exotic_weapon_id);
};
const setMode = (value: string) => {
isExotic = value === 'exotic';
if (isExotic) editing.compatibleCategories = [];
else editing.exotic_weapon_id = '';
};
const addEffect = () => { if (editing.effects.length < 2) editing.effects = [...editing.effects, blankEffect()]; };
const removeEffect = (index: number) => { if (editing.effects.length > 1) editing.effects = editing.effects.filter((_: any, itemIndex: number) => itemIndex !== index); };
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); };
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
$effect(() => {
if (form?.weaponModSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.weaponModMode === 'add' ? blank() : null;
levelPreview = 1;
isExotic = false;
}
});
</script>
<svelte:head><title>Weapon Mods — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT MODIFICATION REGISTRY</p><h2>WEAPON MODS</h2><p>Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.</p></div><button class="primary" onclick={() => { editing = blank(); isExotic = false; }}>ADD WEAPON MOD <span>+</span></button></div>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to weapons'},
{value:'regular',label:'REGULAR',count:data.summary.regular,description:'Category-compatible mods'},
{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Locked to one exotic weapon'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/>
{#if editing}
<div class="dashboard-editor weapon-mod-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}</p><h3>{editing.id ? editing.name : 'WEAPON 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="isExotic" value={isExotic ? '1' : '0'}/>
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
<input type="hidden" name="effects" value={JSON.stringify(editing.effects)}/>
<input type="hidden" name="exoticAttributes" value={JSON.stringify(editing.exoticAttributes)}/>
<div class="weapon-mod-identity">
<label class="field weapon-mod-name">MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
<label class="field exotic-toggle weapon-mod-exotic-toggle">EXOTIC STATUS<span title="Exotic Weapon Mod"><input type="checkbox" checked={isExotic} onchange={(event)=>setMode(event.currentTarget.checked?'exotic':'regular')}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON MOD</b></span></label>
{#if isExotic}<div class="weapon-mod-exotic-quality"><GameIcon name="exotic" decorative/><span><b>EXOTIC QUALITY</b><small>UNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS</small></span></div>{:else}<div class="weapon-mod-primary-types"><span>PRIMARY ATTRIBUTE OPTIONS</span><div>{#each ['firepower','toughness','engineering'] as primary}<b><GameIcon name={primary as GameIconName} decorative/>{primary.toUpperCase()}</b>{/each}</div><small>Selected by the agent while configuring a build.</small></div>{/if}
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required showIndicator={false}/>
</div>
<div class="weapon-mod-compatibility">
<div>
<div class="selection-head"><div><span>{isExotic ? 'EXOTIC WEAPON LOCK' : 'WEAPON CATEGORY COMPATIBILITY'}</span><small>{isExotic ? 'PRE-EQUIPPED · CANNOT BE REMOVED' : 'SLOT TYPE AND CATEGORY MUST BOTH MATCH'}</small></div><b>{isExotic ? (editing.exotic_weapon_id ? 1 : 0) : editing.compatibleCategories.length}</b></div>
{#if isExotic}<SearchableAssignment items={exoticWeapons} fieldName="exoticWeaponId" mode="single" subjectLabel="EXOTIC WEAPON" searchPlaceholder="Search exotic weapons…" bind:value={editing.exotic_weapon_id}/>
{:else}<SegmentedChoice legend="COMPATIBLE WEAPON CATEGORIES" name="compatibleCategories" mode="checkbox" options={categoryOptions} bind:selected={editing.compatibleCategories} columns={6} showIndicator={false}/>{/if}
</div>
</div>
{#if !isExotic}
<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 · SECONDARY RANGES VARY BY QUALITY</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><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><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>
<section class="weapon-mod-effect-section">
<div class="selection-head"><div><span>MOD EFFECT</span><small>NO TRIGGER · FIXED ATTRIBUTE VALUES VARY BY QUALITY, NOT LEVEL</small></div><button type="button" class="row-action" onclick={addEffect} disabled={editing.effects.length >= 2}>ADD EFFECT +</button></div>
<div class="weapon-mod-effects">{#each editing.effects as effect, index}<div><strong>0{index+1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="EFFECT ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.effects.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={effect.statId}/>{#each data.qualities as quality}<label><span class={qualityClass(quality)}>{quality.toUpperCase()}</span><input type="number" step="any" bind:value={effect.values[quality]} required/></label>{/each}<button type="button" aria-label={`Remove Mod Effect ${index+1}`} onclick={() => removeEffect(index)} disabled={editing.effects.length === 1}>×</button></div>{/each}</div>
</section>
{:else}
<section class="weapon-mod-exotic-section">
<div class="selection-head"><div><span>FIXED EXOTIC ATTRIBUTES</span><small>NO RANDOM ROLLS OR QUALITY RANGES · VALUES SCALE LINEARLY FROM LEVEL 1 TO 20</small></div><button type="button" class="row-action" onclick={addExoticAttribute} disabled={editing.exoticAttributes.length>=12}>ADD ATTRIBUTE +</button></div>
<div class="weapon-mod-exotic-attributes">{#each editing.exoticAttributes as attribute,index}<div><strong>{String(index+1).padStart(2,'0')}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="FIXED ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index).map((item:any)=>item.statId)} bind:value={attribute.statId}/><label>LEVEL 1 VALUE<input type="number" step="any" bind:value={attribute.level1Value} required/></label><label>LEVEL 20 VALUE<input type="number" step="any" bind:value={attribute.level20Value} required/></label><button type="button" aria-label={`Remove exotic attribute ${index+1}`} onclick={()=>removeExoticAttribute(index)} disabled={editing.exoticAttributes.length===1}>×</button></div>{/each}</div>
<p class="weapon-mod-rule"><b>EXOTIC LOCK</b> This mod is permanently equipped on {weapon(editing.exotic_weapon_id)?.name ?? 'the selected exotic weapon'}. Its fixed attributes increase with mod level and cannot be rerolled.</p>
</section>
{/if}
{#if form?.weaponModError}<p class="error">{form.weaponModError}</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 WEAPON MOD' : 'UNARCHIVE WEAPON MOD'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE WEAPON MOD →</button></div></div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table weapon-mod-dashboard-table">
<div class="dashboard-row weapon-mod-row table-heading"><span>WEAPON MOD</span><span>SLOT</span><span>PRIMARY</span><span>SECONDARIES</span><span>COMPATIBILITY</span><span>MOD EFFECT</span><span>STATE</span><span></span></div>
{#each data.mods as item}
<div class="dashboard-row weapon-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.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</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 WEAPON MODS REGISTERED</strong><span>Add the first slot-compatible Weapon Mod definition.</span></div>{/each}
</div>
+26 -3
View File
@@ -12,6 +12,7 @@ import { getPendingEntities } from '$lib/server/data-moderation';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding']; const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const; const rangeNames = ['damage'] as const;
const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const;
type ExoticEffect = { statId: string; valueI: number; valueII: number }; type ExoticEffect = { statId: string; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`; const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
@@ -44,9 +45,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])]; const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0)); const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([ const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
(await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(), (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
@@ -74,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids'] } { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots'] }
]) ])
]); ]);
const pendingTalents = pending.filter((item) => item.kind === 'talent'); const pendingTalents = pending.filter((item) => item.kind === 'talent');
@@ -92,6 +94,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
reload_speed: weapon.reloadSpeed, reload_speed: weapon.reloadSpeed,
baseBonuses: [], baseBonuses: [],
tiers: [], tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean), talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean), talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
augmentEffects: [], augmentEffects: [],
@@ -110,6 +113,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return { return {
...weapon, ...weapon,
tiers: tierRanges.filter((range) => range.weapon_id === weapon.id), tiers: tierRanges.filter((range) => range.weapon_id === weapon.id),
modSlots: [0,1,2].map((tier) => ({ tier, slots: modSlots.filter((slot) => slot.weapon_id === weapon.id && Number(slot.tier) === tier).map((slot) => String(slot.slot_type)) })),
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
@@ -142,6 +146,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
filter, filter,
search, search,
requestedWeaponId requestedWeaponId
,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name }))
}; };
}; };
@@ -183,6 +188,12 @@ export const actions: Actions = {
}; };
const talent1Ids = [...new Set(form.getAll('talent1Ids').map(String))]; const talent1Ids = [...new Set(form.getAll('talent1Ids').map(String))];
const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))]; const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))];
const modSlots = (() => {
try {
const parsed = JSON.parse(String(form.get('modSlots') ?? '[]'));
return Array.isArray(parsed) ? parsed.map((row) => ({ tier: Number(row.tier), slots: [...new Set(Array.isArray(row.slots) ? row.slots.map(String) : [])] })) : [];
} catch { return []; }
})();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : ''; const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : ''; const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : ''; const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
@@ -213,6 +224,13 @@ export const actions: Actions = {
if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) { if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) {
return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' }); return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' });
} }
for (let tier = nativeTier; tier <= 2; tier++) {
const configured = modSlots.find((row) => row.tier === tier);
const required = tier === 0 ? 4 : 5;
if (!configured || configured.slots.length !== required || configured.slots.some((slot) => !modSlotTypes.includes(slot as typeof modSlotTypes[number]))) {
return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` });
}
}
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) { if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' }); return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' });
} }
@@ -310,6 +328,7 @@ export const actions: Actions = {
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id), talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id), augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id), baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
exoticTalent: existingExoticTalentId ? { exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId), record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId), icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
@@ -376,6 +395,10 @@ export const actions: Actions = {
await (await database.prepare('DELETE FROM weapon_talent_options WHERE weapon_id=?')).run(weaponId); await (await database.prepare('DELETE FROM weapon_talent_options WHERE weapon_id=?')).run(weaponId);
for (const talentId of talent1Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 1, talentId); for (const talentId of talent1Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 1, talentId);
for (const talentId of talent2Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 2, talentId); for (const talentId of talent2Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 2, talentId);
await (await database.prepare('DELETE FROM weapon_mod_slots WHERE weapon_id=?')).run(weaponId);
for (const row of modSlots.filter((row) => row.tier >= nativeTier)) {
for (const slot of row.slots) await (await database.prepare('INSERT INTO weapon_mod_slots(weapon_id,tier,slot_type) VALUES(?,?,?)')).run(weaponId,row.tier,slot);
}
await (await database.prepare('DELETE FROM weapon_augmentation_effects WHERE weapon_id=?')).run(weaponId); await (await database.prepare('DELETE FROM weapon_augmentation_effects WHERE weapon_id=?')).run(weaponId);
for (const effect of augmentEffects) { for (const effect of augmentEffects) {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
@@ -386,7 +409,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
} }
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}, },
archive: async ({ request, cookies }) => { archive: async ({ request, cookies }) => {
+8 -3
View File
@@ -16,7 +16,7 @@
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true })); const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 }); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null); let editing = $state<any | null>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
let iconResetToken = $state(0); let iconResetToken = $state(0);
@@ -26,9 +26,11 @@
const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier); const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier);
return { ...blankTier(tier), ...saved }; return { ...blankTier(tier), ...saved };
}); });
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() }; const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
}; };
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier); const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier);
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute'; const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent'; const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent';
const setCategory = (category: string) => { const setCategory = (category: string) => {
@@ -92,6 +94,7 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div> <div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data"> <PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/> <input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="modSlots" value={JSON.stringify(editing.modSlots)}/>
<div class="weapon-identity-grid"> <div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label> <label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/> <SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/>
@@ -132,9 +135,11 @@
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION</small></div><b>3 TIERS REQUIRED</b></div> <div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION</small></div><b>3 TIERS REQUIRED</b></div>
<div class="weapon-tier-grid"> <div class="weapon-tier-grid">
{#each [0, 1, 2] as tier} {#each [0, 1, 2] as tier}
{@const slotConfig = modSlotsAt(tier)}
<fieldset class="weapon-tier-card" class:unavailable={tier < Number(editing.native_tier)} disabled={tier < Number(editing.native_tier)}><legend>TIER {tier}</legend> <fieldset class="weapon-tier-card" class:unavailable={tier < Number(editing.native_tier)} disabled={tier < Number(editing.native_tier)}><legend>TIER {tier}</legend>
{#if tier < Number(editing.native_tier)}<p class="tier-unavailable">BELOW NATIVE TIER · NOT AVAILABLE</p>{/if} {#if tier < Number(editing.native_tier)}<p class="tier-unavailable">BELOW NATIVE TIER · NOT AVAILABLE</p>{/if}
<div class="weapon-range-row"><strong><GameIcon name={iconName(editing.damage_type)} decorative/><span>DMG<small>{editing.damage_type.toUpperCase()} DAMAGE</small></span></strong><label>MIN<input name={`tier${tier}damageMin`} type="number" min="0" step="any" value={tierAt(tier).damage_min} oninput={(event) => tierAt(tier).damage_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}damageMax`} type="number" min="0" step="any" value={tierAt(tier).damage_max} oninput={(event) => tierAt(tier).damage_max = event.currentTarget.valueAsNumber} required/></label></div> <div class="weapon-range-row"><strong><GameIcon name={iconName(editing.damage_type)} decorative/><span>DMG<small>{editing.damage_type.toUpperCase()} DAMAGE</small></span></strong><label>MIN<input name={`tier${tier}damageMin`} type="number" min="0" step="any" value={tierAt(tier).damage_min} oninput={(event) => tierAt(tier).damage_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}damageMax`} type="number" min="0" step="any" value={tierAt(tier).damage_max} oninput={(event) => tierAt(tier).damage_max = event.currentTarget.valueAsNumber} required/></label></div>
<div class="weapon-mod-slot-config"><div class="selection-head"><div><span>WEAPON MOD SLOTS</span><small>{tier === 0 ? 'EXACTLY FOUR SLOTS' : 'EXACTLY FIVE SLOTS'}</small></div><b>{slotConfig.slots.length} / {tier === 0 ? 4 : 5}</b></div><SearchableAssignment items={data.modSlotOptions} subjectLabel="MOD SLOTS" searchPlaceholder="Search slot types…" bind:selected={slotConfig.slots}/></div>
</fieldset> </fieldset>
{/each} {/each}
</div> </div>
@@ -161,6 +166,6 @@
<div class="dashboard-table weapon-dashboard-table"> <div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div> <div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
{#each data.weapons as weapon} {#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED</span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)}>EDIT</button>{/if}</div> <div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)}>EDIT</button>{/if}</div>
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each} {:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
</div> </div>