207 lines
16 KiB
TypeScript
207 lines
16 KiB
TypeScript
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 { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||
import { getPagination } from '$lib/server/pagination';
|
||
import { fail } from '@sveltejs/kit';
|
||
import { randomUUID } from 'node:crypto';
|
||
import type { Actions, PageServerLoad } from './$types';
|
||
|
||
type Effect = { statId: string; minValue: number; maxValue: number; sortOrder: number };
|
||
|
||
const parseEffects = (value: FormDataEntryValue | null): Effect[] => {
|
||
try {
|
||
const parsed = JSON.parse(String(value ?? '[]'));
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed.map((item, index) => ({
|
||
statId: String(item.statId ?? ''),
|
||
minValue: Number(item.minValue),
|
||
maxValue: Number(item.maxValue),
|
||
sortOrder: index + 1
|
||
}));
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
async function snapshot(id: string) {
|
||
const database = await getDatabase();
|
||
const talent = await (await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id);
|
||
return {
|
||
record: await auditRecord('skill_chip_sets', id),
|
||
icon: await gameDataIconMetadata(database, 'skill_chip', id),
|
||
twoPiece: await (await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?')).get(id) ?? null,
|
||
primaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id')).all(id),
|
||
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id')).all(id),
|
||
talent: talent ?? null,
|
||
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
|
||
talentEffects: talent ? await (await database.prepare('SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.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', 'configured'].includes(requestedFilter) ? requestedFilter : '';
|
||
const where = filter === 'active' ? 'WHERE sets.active=1'
|
||
: filter === 'archived' ? 'WHERE sets.active=0'
|
||
: filter === 'configured' ? `WHERE EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)
|
||
AND EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)` : '';
|
||
const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = 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 EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
||
FROM skill_chip_sets`)).get(),
|
||
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(),
|
||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
|
||
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]),
|
||
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }])
|
||
]);
|
||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
|
||
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
|
||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
|
||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||
(await database.prepare('SELECT * FROM skill_chip_talent_triggers')).all(),
|
||
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
|
||
(await database.prepare('SELECT id,name,unit,skill_chip_min,skill_chip_max 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 entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'")).all()
|
||
]);
|
||
const availableStats = [...pendingStats.map((item) => ({
|
||
...item,
|
||
unit: String(item.unit ?? ''),
|
||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
|
||
})), ...stats];
|
||
const pendingSetRows = filter === 'archived' ? [] : pendingSets.map((set) => ({
|
||
...set,
|
||
iconUrl: '',
|
||
twoPiece: {
|
||
stat_attribute_id: set.twoPieceStatId,
|
||
min_value: Number(set.twoPieceMin),
|
||
max_value: Number(set.twoPieceMax)
|
||
},
|
||
primaryStatIds: Array.isArray(set.primaryStatIds) ? set.primaryStatIds : [set.primaryStatIds].filter(Boolean),
|
||
secondaryStatIds: Array.isArray(set.secondaryStatIds) ? set.secondaryStatIds : [set.secondaryStatIds].filter(Boolean),
|
||
talent: {
|
||
name: set.talentName,
|
||
description_template: set.descriptionTemplate,
|
||
effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(),
|
||
triggerIds: Array.isArray(set.triggerIds) ? set.triggerIds : [set.triggerIds].filter(Boolean)
|
||
}
|
||
}));
|
||
return {
|
||
sets: [...pendingSetRows, ...sets.map((set) => {
|
||
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
|
||
const icon = icons.find((item) => item.entity_id === set.id);
|
||
return {
|
||
...set,
|
||
iconUrl: icon ? gameDataIconUrl('skill_chip', String(set.id), icon.updated_at) : '',
|
||
twoPiece: twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||
primaryStatIds: primaryOptions.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
|
||
secondaryStatIds: secondaryOptions.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
|
||
talent: talent ? {
|
||
...talent,
|
||
triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)),
|
||
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
||
} : null
|
||
};
|
||
})],
|
||
stats: availableStats,
|
||
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
|
||
triggers: [...pendingTriggers, ...triggers],
|
||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination
|
||
};
|
||
};
|
||
|
||
export const actions: Actions = {
|
||
save: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||
const form = await request.formData();
|
||
let iconMutation;
|
||
try { iconMutation = await readIconMutation(form); }
|
||
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { skillChipError: error.message }); throw error; }
|
||
const id = String(form.get('id') ?? '');
|
||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
|
||
const twoPieceMin = Number(form.get('twoPieceMin'));
|
||
const twoPieceMax = Number(form.get('twoPieceMax'));
|
||
const primaryStatIds = [...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean))];
|
||
const secondaryStatIds = [...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean))];
|
||
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
|
||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim();
|
||
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
|
||
const effects = parseEffects(form.get('talentEffects'));
|
||
if (name.length < 2 || name.length > 80) return fail(400, { skillChipError: 'Skill Chip names must be 2–80 characters.' });
|
||
if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin) return fail(400, { skillChipError: 'Configure a valid 2-piece stat range.' });
|
||
if (!primaryStatIds.length) return fail(400, { skillChipError: 'Configure at least one possible primary stat.' });
|
||
if (secondaryStatIds.length < 2) return fail(400, { skillChipError: 'Configure at least two possible secondary stats.' });
|
||
if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId))) return fail(400, { skillChipError: 'Primary and secondary stat pools must be distinct.' });
|
||
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !effects.length) return fail(400, { skillChipError: 'The 3-piece talent requires a name, description, trigger, and effect.' });
|
||
if (effects.length > 12 || effects.some((effect) => !effect.statId || !Number.isFinite(effect.minValue) || !Number.isFinite(effect.maxValue) || effect.maxValue < effect.minValue)) return fail(400, { skillChipError: 'Every talent effect requires a valid minimum and maximum.' });
|
||
if (new Set(effects.map((effect) => effect.statId)).size !== effects.length) return fail(400, { skillChipError: 'Each talent effect may reference a stat only once.' });
|
||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
|
||
if (placeholders.some((index) => index < 1 || index > effects.length)) return fail(400, { skillChipError: 'Description placeholders must reference configured talent effects.' });
|
||
const database = await getDatabase();
|
||
if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { skillChipError: 'That Skill Chip set already exists.' });
|
||
const existingTalent = id ? await (await database.prepare('SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id) : null;
|
||
if (await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { skillChipError: 'That Skill Chip talent name is already in use.' });
|
||
const allStatIds = [...new Set([twoPieceStatId, ...primaryStatIds, ...secondaryStatIds, ...effects.map((effect) => effect.statId)])];
|
||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`)).get(...allStatIds);
|
||
if (Number(validStats?.count) !== allStatIds.length) return fail(400, { skillChipError: 'One or more selected stats are unavailable.' });
|
||
const rollStatIds = [...primaryStatIds, ...secondaryStatIds];
|
||
const validRollStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`)).get(...rollStatIds);
|
||
if (Number(validRollStats?.count) !== rollStatIds.length) return fail(400, { skillChipError: 'Every primary and secondary option requires a configured Skill Chip range.' });
|
||
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds);
|
||
if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { skillChipError: 'One or more selected triggers are unavailable.' });
|
||
const setId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||
const talentId = String(existingTalent?.id ?? randomUUID());
|
||
const now = new Date().toISOString();
|
||
const before = id ? await snapshot(id) : null;
|
||
if (id) {
|
||
if (!await auditRecord('skill_chip_sets', id)) return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||
await (await database.prepare('UPDATE skill_chip_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(setId, name, now, now);
|
||
await (await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
|
||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
|
||
await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId);
|
||
for (const statId of primaryStatIds) await (await database.prepare('INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId);
|
||
await (await database.prepare('DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?')).run(setId);
|
||
for (const statId of secondaryStatIds) await (await database.prepare('INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId);
|
||
await (await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, setId, talentName, descriptionTemplate, now, now);
|
||
await (await database.prepare('DELETE FROM skill_chip_talent_triggers WHERE talent_id=?')).run(talentId);
|
||
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO skill_chip_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
|
||
await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId);
|
||
for (const effect of effects) await (await database.prepare('INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder);
|
||
await applyIconMutation(database, 'skill_chip', setId, iconMutation);
|
||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) });
|
||
return { skillChipSaved: true, skillChipMode: 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, { skillChipError: 'Skill Chip set not found.' });
|
||
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) });
|
||
return { skillChipArchived: 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, { skillChipError: 'Skill Chip set not found.' });
|
||
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.unarchived', entityId: id, before, after: await snapshot(id) });
|
||
return { skillChipUnarchived: true };
|
||
}
|
||
};
|