368 lines
28 KiB
TypeScript
368 lines
28 KiB
TypeScript
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 };
|
||
}
|
||
};
|