import { 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 { getPagination, getRegistrySearch, getRegistrySort, } from '$lib/server/pagination'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { gameDataIconUrl } from '$lib/server/game-data-icons'; import { managedImageUrl } from '$lib/server/managed-images'; import { getPendingEntities } from '$lib/server/data-moderation'; import { MechanicSourceFingerprintConflictError, } from '$lib/server/mechanic-authored-sources'; import { ProtocolFormValidationError, ProtocolNotFoundError, loadProtocolTalentMechanic, parseProtocolForm, protocolSnapshot, saveProtocolInTransaction, } from './protocol-save'; import { getOsProtocolProfileAttributeIds, type OsProtocolAttributeType, } from '$lib/server/os-protocol-profiles'; import type { Database } from '@tursodatabase/database'; import { MediaMutationJournal } from '$lib/server/media-journal'; // SvelteKit permits underscored exports from route modules. Keep this alias // available to Data Oversight and transaction-focused tests without exposing a // non-route page export. export { saveProtocolInTransaction as _saveProtocolInTransaction, } from './protocol-save'; const qualities = ['Standard', 'Specialized', 'Superior', 'High-end']; const attributeTypes = ['Firepower', 'Toughness', 'Engineering']; const secondaryRoles = ['Core', 'Damage', 'Health']; async function profileSnapshot(id: string, suppliedDatabase?: Database) { const database = suppliedDatabase ?? (await getDatabase()); return { record: (await ( await database.prepare( 'SELECT * FROM os_protocol_secondary_profiles WHERE id=?', ) ).get(id)) ?? null, effects: await ( await database.prepare( 'SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role', ) ).all(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 = [ ...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived', ].includes(requestedFilter) ? requestedFilter : ''; const requestedItemId = url.searchParams.get('item')?.trim() ?? ''; const conditions: string[] = []; const args: unknown[] = []; const { search, pattern } = getRegistrySearch(url); const sort = getRegistrySort( url, { name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC', }, quality: { asc: "CASE quality WHEN 'Standard' THEN 1 WHEN 'Specialized' THEN 2 WHEN 'Superior' THEN 3 WHEN 'High-end' THEN 4 ELSE 5 END ASC,name COLLATE NOCASE ASC", desc: "CASE quality WHEN 'Standard' THEN 1 WHEN 'Specialized' THEN 2 WHEN 'Superior' THEN 3 WHEN 'High-end' THEN 4 ELSE 5 END DESC,name COLLATE NOCASE ASC", }, type: { asc: 'primary_attribute_type COLLATE NOCASE ASC,name COLLATE NOCASE ASC', desc: 'primary_attribute_type COLLATE NOCASE DESC,name COLLATE NOCASE ASC', }, primary: { asc: "COALESCE((SELECT attribute.name FROM os_protocol_primary_effects effect JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id WHERE effect.protocol_id=os_protocols.id),'') COLLATE NOCASE ASC,name COLLATE NOCASE ASC", desc: "COALESCE((SELECT attribute.name FROM os_protocol_primary_effects effect JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id WHERE effect.protocol_id=os_protocols.id),'') COLLATE NOCASE DESC,name COLLATE NOCASE ASC", }, talent: { asc: "COALESCE((SELECT talent.name FROM os_protocol_talents talent WHERE talent.protocol_id=os_protocols.id),'') COLLATE NOCASE ASC,name COLLATE NOCASE ASC", desc: "COALESCE((SELECT talent.name FROM os_protocol_talents talent WHERE talent.protocol_id=os_protocols.id),'') COLLATE NOCASE DESC,name COLLATE NOCASE ASC", }, state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc', }, }, 'name', ); if (requestedItemId) { conditions.push('id=?'); args.push(requestedItemId); } else if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`); else if (qualities.some((quality) => quality.toLowerCase() === filter)) { conditions.push('quality=?'); args.push(qualities.find((quality) => quality.toLowerCase() === filter)); } else if (attributeTypes.some((type) => type.toLowerCase() === filter)) { conditions.push('primary_attribute_type=?'); args.push(attributeTypes.find((type) => type.toLowerCase() === filter)); } if (!requestedItemId && search) { conditions.push("name LIKE ? ESCAPE '\\' COLLATE NOCASE"); args.push(pattern); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const [summary, filtered] = 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 quality='Standard' THEN 1 ELSE 0 END) AS standard, SUM(CASE WHEN quality='Specialized' THEN 1 ELSE 0 END) AS specialized, SUM(CASE WHEN quality='Superior' THEN 1 ELSE 0 END) AS superior, SUM(CASE WHEN quality='High-end' THEN 1 ELSE 0 END) AS high_end FROM os_protocols`) ).get(), ( await database.prepare( `SELECT COUNT(*) AS total FROM os_protocols ${where}`, ) ).get(...args), ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [ protocols, primaryEffects, profiles, secondaryEffects, talents, stats, icons, renders, pending, ] = await Promise.all([ ( await database.prepare( `SELECT * FROM os_protocols ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`, ) ).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(), ( await database.prepare( 'SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type', ) ).all(), ( await database.prepare( 'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role', ) ).all(), ( await database.prepare( 'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_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 entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'", ) ).all(), ( await database.prepare( "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='os_protocol_render'", ) ).all(), getPendingEntities(user.id, [ { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'], }, { module: 'protocols', action: 'save', kind: 'protocol', fields: [ 'quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'hasTalent', 'talentName', 'expectedTalentId', 'authoredSource', 'expectedSourceFingerprint', 'mechanicSelectionIntent', ], }, ]), ]); const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter( (effect) => effect.profile_id === profile.id, ), })); const talentMechanics = new Map( await Promise.all( talents.map(async (talent) => [ String(talent.id), await loadProtocolTalentMechanic(database, String(talent.id)), ] as const), ), ); const pendingProtocols = pending .filter((item) => item.kind === 'protocol') .filter((item) => requestedItemId ? item.id === requestedItemId : filter === 'archived' ? false : qualities.some((quality) => quality.toLowerCase() === filter) ? String(item.quality).toLowerCase() === filter : attributeTypes.some((type) => type.toLowerCase() === filter) ? String(item.attributeType).toLowerCase() === filter : true, ) .filter( (item) => requestedItemId || !search || String(item.name) .toLocaleLowerCase() .includes(search.toLocaleLowerCase()), ) .map((protocol) => ({ ...protocol, iconUrl: '', renderUrl: '', primary_attribute_type: protocol.attributeType, primaryEffect: { stat_attribute_id: protocol.primaryStatId, base_value: Number(protocol.primaryBaseValue), }, secondaryProfile: configuredProfiles.find( (profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.attributeType, ) ?? null, talent: protocol.hasTalent === '1' ? { name: protocol.talentName, authoredSource: null, expectedSourceFingerprint: null, selectedRevision: null, modelDraft: null, } : null, })); const configuredProtocols = await Promise.all( protocols.map(async (protocol) => { const talent = talents.find((item) => item.protocol_id === protocol.id); return { ...protocol, iconUrl: (() => { const icon = icons.find((item) => item.entity_id === protocol.id); return icon ? gameDataIconUrl( 'os_protocol', String(protocol.id), icon.updated_at, ) : ''; })(), renderUrl: (() => { const render = renders.find( (item) => item.source_entity_id === protocol.id, ); return render ? managedImageUrl(String(render.id), render.updated_at) : ''; })(), primaryEffect: primaryEffects.find((item) => item.protocol_id === protocol.id) ?? null, secondaryProfile: configuredProfiles.find( (profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type, ) ?? null, talent: talent ? { ...talent, authoredSource: talentMechanics.get(String(talent.id))?.authoredSource.source ?? null, expectedSourceFingerprint: talentMechanics.get(String(talent.id))?.authoredSource .fingerprint ?? null, selectedRevision: talentMechanics.get(String(talent.id))?.selectedRevision ?? null, publicProjection: talentMechanics.get(String(talent.id))?.projection ?? [], } : null, }; }), ); return { protocols: [...pendingProtocols, ...configuredProtocols], profiles: qualities.flatMap((quality) => attributeTypes.map( (attributeType) => configuredProfiles.find( (profile) => profile.quality === quality && profile.primary_attribute_type === attributeType, ) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [], }, ), ), stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length, }, filter, requestedItemId, pagination, search, sort, }; }; export const actions: Actions = { saveProfile: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const form = await request.formData(); const quality = String(form.get('quality') ?? ''); const attributeType = String(form.get('attributeType') ?? ''); if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { profileError: 'Choose a valid quality and attribute type.', }); const database = await getDatabase(); const attributeIds = await getOsProtocolProfileAttributeIds( database, attributeType as OsProtocolAttributeType, ); if (Object.values(attributeIds).some((id) => !id)) return fail(400, { profileError: 'Create the required active core, damage, and Health Attributes before configuring this profile.', }); const effects = secondaryRoles.map((role) => { const rawValue = form.get(`value${role}`); return { role, statId: attributeIds[role as keyof typeof attributeIds] ?? '', value: typeof rawValue === 'string' && rawValue.trim() ? Number(rawValue) : Number.NaN, }; }); if ( effects.some( (effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0, ) ) return fail(400, { profileError: 'Every shared secondary attribute requires a non-negative value.', }); const saveProfile = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const existing = await ( await tx.prepare( 'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?', ) ).get(quality, attributeType); const profileId = String(existing?.id ?? randomUUID()); const now = new Date().toISOString(); const before = existing ? await profileSnapshot(profileId, tx) : null; if (existing) await ( await tx.prepare( 'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?', ) ).run(now, profileId); else await ( await tx.prepare( 'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)', ) ).run(profileId, quality, attributeType, now, now); await ( await tx.prepare( 'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?', ) ).run(profileId); for (const effect of effects) await ( await tx.prepare( 'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)', ) ).run( randomUUID(), profileId, effect.role, effect.statId, effect.value, ); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: existing ? 'os_protocol_profile.updated' : 'os_protocol_profile.created', entityId: profileId, before, after: await profileSnapshot(profileId, tx), database: tx, }); }); await saveProfile.immediate(); return { profileSaved: true, resultToken: randomUUID() }; }, 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 saveProtocol = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const input = await parseProtocolForm(tx, form, operator.id); return saveProtocolInTransaction(tx, { ...input, journal }); }); const result = await saveProtocol.immediate(); journal.clear(); return { protocolSaved: true, protocolMode: result.mode, resultToken: randomUUID(), }; } catch (error) { await journal.rollback(error); if (error instanceof ProtocolFormValidationError) return fail(error.status, { protocolError: error.message }); if (error instanceof MechanicSourceFingerprintConflictError) return fail(409, { protocolError: error.message }); if (error instanceof ProtocolNotFoundError) return fail(404, { protocolError: error.message }); throw error; } }, archive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? '').trim(); const database = await getDatabase(); const archiveProtocol = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const before = await protocolSnapshot(id, tx); if (!before.record) throw new ProtocolNotFoundError(); await ( await tx.prepare( 'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?', ) ).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'os_protocol.archived', entityId: id, before, after: await protocolSnapshot(id, tx), database: tx, }); }); try { await archiveProtocol.immediate(); } catch (error) { if (error instanceof ProtocolNotFoundError) return fail(404, { protocolError: error.message }); throw error; } return { protocolArchived: true, protocolId: id, resultToken: randomUUID() }; }, unarchive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? '').trim(); const database = await getDatabase(); const unarchiveProtocol = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const before = await protocolSnapshot(id, tx); if (!before.record) throw new ProtocolNotFoundError(); await ( await tx.prepare( 'UPDATE os_protocols SET active=1,updated_at=? WHERE id=?', ) ).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'os_protocol.unarchived', entityId: id, before, after: await protocolSnapshot(id, tx), database: tx, }); }); try { await unarchiveProtocol.immediate(); } catch (error) { if (error instanceof ProtocolNotFoundError) return fail(404, { protocolError: error.message }); throw error; } return { protocolUnarchived: true, protocolId: id, resultToken: randomUUID() }; }, };