import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Database } from '@tursodatabase/database'; import type { Actions, PageServerLoad } from './$types'; import { requireDataView, requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { deserializeProposal, getPendingEntities } from '$lib/server/data-moderation'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { refreshSpecializationFocusReady, refreshSpecializationFocuses } from '$lib/server/specialization-readiness'; const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const; const RANK_TWO_RESOURCES = new Set([ 'resource-electronics-alpha', 'resource-electronics-beta', 'resource-electronics-gamma' ]); const OMEGA_RESOURCE = 'resource-electronics-omega'; const EFFECT_UNITS = new Set(['', '%', 's', 'Shots', 'm']); type AbilityEffectInput = { type?: string; statId?: string; label?: string; value?: number | string | null; unit?: string; }; type RankEffectInput = AbilityEffectInput & { type?: string; abilityId?: string; }; type RankCombatModifierInput = { statId?: string; valuePerStack?: number | string | null; unit?: string; }; type RankCombatEffectInput = { effectId?: string; triggerCount?: number | string; triggerWindowSeconds?: number | string; durationSeconds?: number | string; maxStacks?: number | string; stacksPerTrigger?: number | string; modifiers?: RankCombatModifierInput[]; }; type RankInput = { rank?: number; description?: string; effects?: RankEffectInput[]; combatEffects?: RankCombatEffectInput[]; costs?: Record; }; const cleanName = (value: unknown) => String(value ?? '').normalize('NFKC').trim(); const optionalPositive = (value: unknown) => { const text = String(value ?? '').trim(); if (!text) return null; const numeric = Number(text); return Number.isFinite(numeric) && numeric > 0 ? numeric : Number.NaN; }; const parseJsonArray = (value: unknown): T[] => { try { const parsed = JSON.parse(String(value ?? '[]')); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }; async function specializationSnapshot(database: Database, id: string) { const record = await auditRecord('specializations', id); const focusIds = (await (await database.prepare( 'SELECT id FROM specialization_focuses WHERE specialization_id=? ORDER BY display_order,name' )).all(id)).map((row) => String(row.id)); const abilityIds = (await (await database.prepare( 'SELECT id FROM specialization_abilities WHERE specialization_id=? ORDER BY name' )).all(id)).map((row) => String(row.id)); return { record, icon: await gameDataIconMetadata(database, 'specialization', id), focusIds, abilityIds }; } async function focusSnapshot(database: Database, id: string) { const record = await auditRecord('specialization_focuses', id); const abilities = await (await database.prepare( 'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot' )).all(id); const nodeIds = (await (await database.prepare( 'SELECT id FROM specialization_tree_nodes WHERE focus_id=? ORDER BY row_number,column_number' )).all(id)).map((row) => String(row.id)); return { record, abilities, nodeIds }; } async function abilitySnapshot(database: Database, id: string) { return { record: await auditRecord('specialization_abilities', id), effects: await (await database.prepare( 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order' )).all(id), icon: await gameDataIconMetadata(database, 'specialization_ability', id) }; } async function nodeSnapshot(database: Database, id: string) { const combatEffects = await (await database.prepare( 'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order' )).all(id); return { record: await auditRecord('specialization_tree_nodes', id), ranks: await (await database.prepare( 'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank' )).all(id), effects: await (await database.prepare( 'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order' )).all(id), costs: await (await database.prepare( 'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id' )).all(id), combatEffects, combatModifiers: combatEffects.length ? await (await database.prepare( `SELECT modifier.* FROM specialization_tree_rank_combat_modifiers modifier JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=modifier.rank_combat_effect_id WHERE attachment.node_id=? ORDER BY attachment.rank,attachment.effect_order,modifier.modifier_order` )).all(id) : [], icon: await gameDataIconMetadata(database, 'specialization_node', id) }; } async function combatEffectSnapshot(database: Database, id: string) { return { record: await auditRecord('specialization_combat_effects', id), attachments: await (await database.prepare( 'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order' )).all(id) }; } async function validateAttributeIds(database: Database, ids: string[]) { const unique = [...new Set(ids.filter(Boolean))]; if (!unique.length) return true; const count = Number((await (await database.prepare( `SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${unique.map(() => '?').join(',')})` )).get(...unique))?.count ?? 0); return count === unique.length; } async function validateAbilityIds(database: Database, specializationId: string, ids: string[]) { const unique = [...new Set(ids.filter(Boolean))]; if (!unique.length) return true; const count = Number((await (await database.prepare( `SELECT COUNT(*) AS count FROM specialization_abilities WHERE active=1 AND specialization_id=? AND id IN (${unique.map(() => '?').join(',')})` )).get(specializationId, ...unique))?.count ?? 0); return count === unique.length; } export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); const filter = ['active', 'ready', 'draft', 'archived'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const { search, pattern } = getRegistrySearch(url); const conditions = [ ...(filter === 'active' ? ['specializations.active=1'] : filter === 'ready' ? ['EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)'] : filter === 'draft' ? ['specializations.active=1 AND NOT EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)'] : filter === 'archived' ? ['specializations.active=0'] : []), ...(search ? ["specializations.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) ]; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const args = search ? [pattern] : []; const pending = await getPendingEntities(user.id, [ { module: 'specializations', action: 'saveSpecialization', kind: 'specialization', fields: ['description'] }, { module: 'specializations', action: 'saveFocus', kind: 'focus', fields: ['specializationId', 'description', 'primaryAttribute', 'displayOrder'] }, { module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson'] }, { module: 'specializations', action: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] }, { module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] } ]); const pendingSpecializations = pending.filter((item) => item.kind === 'specialization') .filter((item) => !search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())); const pendingAbilitySetRows = await (await database.prepare(`SELECT entity_id,payload_json,created_at FROM data_change_proposals WHERE module='specializations' AND action='saveFocusAbilities' AND status='pending' AND entity_id IS NOT NULL ORDER BY created_at DESC`)).all(); const pendingAbilitySets = new Map(); for (const row of pendingAbilitySetRows) { const focusId = String(row.entity_id); if (pendingAbilitySets.has(focusId)) continue; const proposal = deserializeProposal(String(row.payload_json)); pendingAbilitySets.set(focusId, [1, 2, 3].map((slot) => String(proposal.get(`abilityId${slot}`) ?? ''))); } const canonicalTotal = Number((await (await database.prepare( `SELECT COUNT(*) AS count FROM specializations ${where}` )).get(...args))?.count ?? 0); const pagination = getPagination(url, canonicalTotal + pendingSpecializations.length); const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : []; const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([ databaseLimit ? (await database.prepare( `SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?` )).all(...args, databaseLimit, databaseOffset) : [], (await database.prepare('SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_ability_effects ORDER BY ability_id,effect_order')).all(), (await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(), (await database.prepare('SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number')).all(), (await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(), (await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(), (await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(), (await database.prepare('SELECT * FROM specialization_combat_effects ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(), (await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(), (await database.prepare('SELECT * FROM upgrade_resources WHERE active=1 ORDER BY resource_type,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(), (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(), (await database.prepare('SELECT id,name,unit FROM stat_attributes 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_type,entity_id,updated_at FROM game_data_icons WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)).all(), (await database.prepare(`SELECT (SELECT COUNT(*) FROM specializations) AS total, (SELECT COUNT(*) FROM specializations WHERE active=1) AS active, (SELECT COUNT(*) FROM specializations WHERE active=0) AS archived, (SELECT COUNT(*) FROM specializations specialization WHERE specialization.active=1 AND EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specialization.id AND focus.builder_ready=1)) AS ready, (SELECT COUNT(*) FROM specializations specialization WHERE specialization.active=1 AND NOT EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specialization.id AND focus.builder_ready=1)) AS draft, (SELECT COUNT(*) FROM specialization_focuses WHERE active=1) AS focuses, (SELECT COUNT(*) FROM specialization_focuses WHERE builder_ready=1) AS ready_focuses, (SELECT COUNT(*) FROM specialization_abilities WHERE active=1) AS abilities`)).get() ]); const pendingFocuses = pending.filter((item) => item.kind === 'focus'); const pendingAbilities = pending.filter((item) => item.kind === 'ability'); const pendingCombatEffects = pending.filter((item) => item.kind === 'combatEffect'); const pendingNodes = pending.filter((item) => item.kind === 'node'); const iconUrl = (type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string) => { const icon = icons.find((item) => item.entity_type === type && item.entity_id === id); return icon ? gameDataIconUrl(type, id, icon.updated_at) : ''; }; const loadedSpecializations = [ ...pendingOnPage.map((item) => ({ ...item, description: item.description, active: 1, pending: true, iconUrl: '' })), ...specializations.map((item) => ({ ...item, iconUrl: iconUrl('specialization', String(item.id)) })) ]; const loadedFocuses = [ ...pendingFocuses.map((item) => ({ ...item, specialization_id: item.specializationId, primary_attribute: item.primaryAttribute, display_order: Number(item.displayOrder || 0), builder_ready: 0, active: 1, pending: true })), ...focuses ]; const loadedAbilities = [ ...pendingAbilities.map((item) => ({ ...item, specialization_id: item.specializationId, duration_seconds: item.durationSeconds || null, cooldown_seconds: item.cooldownSeconds || null, effects: parseJsonArray(item.effectsJson), active: 1, pending: true, skillChipCount: 0, iconUrl: '' })), ...abilities.map((ability) => ({ ...ability, effects: abilityEffects.filter((effect) => effect.ability_id === ability.id), skillChipCount: Number(chipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0), iconUrl: iconUrl('specialization_ability', String(ability.id)) })) ]; const loadedNodes = [ ...pendingNodes.map((item) => ({ ...item, focus_id: item.focusId, description_template: item.descriptionTemplate, row_number: Number(item.rowNumber), column_number: Number(item.columnNumber), max_rank: Number(item.maxRank), ranks: parseJsonArray(item.ranksJson), active: 1, pending: true, iconUrl: '' })), ...nodes.map((node) => ({ ...node, ranks: nodeRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({ ...rank, effects: nodeEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)), combatEffects: nodeCombatEffects .filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)) .map((effect) => ({ ...effect, modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id) })), costs: Object.fromEntries(nodeCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank)) .map((cost) => [String(cost.resource_id), Number(cost.quantity)])) })), iconUrl: iconUrl('specialization_node', String(node.id)) })) ]; const availablePoints = Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19); return { specializations: loadedSpecializations, focuses: loadedFocuses.map((focus) => { const assigned = focusAbilities.filter((link) => link.focus_id === focus.id); const pendingAbilityIds = pendingAbilitySets.get(String(focus.id)); const focusNodes = loadedNodes.filter((node) => node.focus_id === focus.id && Number(node.active) === 1); const pointTotal = focusNodes.reduce((sum, node) => node.ranks.reduce((rankSum: number, rank: { rank: number }) => { const profile = rankProfiles.find((item) => Number(item.rank) === Number(rank.rank)); return rankSum + Number(profile?.point_cost ?? rank.rank); }, sum), 0); return { ...focus, abilityIds: pendingAbilityIds ?? [1, 2, 3].map((slot) => String(assigned.find((link) => Number(link.ability_slot) === slot)?.ability_id ?? '')), abilitySetPending: Boolean(pendingAbilityIds), pointTotal, nodeCount: focusNodes.length, maxRow: Math.max(0, ...focusNodes.map((node) => Number(node.row_number))) }; }), abilities: loadedAbilities, combatEffects: [ ...pendingCombatEffects.map((item) => ({ ...item, specialization_id: item.specializationId, description_template: item.descriptionTemplate, active: 1, pending: true })), ...combatEffects ], nodes: loadedNodes, resources, rankProfiles, progression: { ...progression, availablePoints }, attributes, summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSpecializations.length, pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'combatEffect', 'node'].includes(item.kind)).length }, pagination, filter, search }; }; export const actions: Actions = { saveProgression: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const xpPoints = Number(form.get('xpPoints')); const cachePoints = Number(form.get('cachePoints')); const rankProfiles = [1, 2, 3].map((rank) => ({ rank, pointCost: Number(form.get(`rank${rank}PointCost`)), creditCost: Number(form.get(`rank${rank}CreditCost`)) })); if (![xpPoints, cachePoints].every((value) => Number.isInteger(value) && value >= 0) || xpPoints + cachePoints <= 0) { return fail(400, { progressionError: 'Progression sources require non-negative whole-number point totals.' }); } if (rankProfiles.some((profile) => !Number.isInteger(profile.pointCost) || profile.pointCost <= 0 || !Number.isInteger(profile.creditCost) || profile.creditCost < 0)) { return fail(400, { progressionError: 'Every rank requires positive whole-number point costs and non-negative Credit costs.' }); } const database = await getDatabase(); const before = { progression: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(), ranks: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all() }; const now = new Date().toISOString(); await database.exec('BEGIN'); try { await (await database.prepare(`UPDATE specialization_progression_config SET xp_points=?,shd_cache_points=?,updated_at=? WHERE id='default'`)).run(xpPoints, cachePoints, now); for (const profile of rankProfiles) { await (await database.prepare(`UPDATE specialization_rank_cost_profiles SET point_cost=?,credit_cost=?,updated_at=? WHERE rank=?`)) .run(profile.pointCost, profile.creditCost, now, profile.rank); } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } const after = { progression: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(), ranks: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all() }; const specializationIds = await (await database.prepare('SELECT id FROM specializations')).all(); for (const specialization of specializationIds) await refreshSpecializationFocuses(database, String(specialization.id)); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization.progression_updated', entityId: 'specialization-progression', before, after }); return { progressionSaved: true, resultToken: randomUUID() }; }, saveSpecialization: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const name = cleanName(form.get('name')); const description = cleanName(form.get('description')); if (name.length < 2 || name.length > 80) return fail(400, { specializationError: 'Specialization names must be 2–80 characters.' }); if (description.length > 1200) return fail(400, { specializationError: 'Descriptions must be 1,200 characters or fewer.' }); const database = await getDatabase(); if (await (await database.prepare( 'SELECT id FROM specializations WHERE name=? COLLATE NOCASE AND id<>?' )).get(name, id || 'new')) return fail(409, { specializationError: 'That specialization name already exists.' }); let iconMutation; try { iconMutation = await readIconMutation(form); } catch (error) { return fail(400, { specializationError: error instanceof GameDataIconValidationError ? error.message : 'The specialization icon could not be processed.' }); } const specializationId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await specializationSnapshot(database, id) : null; const now = new Date().toISOString(); if (id) { await (await database.prepare( 'UPDATE specializations SET name=?,description=?,active=1,updated_at=? WHERE id=?' )).run(name, description || null, now, id); } else { await (await database.prepare( 'INSERT INTO specializations(id,name,description,created_at,updated_at) VALUES(?,?,?,?,?)' )).run(specializationId, name, description || null, now, now); } await applyIconMutation(database, 'specialization', specializationId, iconMutation); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'specialization.updated' : 'specialization.created', entityId: specializationId, before, after: await specializationSnapshot(database, specializationId) }); return { specializationSaved: true, specializationMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, saveFocus: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const specializationId = String(form.get('specializationId') ?? ''); const name = cleanName(form.get('name')); const description = cleanName(form.get('description')); const primaryAttribute = String(form.get('primaryAttribute') ?? ''); const displayOrder = Number(form.get('displayOrder') ?? 0); if (name.length < 2 || name.length > 80) return fail(400, { focusError: 'Focus names must be 2–80 characters.' }); if (description.length > 1200) return fail(400, { focusError: 'Descriptions must be 1,200 characters or fewer.' }); if (!PRIMARY_ATTRIBUTES.includes(primaryAttribute as (typeof PRIMARY_ATTRIBUTES)[number])) { return fail(400, { focusError: 'Choose Firepower, Toughness, or Engineering as the primary attribute.' }); } if (!Number.isInteger(displayOrder) || displayOrder < 0 || displayOrder > 999) { return fail(400, { focusError: 'Display order must be a whole number between 0 and 999.' }); } const database = await getDatabase(); if (id) { const existing = await (await database.prepare( 'SELECT specialization_id FROM specialization_focuses WHERE id=?' )).get(id); if (!existing) return fail(404, { focusError: 'That focus no longer exists.' }); if (String(existing.specialization_id) !== specializationId) { return fail(400, { focusError: 'A saved focus cannot be moved to another specialization.' }); } } if (!await (await database.prepare('SELECT id FROM specializations WHERE id=? AND active=1')).get(specializationId)) { return fail(400, { focusError: 'Choose an active specialization.' }); } if (await (await database.prepare( 'SELECT id FROM specialization_focuses WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?' )).get(specializationId, name, id || 'new')) return fail(409, { focusError: 'That focus already exists on this specialization.' }); const focusId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await focusSnapshot(database, id) : null; const now = new Date().toISOString(); if (id) { await (await database.prepare(`UPDATE specialization_focuses SET specialization_id=?,name=?,description=?,primary_attribute=?,display_order=?,active=1,updated_at=? WHERE id=?`)) .run(specializationId, name, description || null, primaryAttribute, displayOrder, now, id); } else { await (await database.prepare(`INSERT INTO specialization_focuses (id,specialization_id,name,description,primary_attribute,display_order,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`)) .run(focusId, specializationId, name, description || null, primaryAttribute, displayOrder, now, now); } await refreshSpecializationFocusReady(database, focusId); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'specialization_focus.updated' : 'specialization_focus.created', entityId: focusId, before, after: await focusSnapshot(database, focusId) }); return { focusSaved: true, focusMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, saveAbility: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const specializationId = String(form.get('specializationId') ?? ''); const name = cleanName(form.get('name')); const description = cleanName(form.get('description')); const durationSeconds = optionalPositive(form.get('durationSeconds')); const cooldownSeconds = optionalPositive(form.get('cooldownSeconds')); const effects = parseJsonArray(form.get('effectsJson')).map((effect) => ({ type: String(effect.type ?? (effect.statId ? 'Attribute' : 'Custom')), statId: String(effect.statId ?? ''), label: cleanName(effect.label), value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value), unit: cleanName(effect.unit) })); if (name.length < 2 || name.length > 100) return fail(400, { abilityError: 'Ability names must be 2–100 characters.' }); if (description.length < 3 || description.length > 2400) return fail(400, { abilityError: 'Ability descriptions must be 3–2,400 characters.' }); if (Number.isNaN(durationSeconds) || Number.isNaN(cooldownSeconds)) { return fail(400, { abilityError: 'Duration and cooldown must be positive when supplied.' }); } if (effects.length > 12 || effects.some((effect) => effect.label.length > 100 || (effect.label.length > 0 && effect.label.length < 2) || !['Attribute', 'Custom'].includes(effect.type) || (effect.type === 'Attribute' && !effect.statId) || (effect.value !== null && !Number.isFinite(effect.value)) || !EFFECT_UNITS.has(effect.unit))) { return fail(400, { abilityError: 'Ability effects require valid targets, optional numeric values, and labels no longer than 100 characters.' }); } if (effects.some((effect) => effect.type === 'Custom' && effect.label.length < 2)) { return fail(400, { abilityError: 'Custom effects require a concise label because they have no Attribute name to use.' }); } const database = await getDatabase(); if (id) { const existing = await (await database.prepare( 'SELECT specialization_id FROM specialization_abilities WHERE id=?' )).get(id); if (!existing) return fail(404, { abilityError: 'That class ability no longer exists.' }); if (String(existing.specialization_id) !== specializationId) { return fail(400, { abilityError: 'A saved class ability cannot be moved to another specialization.' }); } } if (!await (await database.prepare('SELECT id FROM specializations WHERE id=? AND active=1')).get(specializationId)) { return fail(400, { abilityError: 'Choose an active specialization.' }); } if (!await validateAttributeIds(database, effects.map((effect) => effect.statId))) { return fail(400, { abilityError: 'One or more selected Attributes are unavailable.' }); } const unlabeledAttributeIds = [...new Set(effects .filter((effect) => effect.type === 'Attribute' && !effect.label) .map((effect) => effect.statId))]; const fallbackRows = unlabeledAttributeIds.length ? await (await database.prepare( `SELECT id,name FROM stat_attributes WHERE id IN (${unlabeledAttributeIds.map(() => '?').join(',')})` )).all(...unlabeledAttributeIds) : []; const fallbackLabels = new Map(fallbackRows.map((row) => [String(row.id), cleanName(row.name)])); const resolvedEffects = effects.map((effect) => ({ ...effect, label: effect.label || fallbackLabels.get(effect.statId) || '' })); if (resolvedEffects.some((effect) => effect.label.length < 2)) { return fail(400, { abilityError: 'Each effect needs either a label or an available Attribute target name.' }); } if (await (await database.prepare( 'SELECT id FROM specialization_abilities WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?' )).get(specializationId, name, id || 'new')) return fail(409, { abilityError: 'That ability already exists on this specialization.' }); let iconMutation; try { iconMutation = await readIconMutation(form); } catch (error) { return fail(400, { abilityError: error instanceof GameDataIconValidationError ? error.message : 'The ability icon could not be processed.' }); } const abilityId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await abilitySnapshot(database, id) : null; const now = new Date().toISOString(); await database.exec('BEGIN'); try { if (id) { await (await database.prepare(`UPDATE specialization_abilities SET specialization_id=?,name=?,description=?,duration_seconds=?,cooldown_seconds=?,active=1,updated_at=? WHERE id=?`)) .run(specializationId, name, description, durationSeconds, cooldownSeconds, now, id); } else { await (await database.prepare(`INSERT INTO specialization_abilities (id,specialization_id,name,description,duration_seconds,cooldown_seconds,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`)) .run(abilityId, specializationId, name, description, durationSeconds, cooldownSeconds, now, now); } await (await database.prepare('DELETE FROM specialization_ability_effects WHERE ability_id=?')).run(abilityId); for (const [index, effect] of resolvedEffects.entries()) { await (await database.prepare(`INSERT INTO specialization_ability_effects (id,ability_id,effect_order,stat_attribute_id,label,value,unit) VALUES(?,?,?,?,?,?,?)`)) .run(randomUUID(), abilityId, index + 1, effect.statId || null, effect.label, effect.value, effect.unit || null); } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation); await refreshSpecializationFocuses(database, specializationId); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'specialization_ability.updated' : 'specialization_ability.created', entityId: abilityId, before, after: await abilitySnapshot(database, abilityId) }); return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, saveCombatEffect: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const specializationId = String(form.get('specializationId') ?? ''); const name = cleanName(form.get('name')); const descriptionTemplate = cleanName(form.get('descriptionTemplate')); if (name.length < 2 || name.length > 100) { return fail(400, { combatEffectError: 'Combat-effect names must be 2–100 characters.' }); } if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) { return fail(400, { combatEffectError: 'The effect description template must be 3–2,400 characters.' }); } const database = await getDatabase(); const specialization = await (await database.prepare( 'SELECT id FROM specializations WHERE id=? AND active=1' )).get(specializationId); if (!specialization) return fail(404, { combatEffectError: 'That active specialization no longer exists.' }); if (id) { const existing = await (await database.prepare( 'SELECT specialization_id FROM specialization_combat_effects WHERE id=?' )).get(id); if (!existing) return fail(404, { combatEffectError: 'That combat effect no longer exists.' }); if (String(existing.specialization_id) !== specializationId) { return fail(400, { combatEffectError: 'A saved combat effect cannot be moved to another specialization.' }); } } if (await (await database.prepare( 'SELECT id FROM specialization_combat_effects WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?' )).get(specializationId, name, id || 'new')) { return fail(409, { combatEffectError: 'That combat-effect name already exists in this specialization.' }); } const effectId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await combatEffectSnapshot(database, id) : null; const now = new Date().toISOString(); if (id) { await (await database.prepare(`UPDATE specialization_combat_effects SET name=?,description_template=?,active=1,updated_at=? WHERE id=?`)) .run(name, descriptionTemplate, now, id); } else { await (await database.prepare(`INSERT INTO specialization_combat_effects (id,specialization_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)`)).run(effectId, specializationId, name, descriptionTemplate, now, now); } await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'specialization_combat_effect.updated' : 'specialization_combat_effect.created', entityId: effectId, before, after: await combatEffectSnapshot(database, effectId) }); return { combatEffectSaved: true, combatEffectMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, saveFocusAbilities: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const focusId = String(form.get('id') ?? form.get('focusId') ?? ''); const abilityIds = [1, 2, 3].map((slot) => String(form.get(`abilityId${slot}`) ?? '')); if (abilityIds.some((id) => !id) || new Set(abilityIds).size !== 3) { return fail(400, { focusAbilitiesError: 'Assign three different class abilities.' }); } const database = await getDatabase(); const focus = await (await database.prepare( 'SELECT specialization_id FROM specialization_focuses WHERE id=? AND active=1' )).get(focusId); if (!focus) return fail(404, { focusAbilitiesError: 'That active focus no longer exists.' }); if (!await validateAbilityIds(database, String(focus.specialization_id), abilityIds)) { return fail(400, { focusAbilitiesError: 'Every assigned ability must be active and belong to the same specialization.' }); } const before = await focusSnapshot(database, focusId); await database.exec('BEGIN'); try { await (await database.prepare('DELETE FROM specialization_focus_abilities WHERE focus_id=?')).run(focusId); for (const [index, abilityId] of abilityIds.entries()) { await (await database.prepare( 'INSERT INTO specialization_focus_abilities(focus_id,ability_slot,ability_id) VALUES(?,?,?)' )).run(focusId, index + 1, abilityId); } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } const readiness = await refreshSpecializationFocusReady(database, focusId); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.abilities_updated', entityId: focusId, before, after: { ...(await focusSnapshot(database, focusId)), readiness } }); return { focusAbilitiesSaved: true, resultToken: randomUUID() }; }, saveNode: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''); const focusId = String(form.get('focusId') ?? ''); const name = cleanName(form.get('name')); const descriptionTemplate = cleanName(form.get('descriptionTemplate')); const rowNumber = Number(form.get('rowNumber')); const columnNumber = Number(form.get('columnNumber')); const maxRank = Number(form.get('maxRank')); const ranks = parseJsonArray(form.get('ranksJson')).map((rank) => ({ rank: Number(rank.rank), description: cleanName(rank.description), effects: Array.isArray(rank.effects) ? rank.effects.map((effect) => ({ type: String(effect.type ?? 'Custom'), statId: String(effect.statId ?? ''), abilityId: String(effect.abilityId ?? ''), label: cleanName(effect.label), value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value), unit: cleanName(effect.unit) })) : [], combatEffects: Array.isArray(rank.combatEffects) ? rank.combatEffects.map((effect) => ({ effectId: String(effect.effectId ?? ''), triggerCount: Number(effect.triggerCount), triggerWindowSeconds: Number(effect.triggerWindowSeconds), durationSeconds: Number(effect.durationSeconds), maxStacks: Number(effect.maxStacks), stacksPerTrigger: Number(effect.stacksPerTrigger ?? 1), modifiers: Array.isArray(effect.modifiers) ? effect.modifiers.map((modifier) => ({ statId: String(modifier.statId ?? ''), valuePerStack: Number(modifier.valuePerStack), unit: cleanName(modifier.unit) })) : [] })) : [], costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)])) })); if (name.length < 2 || name.length > 100) return fail(400, { nodeError: 'Node names must be 2–100 characters.' }); if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) { return fail(400, { nodeError: 'The shared node description must be 3–2,400 characters.' }); } if (!Number.isInteger(rowNumber) || rowNumber < 1 || rowNumber > 20 || !Number.isInteger(columnNumber) || columnNumber < 1 || columnNumber > 12) { return fail(400, { nodeError: 'Tree placement requires a row from 1–20 and column from 1–12.' }); } if (rowNumber === 1 && columnNumber <= 2) { return fail(400, { nodeError: 'Top-row columns 1 and 2 are reserved for the free class abilities.' }); } if (![1, 2, 3].includes(maxRank) || ranks.length !== maxRank || ranks.some((rank, index) => rank.rank !== index + 1 || rank.description.length < 3 || rank.description.length > 2400)) { return fail(400, { nodeError: 'Configure one ordered description for every available node rank.' }); } const allEffects = ranks.flatMap((rank) => rank.effects); const allCombatEffects = ranks.flatMap((rank) => rank.combatEffects); if (allEffects.length > 36 || allEffects.some((effect) => !['Attribute', 'Ability', 'Custom'].includes(effect.type) || effect.label.length < 2 || effect.label.length > 100 || (effect.value !== null && !Number.isFinite(effect.value)) || !EFFECT_UNITS.has(effect.unit) || (effect.type === 'Attribute' && !effect.statId) || (effect.type === 'Ability' && !effect.abilityId))) { return fail(400, { nodeError: 'Every rank effect requires a valid type, target, label, and optional numeric value.' }); } if (allCombatEffects.length > 18 || allCombatEffects.some((effect) => !effect.effectId || !Number.isInteger(effect.triggerCount) || effect.triggerCount < 1 || effect.triggerCount > 1000 || !Number.isFinite(effect.triggerWindowSeconds) || effect.triggerWindowSeconds <= 0 || effect.triggerWindowSeconds > 3600 || !Number.isFinite(effect.durationSeconds) || effect.durationSeconds <= 0 || effect.durationSeconds > 86400 || !Number.isInteger(effect.maxStacks) || effect.maxStacks < 1 || effect.maxStacks > 1000 || !Number.isInteger(effect.stacksPerTrigger) || effect.stacksPerTrigger < 1 || effect.stacksPerTrigger > effect.maxStacks || effect.modifiers.length < 1 || effect.modifiers.length > 12 || effect.modifiers.some((modifier) => !modifier.statId || !Number.isFinite(modifier.valuePerStack) || !EFFECT_UNITS.has(modifier.unit)) )) { return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' }); } const rankOneCosts = ranks[0]?.costs ?? {}; if (Object.values(rankOneCosts).some((quantity) => !Number.isInteger(quantity) || quantity !== 0)) { return fail(400, { nodeError: 'Rank 1 cannot require Electronics Components.' }); } if (maxRank >= 2) { const costs = ranks[1].costs; const invalidResource = Object.entries(costs).some(([resourceId, quantity]) => !Number.isInteger(quantity) || quantity < 0 || (!RANK_TWO_RESOURCES.has(resourceId) && quantity !== 0) ); const total = Object.entries(costs).filter(([resourceId]) => RANK_TWO_RESOURCES.has(resourceId)) .reduce((sum, [, quantity]) => sum + quantity, 0); if (invalidResource || total < 1) { return fail(400, { nodeError: 'Rank 2 requires positive whole-number quantities of Alpha, Beta, and/or Gamma Components.' }); } } if (maxRank >= 3) { const costs = ranks[2].costs; if (Number(costs[OMEGA_RESOURCE] ?? 0) !== 1 || Object.entries(costs).some(([resourceId, quantity]) => !Number.isInteger(quantity) || quantity < 0 || (resourceId !== OMEGA_RESOURCE && quantity !== 0) )) { return fail(400, { nodeError: 'Rank 3 requires exactly one Electronics Omega Component.' }); } } const database = await getDatabase(); if (id) { const existing = await (await database.prepare( 'SELECT focus_id FROM specialization_tree_nodes WHERE id=?' )).get(id); if (!existing) return fail(404, { nodeError: 'That tree node no longer exists.' }); if (String(existing.focus_id) !== focusId) { return fail(400, { nodeError: 'A saved tree node cannot be moved to another focus.' }); } } const focus = await (await database.prepare( 'SELECT specialization_id FROM specialization_focuses WHERE id=? AND active=1' )).get(focusId); if (!focus) return fail(404, { nodeError: 'That active focus no longer exists.' }); if (!await validateAttributeIds(database, allEffects.map((effect) => effect.statId))) { return fail(400, { nodeError: 'One or more effect Attributes are unavailable.' }); } if (!await validateAttributeIds(database, allCombatEffects.flatMap((effect) => effect.modifiers.map((modifier) => modifier.statId)))) { return fail(400, { nodeError: 'One or more conditional-effect Attributes are unavailable.' }); } if (!await validateAbilityIds(database, String(focus.specialization_id), allEffects.map((effect) => effect.abilityId))) { return fail(400, { nodeError: 'One or more effect abilities are unavailable for this specialization.' }); } const combatEffectIds = [...new Set(allCombatEffects.map((effect) => effect.effectId))]; if (combatEffectIds.length) { const count = Number((await (await database.prepare( `SELECT COUNT(*) AS count FROM specialization_combat_effects WHERE active=1 AND specialization_id=? AND id IN (${combatEffectIds.map(() => '?').join(',')})` )).get(String(focus.specialization_id), ...combatEffectIds))?.count ?? 0); if (count !== combatEffectIds.length) { return fail(400, { nodeError: 'One or more conditional effects are unavailable for this specialization.' }); } } if (await (await database.prepare( 'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND name=? COLLATE NOCASE AND id<>?' )).get(focusId, name, id || 'new')) return fail(409, { nodeError: 'That node name already exists in this focus.' }); if (await (await database.prepare( 'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND row_number=? AND column_number=? AND id<>?' )).get(focusId, rowNumber, columnNumber, id || 'new')) return fail(409, { nodeError: 'Another node already occupies that tree position.' }); let iconMutation; try { iconMutation = await readIconMutation(form); } catch (error) { return fail(400, { nodeError: error instanceof GameDataIconValidationError ? error.message : 'The node icon could not be processed.' }); } const nodeId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await nodeSnapshot(database, id) : null; const now = new Date().toISOString(); await database.exec('BEGIN'); try { if (id) { await (await database.prepare(`UPDATE specialization_tree_nodes SET focus_id=?,name=?,description_template=?,row_number=?,column_number=?,max_rank=?,active=1,updated_at=? WHERE id=?`)) .run(focusId, name, descriptionTemplate, rowNumber, columnNumber, maxRank, now, id); } else { await (await database.prepare(`INSERT INTO specialization_tree_nodes (id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`)) .run(nodeId, focusId, name, descriptionTemplate, rowNumber, columnNumber, maxRank, now, now); } await (await database.prepare('DELETE FROM specialization_tree_node_ranks WHERE node_id=?')).run(nodeId); for (const rank of ranks) { await (await database.prepare( 'INSERT INTO specialization_tree_node_ranks(node_id,rank,description) VALUES(?,?,?)' )).run(nodeId, rank.rank, rank.description); for (const [index, effect] of rank.effects.entries()) { await (await database.prepare(`INSERT INTO specialization_tree_rank_effects (id,node_id,rank,effect_order,effect_type,stat_attribute_id,ability_id,label,value,unit) VALUES(?,?,?,?,?,?,?,?,?,?)`)) .run(randomUUID(), nodeId, rank.rank, index + 1, effect.type, effect.statId || null, effect.abilityId || null, effect.label, effect.value, effect.unit || null); } for (const [index, effect] of rank.combatEffects.entries()) { const attachmentId = randomUUID(); await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_effects (id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,trigger_window_seconds, duration_seconds,max_stacks,stacks_per_trigger,counter_policy,refresh_policy,expiry_policy) VALUES(?,?,?,?,?,'enemy_hits_within_window',?,?,?,?,?,'reset_on_proc','refresh_on_stack_gain','clear_all')`)) .run(attachmentId, nodeId, rank.rank, effect.effectId, index + 1, effect.triggerCount, effect.triggerWindowSeconds, effect.durationSeconds, effect.maxStacks, effect.stacksPerTrigger); for (const [modifierIndex, modifier] of effect.modifiers.entries()) { await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_modifiers (id,rank_combat_effect_id,modifier_order,stat_attribute_id,value_per_stack,unit) VALUES(?,?,?,?,?,?)`)) .run(randomUUID(), attachmentId, modifierIndex + 1, modifier.statId, modifier.valuePerStack, modifier.unit || null); } } for (const [resourceId, quantity] of Object.entries(rank.costs)) { if (Number(quantity) > 0) { await (await database.prepare(`INSERT INTO specialization_tree_rank_costs (node_id,rank,resource_id,quantity) VALUES(?,?,?,?)`)) .run(nodeId, rank.rank, resourceId, quantity); } } } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } await applyIconMutation(database, 'specialization_node', nodeId, iconMutation); const readiness = await refreshSpecializationFocusReady(database, focusId); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'specialization_node.updated' : 'specialization_node.created', entityId: nodeId, before, after: { ...(await nodeSnapshot(database, nodeId)), readiness } }); return { nodeSaved: true, nodeMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archiveSpecialization: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const before = await specializationSnapshot(database, id); await (await database.prepare('UPDATE specializations SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization.archived', entityId: id, before, after: await specializationSnapshot(database, id) }); return { specializationArchived: true, resultToken: randomUUID() }; }, unarchiveSpecialization: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const before = await specializationSnapshot(database, id); await (await database.prepare('UPDATE specializations SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization.unarchived', entityId: id, before, after: await specializationSnapshot(database, id) }); return { specializationUnarchived: true, resultToken: randomUUID() }; }, archiveFocus: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const before = await focusSnapshot(database, id); await (await database.prepare( 'UPDATE specialization_focuses SET active=0,builder_ready=0,updated_at=? WHERE id=?' )).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.archived', entityId: id, before, after: await focusSnapshot(database, id) }); return { focusArchived: true, resultToken: randomUUID() }; }, unarchiveFocus: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const before = await focusSnapshot(database, id); await (await database.prepare('UPDATE specialization_focuses SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); const readiness = await refreshSpecializationFocusReady(database, id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.unarchived', entityId: id, before, after: { ...(await focusSnapshot(database, id)), readiness } }); return { focusUnarchived: true, resultToken: randomUUID() }; }, archiveAbility: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id); const before = await abilitySnapshot(database, id); await (await database.prepare('UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id)); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.archived', entityId: id, before, after: await abilitySnapshot(database, id) }); return { abilityArchived: true, resultToken: randomUUID() }; }, unarchiveAbility: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id); const before = await abilitySnapshot(database, id); await (await database.prepare('UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id)); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.unarchived', entityId: id, before, after: await abilitySnapshot(database, id) }); return { abilityUnarchived: true, resultToken: randomUUID() }; }, archiveNode: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id); const before = await nodeSnapshot(database, id); await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null; await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); return { nodeArchived: true, resultToken: randomUUID() }; }, unarchiveNode: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); const database = await getDatabase(); const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id); const before = await nodeSnapshot(database, id); await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null; await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); return { nodeUnarchived: true, resultToken: randomUUID() }; } };