feat(isac): add weapon mod data management
This commit is contained in:
+74
-1
@@ -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 data_change_proposals (
|
||||
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,
|
||||
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, 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 (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
|
||||
entity_id TEXT NOT NULL,
|
||||
|
||||
@@ -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
|
||||
summaries; owner editing returns to the builder. On narrow layouts,
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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 sets support Superior and High-end chip instances. Each set owns one
|
||||
|
||||
@@ -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 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(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
|
||||
)).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();
|
||||
if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) {
|
||||
console.log('Adding persistent builder loadout snapshots.');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ export async function recordIsacAudit(input: {
|
||||
}
|
||||
|
||||
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
|
||||
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', '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');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AuthUser } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
|
||||
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', '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];
|
||||
|
||||
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' }
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
talents: {
|
||||
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
|
||||
saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
{ href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
|
||||
{ href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
|
||||
{ href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
|
||||
{ href: '/isac/weaponmods', label: 'Weapon Mods', code: 'WMOD', module: 'weaponmods' },
|
||||
{ href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
|
||||
{ href: '/isac/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'];
|
||||
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'];
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
|
||||
);
|
||||
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||||
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.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 openReview = (proposal: any) => {
|
||||
reviewing = proposal;
|
||||
@@ -24,7 +24,7 @@
|
||||
}));
|
||||
};
|
||||
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(() => {
|
||||
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
|
||||
handledToken = form.moderationToken;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -12,6 +12,7 @@ import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||
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 };
|
||||
|
||||
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 filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
||||
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 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,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(),
|
||||
@@ -74,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
||||
{ 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');
|
||||
@@ -92,6 +94,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
reload_speed: weapon.reloadSpeed,
|
||||
baseBonuses: [],
|
||||
tiers: [],
|
||||
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
|
||||
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
|
||||
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
|
||||
augmentEffects: [],
|
||||
@@ -110,6 +113,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
return {
|
||||
...weapon,
|
||||
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)),
|
||||
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),
|
||||
@@ -142,6 +146,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
filter,
|
||||
search,
|
||||
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 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 exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
|
||||
const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
|
||||
@@ -213,6 +224,13 @@ export const actions: Actions = {
|
||||
if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) {
|
||||
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)) {
|
||||
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),
|
||||
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),
|
||||
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
|
||||
exoticTalent: existingExoticTalentId ? {
|
||||
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(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);
|
||||
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);
|
||||
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);
|
||||
for (const effect of augmentEffects) {
|
||||
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(?,?,?,?,?)'))
|
||||
.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() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
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 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 handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
@@ -26,9 +26,11 @@
|
||||
const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier);
|
||||
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 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 talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent';
|
||||
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>
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="modSlots" value={JSON.stringify(editing.modSlots)}/>
|
||||
<div class="weapon-identity-grid">
|
||||
<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/>
|
||||
@@ -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="weapon-tier-grid">
|
||||
{#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>
|
||||
{#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-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>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -161,6 +166,6 @@
|
||||
<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>
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user