117 lines
8.5 KiB
TypeScript
117 lines
8.5 KiB
TypeScript
import { getSessionUser } from '$lib/server/auth/session';
|
||
import { requireAdmin } from '$lib/server/auth/authorization';
|
||
import { getDatabase } from '$lib/server/database';
|
||
import { fail } from '@sveltejs/kit';
|
||
import { randomUUID } from 'node:crypto';
|
||
import type { Actions, PageServerLoad } from './$types';
|
||
|
||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||
|
||
export const load: PageServerLoad = async () => {
|
||
const db = await getDatabase();
|
||
const [talents, tiers, triggers, links, effects, stats, templates] = await Promise.all([
|
||
(await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(),
|
||
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
|
||
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
|
||
(await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
|
||
(await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
|
||
(await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||
(await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all()
|
||
]);
|
||
return {
|
||
talents: talents.map((talent) => ({
|
||
...talent,
|
||
category: talent.category === 'Vest' ? 'Body Armor' : talent.category,
|
||
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
|
||
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
|
||
...tier,
|
||
triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)),
|
||
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
|
||
}))
|
||
})),
|
||
triggers,
|
||
stats
|
||
};
|
||
};
|
||
|
||
const parseEffects = (raw: FormDataEntryValue | null): SharedEffect[] => {
|
||
const value = JSON.parse(String(raw ?? '[]')) as Array<{ statId: unknown; valueI: unknown; valueII: unknown }>;
|
||
if (!Array.isArray(value)) throw new Error();
|
||
return value.map((effect) => ({
|
||
statId: String(effect.statId),
|
||
valueI: Number(effect.valueI),
|
||
valueII: Number(effect.valueII)
|
||
}));
|
||
};
|
||
|
||
const displayValue = (value: number, unit: unknown) => {
|
||
const normalizedUnit = unit === 'flat' ? '' : String(unit ?? '');
|
||
return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`;
|
||
};
|
||
|
||
const renderDescription = (
|
||
template: string,
|
||
effects: SharedEffect[],
|
||
units: Map<string, unknown>,
|
||
tier: 'I' | 'II'
|
||
) => template.replace(/\{(\d+)\}/g, (placeholder, rawIndex) => {
|
||
const effect = effects[Number(rawIndex) - 1];
|
||
if (!effect) return placeholder;
|
||
return displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId));
|
||
});
|
||
|
||
export const actions: Actions = {
|
||
saveTrigger: async ({ request, cookies }) => {
|
||
requireAdmin(await getSessionUser(cookies));
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on';
|
||
const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null;
|
||
if (name.length < 2 || !description) return fail(400, { triggerError: 'A trigger name and description are required.' });
|
||
if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' });
|
||
const db = await getDatabase();
|
||
if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' });
|
||
const now = new Date().toISOString();
|
||
if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id);
|
||
else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(randomUUID(), name, description, uptime ? 1 : 0, min, max, now, now);
|
||
return { triggerSaved: true };
|
||
},
|
||
saveTalent: async ({ request, cookies }) => {
|
||
requireAdmin(await getSessionUser(cookies));
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
|
||
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
|
||
let sharedEffects: SharedEffect[];
|
||
try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); }
|
||
if (name.length < 2 || !['Weapon', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category.' });
|
||
if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' });
|
||
if (sharedEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every effect needs valid Tier I and Tier II values.' });
|
||
if (new Set(sharedEffects.map((effect) => effect.statId)).size !== sharedEffects.length) return fail(400, { talentError: 'Each affected attribute can only be selected once.' });
|
||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
|
||
if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' });
|
||
const storedCategory = category === 'Body Armor' ? 'Vest' : category;
|
||
const db = await getDatabase();
|
||
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' });
|
||
const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId));
|
||
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
|
||
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
|
||
const talentId = id || randomUUID(), now = new Date().toISOString();
|
||
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
|
||
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
|
||
await (await db.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(talentId, descriptionTemplate, now);
|
||
for (const tier of ['I', 'II'] as const) {
|
||
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier);
|
||
await (await db.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(), talentId, tier, description, '', '', now, now);
|
||
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
|
||
const tierId = String(tierRow?.id);
|
||
await (await db.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId);
|
||
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
|
||
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
|
||
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
|
||
}
|
||
return { talentSaved: true };
|
||
}
|
||
};
|