import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import { mkdir, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; 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, getRegistrySort, } from '$lib/server/pagination'; import { deserializeProposal, getPendingEntities, } from '$lib/server/data-moderation'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconStorageDirectory, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readGameDataIcon, readIconMutation, type GameDataIconType, } from '$lib/server/game-data-icons'; import { refreshSpecializationFocusReady, refreshSpecializationFocuses, } from '$lib/server/specialization-readiness'; import { conditionalRuleSnapshot, loadConditionalRuleDraft, parseConditionalRulePayload, saveConditionalRuleInTransaction, } from '$lib/server/conditional-effects'; import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation, } from '$lib/server/managed-images'; 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 RankCombatEffectInput = { effectId?: string; conditionalRule?: unknown; }; 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 []; } }; const parsedJsonObject = (value: unknown) => { try { const parsed = JSON.parse(String(value ?? '{}')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } }; async function restoreIconFile( type: GameDataIconType, entityId: string, previousIcon: Buffer | null, ) { const iconPath = path.join( gameDataIconStorageDirectory(), `${type}-${entityId}.webp`, ); if (previousIcon) { await mkdir(gameDataIconStorageDirectory(), { recursive: true }); await writeFile(iconPath, previousIcon, { flag: 'w', mode: 0o600 }); return; } await unlink(iconPath).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error; }); } const mechanicOutcomes = (rule: ConditionalMechanicDraftV1) => rule.clauses.flatMap((clause) => clause.outcomes); 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), render: (await ( await database.prepare( "SELECT id,storage_key,updated_at FROM managed_images WHERE source_entity_type='specialization_render' AND source_entity_id=?", ) ).get(id)) ?? null, 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), skillChipLinks: await ( await database.prepare( `SELECT ability_id,compatible_ability_id FROM specialization_ability_skill_chip_links WHERE ability_id=? OR compatible_ability_id=? ORDER BY ability_id,compatible_ability_id`, ) ).all(id, id), attack: (await ( await database.prepare( 'SELECT * FROM specialization_ability_attacks WHERE ability_id=?', ) ).get(id)) ?? null, 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), render: (await ( await database.prepare( "SELECT id,storage_key,updated_at FROM managed_images WHERE source_entity_type='specialization_ability_render' AND source_entity_id=?", ) ).get(id)) ?? null, }; } 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); const conditionalRules = await Promise.all( combatEffects.map((effect) => conditionalRuleSnapshot( database, 'specialization_tree_rank', String(effect.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) : [], conditionalRules, icon: await gameDataIconMetadata(database, 'specialization_node', id), render: (await ( await database.prepare( "SELECT id,storage_key,updated_at FROM managed_images WHERE source_entity_type='specialization_node_render' AND source_entity_id=?", ) ).get(id)) ?? null, }; } 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 requestedItemId = url.searchParams.get('item')?.trim() ?? ''; const { search, pattern } = getRegistrySearch(url); const sort = getRegistrySort( url, { name: { asc: 'specializations.name COLLATE NOCASE ASC,specializations.id ASC', desc: 'specializations.name COLLATE NOCASE DESC,specializations.id DESC', }, focuses: { asc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id) ASC,specializations.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id) DESC,specializations.name COLLATE NOCASE ASC', }, abilities: { asc: '(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=specializations.id) ASC,specializations.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=specializations.id) DESC,specializations.name COLLATE NOCASE ASC', }, readiness: { asc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1) ASC,specializations.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1) DESC,specializations.name COLLATE NOCASE ASC', }, state: { asc: 'specializations.active ASC,specializations.name COLLATE NOCASE ASC', desc: 'specializations.active DESC,specializations.name COLLATE NOCASE ASC', defaultDirection: 'desc', }, }, 'name', ); const conditions = [ ...(requestedItemId ? ['specializations.id=?'] : 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'] : []), ...(!requestedItemId && search ? ["specializations.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []), ]; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const args = [ ...(requestedItemId ? [requestedItemId] : []), ...(!requestedItemId && 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', 'attackJson', 'skillChipCompatibleAbilityId', ], }, { 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) => requestedItemId ? item.id === requestedItemId : !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, abilitySkillChipLinks, abilityAttacks, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, renders, summary, ] = await Promise.all([ databaseLimit ? ( await database.prepare( `SELECT * FROM specializations ${where} ORDER BY ${sort.orderBy} 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_skill_chip_links ORDER BY ability_id,compatible_ability_id', ) ).all(), ( await database.prepare('SELECT * FROM specialization_ability_attacks') ).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 source_entity_type,source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type IN ('specialization_render','specialization_ability_render','specialization_node_render')`) ).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 conditionalDrafts = new Map( await Promise.all( nodeCombatEffects.map( async (effect) => [ String(effect.id), await loadConditionalRuleDraft( database, 'specialization_tree_rank', String(effect.id), ['base'], ), ] as const, ), ), ); 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 renderUrl = ( type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string, ) => { const render = renders.find( (item) => item.source_entity_type === `${type}_render` && item.source_entity_id === id, ); return render ? managedImageUrl(String(render.id), render.updated_at) : ''; }; const loadedSpecializations = [ ...pendingOnPage.map((item) => ({ ...item, description: item.description, active: 1, pending: true, iconUrl: '', renderUrl: '', })), ...specializations.map((item) => ({ ...item, iconUrl: iconUrl('specialization', String(item.id)), renderUrl: renderUrl('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 linkedAbilityById = new Map(); for (const link of abilitySkillChipLinks) { linkedAbilityById.set( String(link.ability_id), String(link.compatible_ability_id), ); linkedAbilityById.set( String(link.compatible_ability_id), String(link.ability_id), ); } const loadedAbilities = [ ...pendingAbilities.map((item) => ({ ...item, specialization_id: item.specializationId, duration_seconds: item.durationSeconds || null, cooldown_seconds: item.cooldownSeconds || null, attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null, effects: parseJsonArray(item.effectsJson), skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '', active: 1, pending: true, skillChipCount: 0, iconUrl: '', renderUrl: '', })), ...abilities.map((ability) => ({ ...ability, attack: abilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null, effects: abilityEffects.filter( (effect) => effect.ability_id === ability.id, ), skillChipCount: Number( chipCounts.find((item) => item.specialization_ability_id === ability.id) ?.count ?? 0, ), skillChipCompatibleAbilityId: linkedAbilityById.get(String(ability.id)) ?? '', iconUrl: iconUrl('specialization_ability', String(ability.id)), renderUrl: renderUrl('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: '', renderUrl: '', })), ...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, ), conditionalRule: conditionalDrafts.get(String(effect.id)) ?? null, })), 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)), renderUrl: renderUrl('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, requestedItemId, 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, sort, }; }; 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 renderMutation = await readGameDataRenderMutation(form); try { if (renderMutation.file) renderMutation.processed = await processManagedImage( renderMutation.file, false, false, true, ); } catch (error) { return fail(400, { specializationError: error instanceof ManagedImageValidationError ? error.message : 'The specialization render could not be processed.', }); } 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, operator.id, ); await applyGameDataRender( database, 'specialization', specializationId, name, renderMutation, operator.id, ); 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 abilityId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const specializationId = String(form.get('specializationId') ?? ''); const skillChipCompatibleAbilityId = String( form.get('skillChipCompatibleAbilityId') ?? '', ); 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), })); let attack: null | { baseDamage: number; damageType: string; delivery: string; hitsPerActivation: number; tickIntervalSeconds: number | null; damageDurationSeconds: number | null; castSeconds: number; targetCap: number; chargeCount: number; chargePolicy: string; } = null; try { const raw = JSON.parse( String(form.get('attackJson') ?? '{"enabled":false}'), ); if (raw?.enabled) { attack = { baseDamage: Number(raw.baseDamage), damageType: String(raw.damageType ?? ''), delivery: String(raw.delivery ?? ''), hitsPerActivation: Number(raw.hitsPerActivation ?? 1), tickIntervalSeconds: raw.delivery === 'periodic' ? Number(raw.tickIntervalSeconds) : null, damageDurationSeconds: raw.delivery === 'periodic' ? Number(raw.damageDurationSeconds) : null, castSeconds: Number(raw.castSeconds ?? 0), targetCap: Number(raw.targetCap ?? 1), chargeCount: raw.chargePolicy === 'none' ? 1 : Number(raw.chargeCount ?? 1), chargePolicy: String(raw.chargePolicy ?? 'none'), }; } } catch { return fail(400, { abilityError: 'The ability attack profile could not be read.', }); } 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.', }); } if ( attack && (!Number.isFinite(attack.baseDamage) || attack.baseDamage <= 0 || !['Blast', 'Pierce', 'Shredding'].includes(attack.damageType) || !['direct', 'periodic'].includes(attack.delivery) || !Number.isInteger(attack.hitsPerActivation) || attack.hitsPerActivation <= 0 || !Number.isFinite(attack.castSeconds) || attack.castSeconds < 0 || !Number.isInteger(attack.targetCap) || attack.targetCap <= 0 || !['none', 'independent', 'full_set'].includes(attack.chargePolicy) || !Number.isInteger(attack.chargeCount) || attack.chargeCount <= 0 || (attack.delivery === 'periodic' && (!Number.isFinite(attack.tickIntervalSeconds) || Number(attack.tickIntervalSeconds) <= 0 || !Number.isFinite(attack.damageDurationSeconds) || Number(attack.damageDurationSeconds) <= 0))) ) return fail(400, { abilityError: 'Provide a complete and valid intrinsic attack profile.', }); 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 (skillChipCompatibleAbilityId) { if (skillChipCompatibleAbilityId === abilityId) { return fail(400, { abilityError: 'An ability cannot share Skill Chips with itself.', }); } const compatibleAbility = await ( await database.prepare( 'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1', ) ).get(skillChipCompatibleAbilityId); if ( !compatibleAbility || String(compatibleAbility.specialization_id) !== specializationId ) { return fail(400, { abilityError: 'Choose an active Skill Chip-compatible ability from the same specialization.', }); } const conflictingLink = await ( await database.prepare( `SELECT ability_id,compatible_ability_id FROM specialization_ability_skill_chip_links WHERE (ability_id=? OR compatible_ability_id=?) AND ability_id<>? AND compatible_ability_id<>?`, ) ).get( skillChipCompatibleAbilityId, skillChipCompatibleAbilityId, abilityId, abilityId, ); if (conflictingLink) { return fail(409, { abilityError: 'Each ability can share one Skill Chip pool. Clear the existing link first.', }); } } 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 renderMutation = await readGameDataRenderMutation(form); try { if (renderMutation.file) renderMutation.processed = await processManagedImage( renderMutation.file, false, false, true, ); } catch (error) { return fail(400, { abilityError: error instanceof ManagedImageValidationError ? error.message : 'The ability render could not be processed.', }); } 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 before = id ? await abilitySnapshot(database, id) : null; const iconChanged = Boolean(iconMutation.output || iconMutation.remove); const previousIcon = iconChanged ? await readGameDataIcon(database, 'specialization_ability', abilityId) : 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_skill_chip_links WHERE ability_id=? OR compatible_ability_id=?`, ) ).run(abilityId, abilityId); if (skillChipCompatibleAbilityId) { const [leftAbilityId, rightAbilityId] = [ abilityId, skillChipCompatibleAbilityId, ].sort(); await ( await database.prepare( `INSERT INTO specialization_ability_skill_chip_links (ability_id,compatible_ability_id) VALUES(?,?)`, ) ).run(leftAbilityId, rightAbilityId); } 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, ); } if (attack) { await ( await database.prepare(`INSERT INTO specialization_ability_attacks (ability_id,base_damage,damage_type,delivery,hits_per_activation,tick_interval_seconds, damage_duration_seconds,cast_seconds,target_cap,charge_count,charge_policy,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(ability_id) DO UPDATE SET base_damage=excluded.base_damage, damage_type=excluded.damage_type, delivery=excluded.delivery, hits_per_activation=excluded.hits_per_activation, tick_interval_seconds=excluded.tick_interval_seconds, damage_duration_seconds=excluded.damage_duration_seconds, cast_seconds=excluded.cast_seconds, target_cap=excluded.target_cap, charge_count=excluded.charge_count, charge_policy=excluded.charge_policy, updated_at=excluded.updated_at`) ).run( abilityId, attack.baseDamage, attack.damageType, attack.delivery, attack.hitsPerActivation, attack.tickIntervalSeconds, attack.damageDurationSeconds, attack.castSeconds, attack.targetCap, attack.chargeCount, attack.chargePolicy, now, ); } else { await ( await database.prepare( 'DELETE FROM specialization_ability_attacks WHERE ability_id=?', ) ).run(abilityId); } await applyIconMutation( database, 'specialization_ability', abilityId, iconMutation, operator.id, ); await applyGameDataRender( database, 'specialization_ability', abilityId, name, renderMutation, operator.id, ); 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), }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); if (iconChanged) await restoreIconFile( 'specialization_ability', abilityId, previousIcon, ); throw error; } 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 ?? ''), conditionalRule: effect.conditionalRule, })) : [], 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 || !effect.conditionalRule, ) ) { return fail(400, { nodeError: 'Every conditional effect requires a shared effect and a complete calculation rule.', }); } 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(); const parsedCombatRules = new Map< (typeof allCombatEffects)[number], ConditionalMechanicDraftV1 >(); try { const activeAttributes = await ( await database.prepare('SELECT id FROM stat_attributes WHERE active=1') ).all(); for (const effect of allCombatEffects) { parsedCombatRules.set( effect, parseConditionalRulePayload( JSON.stringify(effect.conditionalRule), [{ key: 'base', variantKey: 'base' }], { allowedAttributeIds: new Set( activeAttributes.map((row) => String(row.id)), ), allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)), }, ), ); } } catch (error) { return fail(400, { nodeError: error instanceof Error ? error.message : 'One or more conditional mechanics are invalid.', }); } 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.', }); } const conditionalAttributeIds = [...parsedCombatRules.values()].flatMap( (rule) => mechanicOutcomes(rule) .map((outcome) => outcome.attributeId) .filter((id): id is string => Boolean(id)), ); if (!(await validateAttributeIds(database, conditionalAttributeIds))) { 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 renderMutation = await readGameDataRenderMutation(form); try { if (renderMutation.file) renderMutation.processed = await processManagedImage( renderMutation.file, false, false, true, ); } catch (error) { return fail(400, { nodeError: error instanceof ManagedImageValidationError ? error.message : 'The node render could not be processed.', }); } 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 iconChanged = Boolean(iconMutation.output || iconMutation.remove); const previousIcon = iconChanged ? await readGameDataIcon(database, 'specialization_node', nodeId) : 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, ); } const replacedCombatIds = ( await ( await database.prepare( 'SELECT id FROM specialization_tree_rank_combat_effects WHERE node_id=?', ) ).all(nodeId) ).map((row) => String(row.id)); for (const attachmentId of replacedCombatIds) { await ( await database.prepare( "DELETE FROM conditional_effect_rules WHERE source_type='specialization_tree_rank' AND source_id=?", ) ).run(attachmentId); } 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(); const conditionalRule = parsedCombatRules.get(effect); if (!conditionalRule) throw new Error( 'A validated conditional mechanic was not available during save.', ); const primaryClause = conditionalRule.clauses[0]; const reducer = primaryClause?.reducer; const legacyTriggerCount = Number( reducer && 'threshold' in reducer ? reducer.threshold : 1, ); const legacyWindowSeconds = Number( reducer && 'windowSeconds' in reducer ? reducer.windowSeconds : reducer?.kind === 'duration' ? reducer.seconds : 1, ); const legacyPool = conditionalRule.pools[0]; 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, Number.isInteger(legacyTriggerCount) && legacyTriggerCount > 0 ? legacyTriggerCount : 1, Number.isFinite(legacyWindowSeconds) && legacyWindowSeconds > 0 ? legacyWindowSeconds : 1, legacyPool?.expiresAfterSeconds ?? 0.001, legacyPool?.max ?? 1, 1, ); const legacyModifiers = mechanicOutcomes(conditionalRule).filter( (outcome) => outcome.kind === 'attribute_modifier' && outcome.attributeId, ); for (const [modifierIndex, modifier] of legacyModifiers.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.attributeId, modifier.variants.base.value, null, ); } await saveConditionalRuleInTransaction( database, 'specialization_tree_rank', attachmentId, conditionalRule, ); } 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 applyIconMutation( database, 'specialization_node', nodeId, iconMutation, operator.id, ); await applyGameDataRender( database, 'specialization_node', nodeId, name, renderMutation, operator.id, ); 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 }, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); if (iconChanged) await restoreIconFile('specialization_node', nodeId, previousIcon); throw error; } 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 database.exec('BEGIN'); try { await ( await database.prepare( 'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?', ) ).run(new Date().toISOString(), id); await ( await database.prepare( `DELETE FROM specialization_ability_skill_chip_links WHERE ability_id=? OR compatible_ability_id=?`, ) ).run(id, 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), }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } 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 database.exec('BEGIN'); try { 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), }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } 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 database.exec('BEGIN'); try { 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 }, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } 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 database.exec('BEGIN'); try { 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 }, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { nodeUnarchived: true, resultToken: randomUUID() }; }, };