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 { gameDataIconUrl } from '$lib/server/game-data-icons'; import { managedImageUrl } from '$lib/server/managed-images'; import { getPagination, getRegistrySearch, getRegistrySort, } from '$lib/server/pagination'; import { refreshAbilityFocuses, refreshSpecializationFocusReady, } from '$lib/server/specialization-readiness'; import { SkillChipFormValidationError, parseSkillChipForm, _saveSkillChipInTransaction, SkillChipNotFoundError, loadTalent, refreshAllFocusReadiness, snapshot, } from './skillchip-save'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { loadMechanicAuthoredSource, MechanicSourceFingerprintConflictError, } from '$lib/server/mechanic-authored-sources'; import { loadSelectedMechanicModelRevision, } from '$lib/server/mechanic-model-revisions'; import { loadMechanicPublicProjection } from '$lib/server/mechanic-public-projection'; import type { Database } from '@tursodatabase/database'; import { MediaMutationJournal } from '$lib/server/media-journal'; import { normalizeSkillChipProfiles, SKILL_CHIP_QUALITIES, SKILL_CHIP_SECONDARY_QUALITIES, } from '$lib/skill-chips/profiles'; import { getSkillChipPrimaryAttributeIds, getSkillChipPrimaryProfiles, getSkillChipSecondaryPool, replaceSkillChipPrimaryProfiles, replaceSkillChipSecondaryPool, } from '$lib/server/skill-chip-profiles'; export { _saveSkillChipInTransaction } from './skillchip-save'; 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', 'assigned'].includes( requestedFilter, ) ? requestedFilter : ''; const requestedItemId = url.searchParams.get('item')?.trim() ?? ''; const { search, pattern } = getRegistrySearch(url); const sort = getRegistrySort( url, { name: { asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC', desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC', }, ability: { asc: "COALESCE((SELECT ability.name FROM specialization_abilities ability WHERE ability.id=sets.specialization_ability_id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT ability.name FROM specialization_abilities ability WHERE ability.id=sets.specialization_ability_id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC", }, twoPiece: { asc: "COALESCE((SELECT attribute.name FROM skill_chip_two_piece_bonuses bonus JOIN stat_attributes attribute ON attribute.id=bonus.stat_attribute_id WHERE bonus.skill_chip_set_id=sets.id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT attribute.name FROM skill_chip_two_piece_bonuses bonus JOIN stat_attributes attribute ON attribute.id=bonus.stat_attribute_id WHERE bonus.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC", }, talent: { asc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC", }, rollPools: { asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC', desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC', }, state: { asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC', desc: 'sets.active DESC,sets.name COLLATE NOCASE ASC', defaultDirection: 'desc', }, }, 'name', ); const conditions = [ ...(requestedItemId ? ['sets.id=?'] : filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0'] : filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL'] : filter === 'configured' ? [ 'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)', ] : []), ...(!requestedItemId && search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []), ]; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const args = [ ...(requestedItemId ? [requestedItemId] : []), ...(!requestedItemId && search ? [pattern] : []), ]; const [ summary, filtered, pendingStats, pendingSets, pendingAbilities, pendingSpecializations, ] = 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 specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned, SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.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(...args), getPendingEntities(user.id, [ { module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'], }, ]), getPendingEntities(user.id, [ { module: 'skillchips', action: 'save', kind: 'skillchip', fields: [ 'abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'hasTalent', 'talentName', 'expectedTalentId', 'authoredSource', 'expectedSourceFingerprint', 'mechanicSelectionIntent', ], }, ]), getPendingEntities(user.id, [ { module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId'], }, ]), getPendingEntities(user.id, [ { module: 'specializations', action: 'saveSpecialization', kind: 'specialization', }, ]), ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [ sets, twoPiece, talents, stats, abilities, specializations, abilityChipCounts, icons, renders, primaryProfiles, secondaryPool, primaryAttributeIds, ] = await Promise.all([ ( await database.prepare( `SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`, ) ).all(...args, pagination.perPage, pagination.offset), ( await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses') ).all(), ( await database.prepare( `SELECT id,skill_chip_set_id,name,created_at,updated_at FROM skill_chip_talents`, ) ).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,specialization_id FROM specialization_abilities WHERE active=1 ORDER BY name COLLATE NOCASE', ) ).all(), ( await database.prepare( 'SELECT id,name FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE', ) ).all(), ( await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count FROM skill_chip_sets WHERE active=1 AND specialization_ability_id IS NOT NULL GROUP BY specialization_ability_id`) ).all(), ( await database.prepare( "SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'", ) ).all(), ( await database.prepare( "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'", ) ).all(), getSkillChipPrimaryProfiles(database), getSkillChipSecondaryPool(database), getSkillChipPrimaryAttributeIds(database), ]); const availableStats = [ ...pendingStats.map((item) => ({ ...item, unit: String(item.unit ?? ''), })), ...stats, ]; const specializationNames = new Map([ ...pendingSpecializations.map( (item) => [String(item.id), String(item.name)] as const, ), ...specializations.map( (item) => [String(item.id), String(item.name)] as const, ), ]); const loadedAbilities = [ ...pendingAbilities.map((ability) => ({ ...ability, name: `${ability.name} · ${specializationNames.get(String(ability.specializationId)) ?? 'PENDING SPECIALIZATION'}`, chipCount: 0, })), ...abilities.map((ability) => ({ ...ability, name: `${ability.name} · ${specializationNames.get(String(ability.specialization_id)) ?? 'SPECIALIZATION'}`, chipCount: Number( abilityChipCounts.find( (item) => item.specialization_ability_id === ability.id, )?.count ?? 0, ), })), ]; const currentPendingSets = pendingSets.filter( (set) => Boolean(set.abilityId) && (String(set.hasTalent) !== '1' || Boolean(set.authoredSource)), ); const pendingSetRows = requestedItemId ? currentPendingSets.filter((set) => set.id === requestedItemId) : filter === 'archived' ? [] : currentPendingSets .filter((set) => filter !== 'assigned' || Boolean(set.abilityId)) .filter( (set) => !search || String(set.name) .toLocaleLowerCase() .includes(search.toLocaleLowerCase()), ) .map((set) => ({ ...set, specialization_ability_id: set.abilityId, iconUrl: '', renderUrl: '', twoPiece: { stat_attribute_id: set.twoPieceStatId, min_value: Number(set.twoPieceMin), max_value: Number(set.twoPieceMax), }, talent: String(set.hasTalent) === '1' ? { name: set.talentName, authoredSource: (() => { try { return JSON.parse(String(set.authoredSource ?? 'null')); } catch { return null; } })(), expectedSourceFingerprint: String(set.expectedSourceFingerprint ?? ''), selectedRevision: null, publicProjection: [], mechanicSelectionIntent: String(set.mechanicSelectionIntent ?? 'clear'), } : null, })); const configuredSets = await Promise.all( sets.map(async (set) => { const talent = talents.find((item) => item.skill_chip_set_id === set.id); const icon = icons.find((item) => item.entity_id === set.id); const authoredSource = talent ? await loadMechanicAuthoredSource( database, 'skill_chip_talent', String(talent.id), ) : null; const selectedRevision = authoredSource ? await loadSelectedMechanicModelRevision(database, authoredSource.id) : null; const publicProjection = authoredSource ? await loadMechanicPublicProjection(database, authoredSource.id) : []; return { ...set, iconUrl: icon ? gameDataIconUrl('skill_chip', String(set.id), icon.updated_at) : '', renderUrl: (() => { const render = renders.find( (item) => item.source_entity_id === set.id, ); return render ? managedImageUrl(String(render.id), render.updated_at) : ''; })(), twoPiece: twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null, talent: talent ? { ...talent, authoredSource: authoredSource ? { version: authoredSource.source.version, template: authoredSource.source.template, variants: authoredSource.source.variants, } : null, expectedSourceFingerprint: authoredSource?.fingerprint ?? null, selectedRevision, publicProjection, mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear', } : null, }; }), ); return { sets: [...pendingSetRows, ...configuredSets], stats: availableStats, primaryProfiles: primaryAttributeIds.map( (statId) => primaryProfiles.find((profile) => profile.statId === statId) ?? { statId, values: {}, }, ), primaryProfileBase: primaryProfiles, secondaryPool, abilities: loadedAbilities, summary: { active: Number(summary?.active ?? 0), archived: Number(summary?.archived ?? 0), configured: Number(summary?.configured ?? 0), total: Number(summary?.total ?? 0) + currentPendingSets.length, assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length, pending: currentPendingSets.length, }, filter, requestedItemId, pagination, search, sort, }; }; export const actions: Actions = { save: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const form = await request.formData(); const database = await getDatabase(); const journal = new MediaMutationJournal(); try { const save = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const input = await parseSkillChipForm(tx, form, operator.id); return _saveSkillChipInTransaction(tx, { ...input, journal }); }); const result = await save.immediate(); journal.clear(); return { skillChipSaved: true, skillChipMode: result.mode, resultToken: randomUUID(), }; } catch (error) { await journal.rollback(error); if (error instanceof SkillChipFormValidationError) return fail(error.status, { skillChipError: error.message }); if (error instanceof MechanicSourceFingerprintConflictError) return fail(409, { skillChipError: error.message }); if (error instanceof SkillChipNotFoundError) return fail(404, { skillChipError: error.message }); throw error; } }, savePrimaryProfile: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const database = await getDatabase(); const form = await request.formData(); const expectedStatIds = await getSkillChipPrimaryAttributeIds(database); const normalized = normalizeSkillChipProfiles( (() => { try { return JSON.parse(String(form.get('profiles') ?? '')); } catch { return null; } })(), SKILL_CHIP_QUALITIES, { expectedStatIds, minimumOptions: 4 }, ); if (normalized.error) return fail(400, { skillChipPrimaryProfileError: normalized.error }); await database.exec('BEGIN IMMEDIATE'); try { const before = await getSkillChipPrimaryProfiles(database); if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) { await database.exec('ROLLBACK'); if (JSON.stringify(before) === JSON.stringify(normalized.profiles)) return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID(), }; return fail(409, { skillChipPrimaryProfileError: 'The shared primary profile changed after this editor opened. Review the latest values and try again.', }); } await replaceSkillChipPrimaryProfiles(database, normalized.profiles); await refreshAllFocusReadiness(database); const after = await getSkillChipPrimaryProfiles(database); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip_primary_profile.updated', entityId: 'skill-chip-primary-profile', before, after, database, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() }; }, saveSecondaryPool: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const database = await getDatabase(); const form = await request.formData(); const normalized = normalizeSkillChipProfiles( (() => { try { return JSON.parse(String(form.get('profiles') ?? '')); } catch { return null; } })(), SKILL_CHIP_SECONDARY_QUALITIES, { minimumOptions: 2 }, ); if (normalized.error) return fail(400, { skillChipSecondaryPoolError: normalized.error }); const statIds = normalized.profiles.map((profile) => profile.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, { skillChipSecondaryPoolError: 'One or more selected Attributes are unavailable.', }); await database.exec('BEGIN IMMEDIATE'); try { const before = await getSkillChipSecondaryPool(database); if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) { await database.exec('ROLLBACK'); if (JSON.stringify(before) === JSON.stringify(normalized.profiles)) return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID(), }; return fail(409, { skillChipSecondaryPoolError: 'The shared secondary pool changed after this editor opened. Review the latest values and try again.', }); } await replaceSkillChipSecondaryPool(database, normalized.profiles); await refreshAllFocusReadiness(database); const after = await getSkillChipSecondaryPool(database); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip_secondary_pool.updated', entityId: 'skill-chip-secondary-pool', before, after, database, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); await database.exec('BEGIN IMMEDIATE'); try { const before = await snapshot(database, id); if (!before.record) { await database.exec('ROLLBACK'); return fail(404, { skillChipError: 'Skill Chip set not found.' }); } await ( await database.prepare( 'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?', ) ).run(new Date().toISOString(), id); await refreshAbilityFocuses( database, String(before.record.specialization_ability_id), ); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(database, id), database, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { skillChipArchived: true, skillChipId: id, resultToken: randomUUID(), }; }, unarchive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); await database.exec('BEGIN IMMEDIATE'); try { const before = await snapshot(database, id); if (!before.record) { await database.exec('ROLLBACK'); return fail(404, { skillChipError: 'Skill Chip set not found.' }); } const abilityId = String(before.record.specialization_ability_id); if ( !(await ( await database.prepare( 'SELECT id FROM specialization_abilities WHERE id=? AND active=1', ) ).get(abilityId)) ) { await database.exec('ROLLBACK'); return fail(400, { skillChipError: 'The assigned specialization ability must be active before restoring this Skill Chip.', }); } const assignedCount = Number( ( await ( await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets WHERE specialization_ability_id=? AND active=1 AND id<>?`) ).get(abilityId, id) )?.count ?? 0, ); if (assignedCount >= 3) { await database.exec('ROLLBACK'); return fail(409, { skillChipError: 'That ability already has all three active Skill Chips assigned.', }); } await ( await database.prepare( 'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?', ) ).run(new Date().toISOString(), id); await refreshAbilityFocuses(database, abilityId); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.unarchived', entityId: id, before, after: await snapshot(database, id), database, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { skillChipUnarchived: true, skillChipId: id, resultToken: randomUUID(), }; }, };