Files
shd.build/src/routes/admin/weapons/+page.server.ts
T
2026-07-25 17:54:37 -04:00

368 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const;
type ExoticEffect = { statId: string; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map<string, unknown>, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) =>
template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
if (expression === 'duration') return duration === null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks === null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = effects[Number(expression) - 1];
if (!effect) return placeholder;
const value = tier === 'I' ? effect.valueI : effect.valueII;
const unit = units.get(effect.statId) === 'flat' ? '' : String(units.get(effect.statId) ?? '');
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
});
export const load: PageServerLoad = async ({ cookies, url }) => {
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN is_exotic=0 THEN 1 ELSE 0 END) AS standard,
SUM(CASE WHEN is_exotic=1 THEN 1 ELSE 0 END) AS exotic FROM weapons`)).get();
const filter = ['active', 'standard', 'exotic'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const requestedWeaponId = url.searchParams.get('weapon')?.trim() ?? '';
const where = requestedWeaponId ? 'WHERE id=?' : filter === 'active' ? 'WHERE active=1' : filter === 'standard' ? 'WHERE is_exotic=0' : filter === 'exotic' ? 'WHERE is_exotic=1' : '';
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
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] = 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 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(),
(await database.prepare(`SELECT talents.id,talents.name FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=1
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare(`SELECT talents.id,talents.name FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2
ORDER BY talents.name COLLATE NOCASE`)).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,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
(await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name,
talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks
FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(),
(await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
return {
weapons: weapons.map((weapon) => {
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId);
const tier = (name: string) => talentRows.find((item) => item.tier === name);
const tierI = tier('I'), tierII = tier('II');
const effectIds = [...new Set([
...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierI?.id),
...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierII?.id)
].map((effect) => String(effect.stat_attribute_id)))];
return {
...weapon,
tiers: tierRanges.filter((range) => range.weapon_id === weapon.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)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
cooldown_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.cooldown_seconds ?? '',
duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '',
max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '',
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
triggerIds: [...new Set([
...exoticTierTriggers.filter((link) => link.talent_tier_id === tierI?.id),
...exoticTierTriggers.filter((link) => link.talent_tier_id === tierII?.id)
].map((link) => String(link.trigger_id)))],
effects: effectIds.map((statId) => ({
statId,
valueI: exoticTierEffects.find((effect) => effect.talent_tier_id === tierI?.id && String(effect.stat_attribute_id) === statId)?.value ?? '',
valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? ''
}))
} : null
}}),
talent1Options,
talent2Options,
stats,
triggers,
summary,
pagination,
filter,
requestedWeaponId
};
};
function number(form: FormData, name: string): number {
return Number(form.get(name));
}
function optionalPositiveNumber(form: FormData, name: string): number | null {
const raw = String(form.get(name) ?? '').trim();
return raw ? Number(raw) : null;
}
function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) {
const min = number(form, `tier${tier}${range}Min`);
const max = number(form, `tier${tier}${range}Max`);
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error();
return { min, max };
}
export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let exoticIconMutation;
try { exoticIconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { weaponError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? '');
const damageType = String(form.get('damageType') ?? '');
const isExotic = form.get('isExotic') === '1';
const nativeTier = number(form, 'nativeTier');
const fixed = {
rpm: number(form, 'rpm'),
magazine: number(form, 'magazine'),
reloadSpeed: number(form, 'reloadSpeed'),
accuracy: number(form, 'accuracy'),
stability: number(form, 'stability')
};
const talent1Ids = [...new Set(form.getAll('talent1Ids').map(String))];
const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))];
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() : '';
const exoticCooldownSeconds = isExotic ? optionalPositiveNumber(form, 'exoticCooldownSeconds') : null;
const exoticDurationSeconds = isExotic ? optionalPositiveNumber(form, 'exoticDurationSeconds') : null;
const exoticMaxStacks = isExotic ? optionalPositiveNumber(form, 'exoticMaxStacks') : null;
const exoticTriggerIds = isExotic ? [...new Set(form.getAll('exoticTriggerIds').map(String))] : [];
let exoticEffects: ExoticEffect[] = [];
try {
const parsed = JSON.parse(String(form.get('exoticEffects') ?? '[]')) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed)) throw new Error();
exoticEffects = parsed.map((effect) => ({ statId: String(effect.statId ?? ''), valueI: Number(effect.valueI), valueII: Number(effect.valueII) }));
} catch {
return fail(400, { weaponError: 'The exotic talent effects could not be read.' });
}
if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' });
if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) {
return fail(400, { weaponError: 'Choose a valid category, native tier, and damage type.' });
}
if (Object.values(fixed).some((value) => !Number.isFinite(value) || value < 0)) {
return fail(400, { weaponError: 'Fixed weapon attributes require non-negative numeric values.' });
}
if (!Number.isInteger(fixed.magazine) || !Number.isInteger(fixed.rpm)) {
return fail(400, { weaponError: 'RPM and magazine values must be whole numbers.' });
}
if (!talent1Ids.length || !talent2Ids.length) return fail(400, { weaponError: 'Talent 1 and Talent 2 each require at least one available weapon talent.' });
if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) {
return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' });
}
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.' });
}
if (isExotic && [exoticCooldownSeconds, exoticDurationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) {
return fail(400, { weaponError: 'Exotic talent cooldown and duration must be positive numbers when defined.' });
}
if (isExotic && exoticMaxStacks !== null && (!Number.isInteger(exoticMaxStacks) || exoticMaxStacks <= 0)) {
return fail(400, { weaponError: 'Exotic talent maximum stacks must be a positive whole number when defined.' });
}
if (isExotic && (exoticEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))
|| new Set(exoticEffects.map((effect) => effect.statId)).size !== exoticEffects.length)) {
return fail(400, { weaponError: 'Every exotic talent effect requires one unique attribute and valid Tier I and Tier II values.' });
}
if (isExotic) {
const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > exoticEffects.length)) return fail(400, { weaponError: 'The exotic talent description references a missing effect.' });
if (exoticDescriptionTemplate.includes('{duration}') && exoticDurationSeconds === null) return fail(400, { weaponError: 'The exotic talent description uses {duration}, but no duration is defined.' });
if (exoticDescriptionTemplate.includes('{max_stacks}') && exoticMaxStacks === null) return fail(400, { weaponError: 'The exotic talent description uses {max_stacks}, but no maximum stack count is defined.' });
}
const tiers = [];
try {
for (let tier = 0; tier <= 2; tier++) {
tiers.push({
tier,
damage: readRange(form, tier, 'damage'),
firepower: readRange(form, tier, 'firepower'),
toughness: readRange(form, tier, 'toughness'),
engineering: readRange(form, tier, 'engineering')
});
}
} catch {
return fail(400, { weaponError: 'Every tier range requires a non-negative minimum and a maximum greater than or equal to it.' });
}
const augmentEffects = [];
for (let order = 1; order <= 2; order++) {
const statId = String(form.get(`augmentStat${order}`) ?? '');
const rawValue = String(form.get(`augmentValue${order}`) ?? '').trim();
if (!statId && !rawValue && order === 2) continue;
const value = Number(rawValue);
if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Tactical augmentation effect ${order} requires an attribute and a positive value.` });
augmentEffects.push({ statId, value, order });
}
if (!augmentEffects.length || new Set(augmentEffects.map((effect) => effect.statId)).size !== augmentEffects.length) {
return fail(400, { weaponError: 'The first tactical augmentation requires one or two unique attribute effects.' });
}
const baseBonusCount = number(form, 'baseBonusCount');
if (!Number.isInteger(baseBonusCount) || baseBonusCount < 1 || baseBonusCount > 12) {
return fail(400, { weaponError: 'Weapons require between one and twelve base attribute bonuses.' });
}
const baseBonuses = [];
for (let order = 1; order <= baseBonusCount; order++) {
const statId = String(form.get(`baseBonusStat${order}`) ?? '');
const value = number(form, `baseBonusValue${order}`);
if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Base attribute bonus ${order} requires an attribute and a positive value.` });
baseBonuses.push({ statId, value, order });
}
if (new Set(baseBonuses.map((effect) => effect.statId)).size !== baseBonuses.length) {
return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { weaponError: 'That weapon name already exists.' });
}
for (const [slot, ids] of [[1, talent1Ids], [2, talent2Ids]] as const) {
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard'
AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids);
if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` });
}
const statIds = [...new Set([...augmentEffects.map((effect) => effect.statId), ...baseBonuses.map((effect) => effect.statId), ...exoticEffects.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, { weaponError: 'One or more tactical augmentation attributes are unavailable.' });
if (isExotic) {
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${exoticTriggerIds.map(() => '?').join(',')})`)).get(...exoticTriggerIds);
if (Number(validTriggers?.count) !== exoticTriggerIds.length) return fail(400, { weaponError: 'One or more exotic talent triggers are unavailable.' });
if (existingExoticTalentId) {
const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null;
if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' });
const shared = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(existingExoticTalentId, id || 'new');
if (shared) return fail(409, { weaponError: 'That exotic talent is already owned by another weapon and cannot be shared.' });
}
if (await (await database.prepare("SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category='Weapon' AND id<>?")).get(exoticTalentName, existingExoticTalentId || 'new')) {
return fail(409, { weaponError: 'An exotic talent with that name already exists.' });
}
}
const weaponId = id || randomUUID();
const now = new Date().toISOString();
const exoticTalentId = isExotic ? existingExoticTalentId || randomUUID() : null;
const before = id ? {
record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).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),
baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId),
effects: await (await database.prepare(`SELECT talent_tier_effects.* FROM talent_tier_effects JOIN talent_tiers ON talent_tiers.id=talent_tier_effects.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId)
} : null
} : null;
if (isExotic && exoticTalentId) {
if (existingExoticTalentId) {
await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId);
} else {
await (await database.prepare("INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,active,created_at,updated_at) VALUES(?,?,'Weapon',?,?,?,1,?,?)")).run(exoticTalentId, exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, now);
}
await (await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,'Exotic')
ON CONFLICT(talent_id) DO UPDATE SET scope='Exotic'`)).run(exoticTalentId);
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId);
await (await database.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(exoticTalentId, exoticDescriptionTemplate, now);
const unitRows = await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${exoticEffects.map(() => '?').join(',')})`)).all(...exoticEffects.map((effect) => effect.statId));
const units = new Map(unitRows.map((row) => [String(row.id), row.unit]));
for (const talentTier of ['I', 'II'] as const) {
const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks);
await (await database.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`))
.run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now);
const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier);
const tierId = String(tierRow?.id);
await (await database.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId);
await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const triggerId of exoticTriggerIds) await (await database.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation);
}
if (id) {
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
if (!exists) return fail(404, { weaponError: 'Weapon not found.' });
await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?,
reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`))
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id);
} else {
await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,
accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, now);
}
const previousExoticTalentId = typeof before?.record?.exotic_talent_id === 'string' ? before.record.exotic_talent_id : '';
if (!isExotic && previousExoticTalentId) {
const anotherOwner = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(previousExoticTalentId, weaponId);
if (!anotherOwner) await (await database.prepare('UPDATE talents SET active=0,updated_at=? WHERE id=?')).run(now, previousExoticTalentId);
}
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
for (const tier of tiers) {
await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min,
firepower_max,toughness_min,toughness_max,engineering_min,engineering_max) VALUES(?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, tier.tier, tier.damage.min, tier.damage.max, tier.firepower.min, tier.firepower.max, tier.toughness.min, tier.toughness.max, tier.engineering.min, tier.engineering.max);
}
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_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(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await (await database.prepare('DELETE FROM weapon_base_attribute_bonuses WHERE weapon_id=?')).run(weaponId);
for (const effect of baseBonuses) {
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 } });
return { weaponSaved: true, weaponMode: 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 auditRecord('weapons', id);
await (await (await getDatabase()).prepare('UPDATE weapons SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'weapon.archived', entityId: id, before, after: await auditRecord('weapons', id) });
return { weaponArchived: true };
}
};