Files
shd.build/src/routes/isac/specializations/+page.server.ts
T

1040 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number | string>;
};
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 = <T>(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<string, string[]>();
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<AbilityEffectInput>(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<RankInput>(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<AbilityEffectInput>(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<RankInput>(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() };
}
};