1040 lines
55 KiB
TypeScript
1040 lines
55 KiB
TypeScript
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() };
|
||
}
|
||
};
|