2565 lines
83 KiB
TypeScript
2565 lines
83 KiB
TypeScript
import { fail } from '@sveltejs/kit';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import type { Database } from '@tursodatabase/database';
|
||
import type { Actions, PageServerLoad } from './$types';
|
||
import {
|
||
requireDataView,
|
||
requirePermission,
|
||
} from '$lib/server/auth/authorization';
|
||
import { getSessionUser } from '$lib/server/auth/session';
|
||
import { getDatabase } from '$lib/server/database';
|
||
import {
|
||
getPagination,
|
||
getRegistrySearch,
|
||
getRegistrySort,
|
||
} from '$lib/server/pagination';
|
||
import {
|
||
deserializeProposal,
|
||
getPendingEntities,
|
||
} from '$lib/server/data-moderation';
|
||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||
import {
|
||
applyIconMutation,
|
||
gameDataIconStorageDirectory,
|
||
gameDataIconMetadata,
|
||
gameDataIconUrl,
|
||
GameDataIconValidationError,
|
||
readGameDataIcon,
|
||
readIconMutation,
|
||
type GameDataIconType,
|
||
} from '$lib/server/game-data-icons';
|
||
import {
|
||
refreshSpecializationFocusReady,
|
||
refreshSpecializationFocuses,
|
||
} from '$lib/server/specialization-readiness';
|
||
import {
|
||
conditionalRuleSnapshot,
|
||
loadConditionalRuleDraft,
|
||
parseConditionalRulePayload,
|
||
saveConditionalRuleInTransaction,
|
||
} from '$lib/server/conditional-effects';
|
||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
||
import {
|
||
applyGameDataRender,
|
||
ManagedImageValidationError,
|
||
managedImageUrl,
|
||
processManagedImage,
|
||
readGameDataRenderMutation,
|
||
} from '$lib/server/managed-images';
|
||
|
||
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
|
||
const RANK_TWO_RESOURCES = new Set([
|
||
'resource-electronics-alpha',
|
||
'resource-electronics-beta',
|
||
'resource-electronics-gamma',
|
||
]);
|
||
const OMEGA_RESOURCE = 'resource-electronics-omega';
|
||
const EFFECT_UNITS = new Set(['', '%', 's', 'Shots', 'm']);
|
||
|
||
type AbilityEffectInput = {
|
||
type?: string;
|
||
statId?: string;
|
||
label?: string;
|
||
value?: number | string | null;
|
||
unit?: string;
|
||
};
|
||
|
||
type RankEffectInput = AbilityEffectInput & {
|
||
type?: string;
|
||
abilityId?: string;
|
||
};
|
||
|
||
type RankCombatEffectInput = {
|
||
effectId?: string;
|
||
conditionalRule?: unknown;
|
||
};
|
||
|
||
type RankInput = {
|
||
rank?: number;
|
||
description?: string;
|
||
effects?: RankEffectInput[];
|
||
combatEffects?: RankCombatEffectInput[];
|
||
costs?: Record<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 [];
|
||
}
|
||
};
|
||
|
||
const parsedJsonObject = (value: unknown) => {
|
||
try {
|
||
const parsed = JSON.parse(String(value ?? '{}'));
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? (parsed as Record<string, unknown>)
|
||
: {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
async function restoreIconFile(
|
||
type: GameDataIconType,
|
||
entityId: string,
|
||
previousIcon: Buffer | null,
|
||
) {
|
||
const iconPath = path.join(
|
||
gameDataIconStorageDirectory(),
|
||
`${type}-${entityId}.webp`,
|
||
);
|
||
if (previousIcon) {
|
||
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
||
await writeFile(iconPath, previousIcon, { flag: 'w', mode: 0o600 });
|
||
return;
|
||
}
|
||
await unlink(iconPath).catch((error: NodeJS.ErrnoException) => {
|
||
if (error.code !== 'ENOENT') throw error;
|
||
});
|
||
}
|
||
|
||
const mechanicOutcomes = (rule: ConditionalMechanicDraftV1) =>
|
||
rule.clauses.flatMap((clause) => clause.outcomes);
|
||
|
||
async function specializationSnapshot(database: Database, id: string) {
|
||
const record = await auditRecord('specializations', id);
|
||
const focusIds = (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_focuses WHERE specialization_id=? ORDER BY display_order,name',
|
||
)
|
||
).all(id)
|
||
).map((row) => String(row.id));
|
||
const abilityIds = (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_abilities WHERE specialization_id=? ORDER BY name',
|
||
)
|
||
).all(id)
|
||
).map((row) => String(row.id));
|
||
return {
|
||
record,
|
||
icon: await gameDataIconMetadata(database, 'specialization', id),
|
||
render:
|
||
(await (
|
||
await database.prepare(
|
||
"SELECT id,storage_key,updated_at FROM managed_images WHERE source_entity_type='specialization_render' AND source_entity_id=?",
|
||
)
|
||
).get(id)) ?? null,
|
||
focusIds,
|
||
abilityIds,
|
||
};
|
||
}
|
||
|
||
async function focusSnapshot(database: Database, id: string) {
|
||
const record = await auditRecord('specialization_focuses', id);
|
||
const abilities = await (
|
||
await database.prepare(
|
||
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
|
||
)
|
||
).all(id);
|
||
const nodeIds = (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? ORDER BY row_number,column_number',
|
||
)
|
||
).all(id)
|
||
).map((row) => String(row.id));
|
||
return { record, abilities, nodeIds };
|
||
}
|
||
|
||
async function abilitySnapshot(database: Database, id: string) {
|
||
return {
|
||
record: await auditRecord('specialization_abilities', id),
|
||
skillChipLinks: await (
|
||
await database.prepare(
|
||
`SELECT ability_id,compatible_ability_id
|
||
FROM specialization_ability_skill_chip_links
|
||
WHERE ability_id=? OR compatible_ability_id=?
|
||
ORDER BY ability_id,compatible_ability_id`,
|
||
)
|
||
).all(id, id),
|
||
attack:
|
||
(await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?',
|
||
)
|
||
).get(id)) ?? null,
|
||
effects: await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
|
||
)
|
||
).all(id),
|
||
icon: await gameDataIconMetadata(database, 'specialization_ability', id),
|
||
render:
|
||
(await (
|
||
await database.prepare(
|
||
"SELECT id,storage_key,updated_at FROM managed_images WHERE source_entity_type='specialization_ability_render' AND source_entity_id=?",
|
||
)
|
||
).get(id)) ?? null,
|
||
};
|
||
}
|
||
|
||
async function nodeSnapshot(database: Database, id: string) {
|
||
const combatEffects = await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order',
|
||
)
|
||
).all(id);
|
||
const conditionalRules = await Promise.all(
|
||
combatEffects.map((effect) =>
|
||
conditionalRuleSnapshot(
|
||
database,
|
||
'specialization_tree_rank',
|
||
String(effect.id),
|
||
),
|
||
),
|
||
);
|
||
return {
|
||
record: await auditRecord('specialization_tree_nodes', id),
|
||
ranks: await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank',
|
||
)
|
||
).all(id),
|
||
effects: await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order',
|
||
)
|
||
).all(id),
|
||
costs: await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id',
|
||
)
|
||
).all(id),
|
||
combatEffects,
|
||
combatModifiers: combatEffects.length
|
||
? await (
|
||
await database.prepare(
|
||
`SELECT modifier.* FROM specialization_tree_rank_combat_modifiers modifier
|
||
JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=modifier.rank_combat_effect_id
|
||
WHERE attachment.node_id=? ORDER BY attachment.rank,attachment.effect_order,modifier.modifier_order`,
|
||
)
|
||
).all(id)
|
||
: [],
|
||
conditionalRules,
|
||
icon: await gameDataIconMetadata(database, 'specialization_node', id),
|
||
render:
|
||
(await (
|
||
await database.prepare(
|
||
"SELECT id,storage_key,updated_at FROM managed_images WHERE source_entity_type='specialization_node_render' AND source_entity_id=?",
|
||
)
|
||
).get(id)) ?? null,
|
||
};
|
||
}
|
||
|
||
async function combatEffectSnapshot(database: Database, id: string) {
|
||
return {
|
||
record: await auditRecord('specialization_combat_effects', id),
|
||
attachments: await (
|
||
await database.prepare(
|
||
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order',
|
||
)
|
||
).all(id),
|
||
};
|
||
}
|
||
|
||
async function validateAttributeIds(database: Database, ids: string[]) {
|
||
const unique = [...new Set(ids.filter(Boolean))];
|
||
if (!unique.length) return true;
|
||
const count = Number(
|
||
(
|
||
await (
|
||
await database.prepare(
|
||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${unique.map(() => '?').join(',')})`,
|
||
)
|
||
).get(...unique)
|
||
)?.count ?? 0,
|
||
);
|
||
return count === unique.length;
|
||
}
|
||
|
||
async function validateAbilityIds(
|
||
database: Database,
|
||
specializationId: string,
|
||
ids: string[],
|
||
) {
|
||
const unique = [...new Set(ids.filter(Boolean))];
|
||
if (!unique.length) return true;
|
||
const count = Number(
|
||
(
|
||
await (
|
||
await database.prepare(
|
||
`SELECT COUNT(*) AS count FROM specialization_abilities
|
||
WHERE active=1 AND specialization_id=? AND id IN (${unique.map(() => '?').join(',')})`,
|
||
)
|
||
).get(specializationId, ...unique)
|
||
)?.count ?? 0,
|
||
);
|
||
return count === unique.length;
|
||
}
|
||
|
||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||
const user = await requireDataView(await getSessionUser(cookies));
|
||
const database = await getDatabase();
|
||
const filter = ['active', 'ready', 'draft', 'archived'].includes(
|
||
url.searchParams.get('filter') ?? '',
|
||
)
|
||
? String(url.searchParams.get('filter'))
|
||
: '';
|
||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||
const { search, pattern } = getRegistrySearch(url);
|
||
const sort = getRegistrySort(
|
||
url,
|
||
{
|
||
name: {
|
||
asc: 'specializations.name COLLATE NOCASE ASC,specializations.id ASC',
|
||
desc: 'specializations.name COLLATE NOCASE DESC,specializations.id DESC',
|
||
},
|
||
focuses: {
|
||
asc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id) ASC,specializations.name COLLATE NOCASE ASC',
|
||
desc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id) DESC,specializations.name COLLATE NOCASE ASC',
|
||
},
|
||
abilities: {
|
||
asc: '(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=specializations.id) ASC,specializations.name COLLATE NOCASE ASC',
|
||
desc: '(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=specializations.id) DESC,specializations.name COLLATE NOCASE ASC',
|
||
},
|
||
readiness: {
|
||
asc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1) ASC,specializations.name COLLATE NOCASE ASC',
|
||
desc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1) DESC,specializations.name COLLATE NOCASE ASC',
|
||
},
|
||
state: {
|
||
asc: 'specializations.active ASC,specializations.name COLLATE NOCASE ASC',
|
||
desc: 'specializations.active DESC,specializations.name COLLATE NOCASE ASC',
|
||
defaultDirection: 'desc',
|
||
},
|
||
},
|
||
'name',
|
||
);
|
||
const conditions = [
|
||
...(requestedItemId
|
||
? ['specializations.id=?']
|
||
: filter === 'active'
|
||
? ['specializations.active=1']
|
||
: filter === 'ready'
|
||
? [
|
||
'EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)',
|
||
]
|
||
: filter === 'draft'
|
||
? [
|
||
'specializations.active=1 AND NOT EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)',
|
||
]
|
||
: filter === 'archived'
|
||
? ['specializations.active=0']
|
||
: []),
|
||
...(!requestedItemId && search
|
||
? ["specializations.name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||
: []),
|
||
];
|
||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||
const args = [
|
||
...(requestedItemId ? [requestedItemId] : []),
|
||
...(!requestedItemId && search ? [pattern] : []),
|
||
];
|
||
const pending = await getPendingEntities(user.id, [
|
||
{
|
||
module: 'specializations',
|
||
action: 'saveSpecialization',
|
||
kind: 'specialization',
|
||
fields: ['description'],
|
||
},
|
||
{
|
||
module: 'specializations',
|
||
action: 'saveFocus',
|
||
kind: 'focus',
|
||
fields: [
|
||
'specializationId',
|
||
'description',
|
||
'primaryAttribute',
|
||
'displayOrder',
|
||
],
|
||
},
|
||
{
|
||
module: 'specializations',
|
||
action: 'saveAbility',
|
||
kind: 'ability',
|
||
fields: [
|
||
'specializationId',
|
||
'description',
|
||
'durationSeconds',
|
||
'cooldownSeconds',
|
||
'effectsJson',
|
||
'attackJson',
|
||
'skillChipCompatibleAbilityId',
|
||
],
|
||
},
|
||
{
|
||
module: 'specializations',
|
||
action: 'saveCombatEffect',
|
||
kind: 'combatEffect',
|
||
fields: ['specializationId', 'descriptionTemplate'],
|
||
},
|
||
{
|
||
module: 'specializations',
|
||
action: 'saveNode',
|
||
kind: 'node',
|
||
fields: [
|
||
'focusId',
|
||
'descriptionTemplate',
|
||
'rowNumber',
|
||
'columnNumber',
|
||
'maxRank',
|
||
'ranksJson',
|
||
],
|
||
},
|
||
]);
|
||
const pendingSpecializations = pending
|
||
.filter((item) => item.kind === 'specialization')
|
||
.filter((item) =>
|
||
requestedItemId
|
||
? item.id === requestedItemId
|
||
: !search ||
|
||
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
||
);
|
||
const pendingAbilitySetRows = await (
|
||
await database.prepare(`SELECT entity_id,payload_json,created_at
|
||
FROM data_change_proposals
|
||
WHERE module='specializations' AND action='saveFocusAbilities' AND status='pending' AND entity_id IS NOT NULL
|
||
ORDER BY created_at DESC`)
|
||
).all();
|
||
const pendingAbilitySets = new Map<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,
|
||
abilitySkillChipLinks,
|
||
abilityAttacks,
|
||
abilityEffects,
|
||
focusAbilities,
|
||
nodes,
|
||
nodeRanks,
|
||
nodeEffects,
|
||
nodeCosts,
|
||
combatEffects,
|
||
nodeCombatEffects,
|
||
nodeCombatModifiers,
|
||
resources,
|
||
rankProfiles,
|
||
progression,
|
||
attributes,
|
||
chipCounts,
|
||
icons,
|
||
renders,
|
||
summary,
|
||
] = await Promise.all([
|
||
databaseLimit
|
||
? (
|
||
await database.prepare(
|
||
`SELECT * FROM specializations ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||
)
|
||
).all(...args, databaseLimit, databaseOffset)
|
||
: [],
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_ability_skill_chip_links ORDER BY ability_id,compatible_ability_id',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_ability_effects ORDER BY ability_id,effect_order',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_combat_effects ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM upgrade_resources WHERE active=1 ORDER BY resource_type,name COLLATE NOCASE',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(
|
||
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
||
)
|
||
).get(),
|
||
(
|
||
await database.prepare(
|
||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||
)
|
||
).all(),
|
||
(
|
||
await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count
|
||
FROM skill_chip_sets WHERE active=1 AND specialization_ability_id IS NOT NULL
|
||
GROUP BY specialization_ability_id`)
|
||
).all(),
|
||
(
|
||
await database.prepare(`SELECT entity_type,entity_id,updated_at FROM game_data_icons
|
||
WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)
|
||
).all(),
|
||
(
|
||
await database.prepare(`SELECT source_entity_type,source_entity_id,id,updated_at FROM managed_images
|
||
WHERE source_entity_type IN ('specialization_render','specialization_ability_render','specialization_node_render')`)
|
||
).all(),
|
||
(
|
||
await database.prepare(`SELECT
|
||
(SELECT COUNT(*) FROM specializations) AS total,
|
||
(SELECT COUNT(*) FROM specializations WHERE active=1) AS active,
|
||
(SELECT COUNT(*) FROM specializations WHERE active=0) AS archived,
|
||
(SELECT COUNT(*) FROM specializations specialization WHERE specialization.active=1
|
||
AND EXISTS (SELECT 1 FROM specialization_focuses focus
|
||
WHERE focus.specialization_id=specialization.id AND focus.builder_ready=1)) AS ready,
|
||
(SELECT COUNT(*) FROM specializations specialization WHERE specialization.active=1
|
||
AND NOT EXISTS (SELECT 1 FROM specialization_focuses focus
|
||
WHERE focus.specialization_id=specialization.id AND focus.builder_ready=1)) AS draft,
|
||
(SELECT COUNT(*) FROM specialization_focuses WHERE active=1) AS focuses,
|
||
(SELECT COUNT(*) FROM specialization_focuses WHERE builder_ready=1) AS ready_focuses,
|
||
(SELECT COUNT(*) FROM specialization_abilities WHERE active=1) AS abilities`)
|
||
).get(),
|
||
]);
|
||
const pendingFocuses = pending.filter((item) => item.kind === 'focus');
|
||
const pendingAbilities = pending.filter((item) => item.kind === 'ability');
|
||
const pendingCombatEffects = pending.filter(
|
||
(item) => item.kind === 'combatEffect',
|
||
);
|
||
const pendingNodes = pending.filter((item) => item.kind === 'node');
|
||
const conditionalDrafts = new Map(
|
||
await Promise.all(
|
||
nodeCombatEffects.map(
|
||
async (effect) =>
|
||
[
|
||
String(effect.id),
|
||
await loadConditionalRuleDraft(
|
||
database,
|
||
'specialization_tree_rank',
|
||
String(effect.id),
|
||
['base'],
|
||
),
|
||
] as const,
|
||
),
|
||
),
|
||
);
|
||
const iconUrl = (
|
||
type: 'specialization' | 'specialization_ability' | 'specialization_node',
|
||
id: string,
|
||
) => {
|
||
const icon = icons.find(
|
||
(item) => item.entity_type === type && item.entity_id === id,
|
||
);
|
||
return icon ? gameDataIconUrl(type, id, icon.updated_at) : '';
|
||
};
|
||
const renderUrl = (
|
||
type: 'specialization' | 'specialization_ability' | 'specialization_node',
|
||
id: string,
|
||
) => {
|
||
const render = renders.find(
|
||
(item) =>
|
||
item.source_entity_type === `${type}_render` &&
|
||
item.source_entity_id === id,
|
||
);
|
||
return render ? managedImageUrl(String(render.id), render.updated_at) : '';
|
||
};
|
||
const loadedSpecializations = [
|
||
...pendingOnPage.map((item) => ({
|
||
...item,
|
||
description: item.description,
|
||
active: 1,
|
||
pending: true,
|
||
iconUrl: '',
|
||
renderUrl: '',
|
||
})),
|
||
...specializations.map((item) => ({
|
||
...item,
|
||
iconUrl: iconUrl('specialization', String(item.id)),
|
||
renderUrl: renderUrl('specialization', String(item.id)),
|
||
})),
|
||
];
|
||
const loadedFocuses = [
|
||
...pendingFocuses.map((item) => ({
|
||
...item,
|
||
specialization_id: item.specializationId,
|
||
primary_attribute: item.primaryAttribute,
|
||
display_order: Number(item.displayOrder || 0),
|
||
builder_ready: 0,
|
||
active: 1,
|
||
pending: true,
|
||
})),
|
||
...focuses,
|
||
];
|
||
const linkedAbilityById = new Map<string, string>();
|
||
for (const link of abilitySkillChipLinks) {
|
||
linkedAbilityById.set(
|
||
String(link.ability_id),
|
||
String(link.compatible_ability_id),
|
||
);
|
||
linkedAbilityById.set(
|
||
String(link.compatible_ability_id),
|
||
String(link.ability_id),
|
||
);
|
||
}
|
||
const loadedAbilities = [
|
||
...pendingAbilities.map((item) => ({
|
||
...item,
|
||
specialization_id: item.specializationId,
|
||
duration_seconds: item.durationSeconds || null,
|
||
cooldown_seconds: item.cooldownSeconds || null,
|
||
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
|
||
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
|
||
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '',
|
||
active: 1,
|
||
pending: true,
|
||
skillChipCount: 0,
|
||
iconUrl: '',
|
||
renderUrl: '',
|
||
})),
|
||
...abilities.map((ability) => ({
|
||
...ability,
|
||
attack:
|
||
abilityAttacks.find((attack) => attack.ability_id === ability.id) ??
|
||
null,
|
||
effects: abilityEffects.filter(
|
||
(effect) => effect.ability_id === ability.id,
|
||
),
|
||
skillChipCount: Number(
|
||
chipCounts.find((item) => item.specialization_ability_id === ability.id)
|
||
?.count ?? 0,
|
||
),
|
||
skillChipCompatibleAbilityId:
|
||
linkedAbilityById.get(String(ability.id)) ?? '',
|
||
iconUrl: iconUrl('specialization_ability', String(ability.id)),
|
||
renderUrl: renderUrl('specialization_ability', String(ability.id)),
|
||
})),
|
||
];
|
||
const loadedNodes = [
|
||
...pendingNodes.map((item) => ({
|
||
...item,
|
||
focus_id: item.focusId,
|
||
description_template: item.descriptionTemplate,
|
||
row_number: Number(item.rowNumber),
|
||
column_number: Number(item.columnNumber),
|
||
max_rank: Number(item.maxRank),
|
||
ranks: parseJsonArray<RankInput>(item.ranksJson),
|
||
active: 1,
|
||
pending: true,
|
||
iconUrl: '',
|
||
renderUrl: '',
|
||
})),
|
||
...nodes.map((node) => ({
|
||
...node,
|
||
ranks: nodeRanks
|
||
.filter((rank) => rank.node_id === node.id)
|
||
.map((rank) => ({
|
||
...rank,
|
||
effects: nodeEffects.filter(
|
||
(effect) =>
|
||
effect.node_id === node.id &&
|
||
Number(effect.rank) === Number(rank.rank),
|
||
),
|
||
combatEffects: nodeCombatEffects
|
||
.filter(
|
||
(effect) =>
|
||
effect.node_id === node.id &&
|
||
Number(effect.rank) === Number(rank.rank),
|
||
)
|
||
.map((effect) => ({
|
||
...effect,
|
||
modifiers: nodeCombatModifiers.filter(
|
||
(modifier) => modifier.rank_combat_effect_id === effect.id,
|
||
),
|
||
conditionalRule: conditionalDrafts.get(String(effect.id)) ?? null,
|
||
})),
|
||
costs: Object.fromEntries(
|
||
nodeCosts
|
||
.filter(
|
||
(cost) =>
|
||
cost.node_id === node.id &&
|
||
Number(cost.rank) === Number(rank.rank),
|
||
)
|
||
.map((cost) => [String(cost.resource_id), Number(cost.quantity)]),
|
||
),
|
||
})),
|
||
iconUrl: iconUrl('specialization_node', String(node.id)),
|
||
renderUrl: renderUrl('specialization_node', String(node.id)),
|
||
})),
|
||
];
|
||
const availablePoints =
|
||
Number(progression?.xp_points ?? 30) +
|
||
Number(progression?.shd_cache_points ?? 19);
|
||
return {
|
||
specializations: loadedSpecializations,
|
||
focuses: loadedFocuses.map((focus) => {
|
||
const assigned = focusAbilities.filter(
|
||
(link) => link.focus_id === focus.id,
|
||
);
|
||
const pendingAbilityIds = pendingAbilitySets.get(String(focus.id));
|
||
const focusNodes = loadedNodes.filter(
|
||
(node) => node.focus_id === focus.id && Number(node.active) === 1,
|
||
);
|
||
const pointTotal = focusNodes.reduce(
|
||
(sum, node) =>
|
||
node.ranks.reduce((rankSum: number, rank: { rank: number }) => {
|
||
const profile = rankProfiles.find(
|
||
(item) => Number(item.rank) === Number(rank.rank),
|
||
);
|
||
return rankSum + Number(profile?.point_cost ?? rank.rank);
|
||
}, sum),
|
||
0,
|
||
);
|
||
return {
|
||
...focus,
|
||
abilityIds:
|
||
pendingAbilityIds ??
|
||
[1, 2, 3].map((slot) =>
|
||
String(
|
||
assigned.find((link) => Number(link.ability_slot) === slot)
|
||
?.ability_id ?? '',
|
||
),
|
||
),
|
||
abilitySetPending: Boolean(pendingAbilityIds),
|
||
pointTotal,
|
||
nodeCount: focusNodes.length,
|
||
maxRow: Math.max(
|
||
0,
|
||
...focusNodes.map((node) => Number(node.row_number)),
|
||
),
|
||
};
|
||
}),
|
||
abilities: loadedAbilities,
|
||
combatEffects: [
|
||
...pendingCombatEffects.map((item) => ({
|
||
...item,
|
||
specialization_id: item.specializationId,
|
||
description_template: item.descriptionTemplate,
|
||
active: 1,
|
||
pending: true,
|
||
})),
|
||
...combatEffects,
|
||
],
|
||
nodes: loadedNodes,
|
||
resources,
|
||
rankProfiles,
|
||
progression: { ...progression, availablePoints },
|
||
attributes,
|
||
requestedItemId,
|
||
summary: {
|
||
...summary,
|
||
total: Number(summary?.total ?? 0) + pendingSpecializations.length,
|
||
pending: pending.filter((item) =>
|
||
['specialization', 'focus', 'ability', 'combatEffect', 'node'].includes(
|
||
item.kind,
|
||
),
|
||
).length,
|
||
},
|
||
pagination,
|
||
filter,
|
||
search,
|
||
sort,
|
||
};
|
||
};
|
||
|
||
export const actions: Actions = {
|
||
saveProgression: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const xpPoints = Number(form.get('xpPoints'));
|
||
const cachePoints = Number(form.get('cachePoints'));
|
||
const rankProfiles = [1, 2, 3].map((rank) => ({
|
||
rank,
|
||
pointCost: Number(form.get(`rank${rank}PointCost`)),
|
||
creditCost: Number(form.get(`rank${rank}CreditCost`)),
|
||
}));
|
||
if (
|
||
![xpPoints, cachePoints].every(
|
||
(value) => Number.isInteger(value) && value >= 0,
|
||
) ||
|
||
xpPoints + cachePoints <= 0
|
||
) {
|
||
return fail(400, {
|
||
progressionError:
|
||
'Progression sources require non-negative whole-number point totals.',
|
||
});
|
||
}
|
||
if (
|
||
rankProfiles.some(
|
||
(profile) =>
|
||
!Number.isInteger(profile.pointCost) ||
|
||
profile.pointCost <= 0 ||
|
||
!Number.isInteger(profile.creditCost) ||
|
||
profile.creditCost < 0,
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
progressionError:
|
||
'Every rank requires positive whole-number point costs and non-negative Credit costs.',
|
||
});
|
||
}
|
||
const database = await getDatabase();
|
||
const before = {
|
||
progression: await (
|
||
await database.prepare(
|
||
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
||
)
|
||
).get(),
|
||
ranks: await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
||
)
|
||
).all(),
|
||
};
|
||
const now = new Date().toISOString();
|
||
await database.exec('BEGIN');
|
||
try {
|
||
await (
|
||
await database.prepare(`UPDATE specialization_progression_config
|
||
SET xp_points=?,shd_cache_points=?,updated_at=? WHERE id='default'`)
|
||
).run(xpPoints, cachePoints, now);
|
||
for (const profile of rankProfiles) {
|
||
await (
|
||
await database.prepare(`UPDATE specialization_rank_cost_profiles
|
||
SET point_cost=?,credit_cost=?,updated_at=? WHERE rank=?`)
|
||
).run(profile.pointCost, profile.creditCost, now, profile.rank);
|
||
}
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
const after = {
|
||
progression: await (
|
||
await database.prepare(
|
||
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
||
)
|
||
).get(),
|
||
ranks: await (
|
||
await database.prepare(
|
||
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
||
)
|
||
).all(),
|
||
};
|
||
const specializationIds = await (
|
||
await database.prepare('SELECT id FROM specializations')
|
||
).all();
|
||
for (const specialization of specializationIds)
|
||
await refreshSpecializationFocuses(database, String(specialization.id));
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization.progression_updated',
|
||
entityId: 'specialization-progression',
|
||
before,
|
||
after,
|
||
});
|
||
return { progressionSaved: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
saveSpecialization: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? '');
|
||
const name = cleanName(form.get('name'));
|
||
const description = cleanName(form.get('description'));
|
||
if (name.length < 2 || name.length > 80)
|
||
return fail(400, {
|
||
specializationError: 'Specialization names must be 2–80 characters.',
|
||
});
|
||
if (description.length > 1200)
|
||
return fail(400, {
|
||
specializationError: 'Descriptions must be 1,200 characters or fewer.',
|
||
});
|
||
const database = await getDatabase();
|
||
if (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specializations WHERE name=? COLLATE NOCASE AND id<>?',
|
||
)
|
||
).get(name, id || 'new')
|
||
)
|
||
return fail(409, {
|
||
specializationError: 'That specialization name already exists.',
|
||
});
|
||
let renderMutation = await readGameDataRenderMutation(form);
|
||
try {
|
||
if (renderMutation.file)
|
||
renderMutation.processed = await processManagedImage(
|
||
renderMutation.file,
|
||
false,
|
||
false,
|
||
true,
|
||
);
|
||
} catch (error) {
|
||
return fail(400, {
|
||
specializationError:
|
||
error instanceof ManagedImageValidationError
|
||
? error.message
|
||
: 'The specialization render could not be processed.',
|
||
});
|
||
}
|
||
let iconMutation;
|
||
try {
|
||
iconMutation = await readIconMutation(form);
|
||
} catch (error) {
|
||
return fail(400, {
|
||
specializationError:
|
||
error instanceof GameDataIconValidationError
|
||
? error.message
|
||
: 'The specialization icon could not be processed.',
|
||
});
|
||
}
|
||
const specializationId =
|
||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||
const before = id ? await specializationSnapshot(database, id) : null;
|
||
const now = new Date().toISOString();
|
||
if (id) {
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specializations SET name=?,description=?,active=1,updated_at=? WHERE id=?',
|
||
)
|
||
).run(name, description || null, now, id);
|
||
} else {
|
||
await (
|
||
await database.prepare(
|
||
'INSERT INTO specializations(id,name,description,created_at,updated_at) VALUES(?,?,?,?,?)',
|
||
)
|
||
).run(specializationId, name, description || null, now, now);
|
||
}
|
||
await applyIconMutation(
|
||
database,
|
||
'specialization',
|
||
specializationId,
|
||
iconMutation,
|
||
operator.id,
|
||
);
|
||
await applyGameDataRender(
|
||
database,
|
||
'specialization',
|
||
specializationId,
|
||
name,
|
||
renderMutation,
|
||
operator.id,
|
||
);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: id ? 'specialization.updated' : 'specialization.created',
|
||
entityId: specializationId,
|
||
before,
|
||
after: await specializationSnapshot(database, specializationId),
|
||
});
|
||
return {
|
||
specializationSaved: true,
|
||
specializationMode: id ? 'edit' : 'add',
|
||
resultToken: randomUUID(),
|
||
};
|
||
},
|
||
|
||
saveFocus: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? '');
|
||
const specializationId = String(form.get('specializationId') ?? '');
|
||
const name = cleanName(form.get('name'));
|
||
const description = cleanName(form.get('description'));
|
||
const primaryAttribute = String(form.get('primaryAttribute') ?? '');
|
||
const displayOrder = Number(form.get('displayOrder') ?? 0);
|
||
if (name.length < 2 || name.length > 80)
|
||
return fail(400, { focusError: 'Focus names must be 2–80 characters.' });
|
||
if (description.length > 1200)
|
||
return fail(400, {
|
||
focusError: 'Descriptions must be 1,200 characters or fewer.',
|
||
});
|
||
if (
|
||
!PRIMARY_ATTRIBUTES.includes(
|
||
primaryAttribute as (typeof PRIMARY_ATTRIBUTES)[number],
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
focusError:
|
||
'Choose Firepower, Toughness, or Engineering as the primary attribute.',
|
||
});
|
||
}
|
||
if (
|
||
!Number.isInteger(displayOrder) ||
|
||
displayOrder < 0 ||
|
||
displayOrder > 999
|
||
) {
|
||
return fail(400, {
|
||
focusError: 'Display order must be a whole number between 0 and 999.',
|
||
});
|
||
}
|
||
const database = await getDatabase();
|
||
if (id) {
|
||
const existing = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_focuses WHERE id=?',
|
||
)
|
||
).get(id);
|
||
if (!existing)
|
||
return fail(404, { focusError: 'That focus no longer exists.' });
|
||
if (String(existing.specialization_id) !== specializationId) {
|
||
return fail(400, {
|
||
focusError:
|
||
'A saved focus cannot be moved to another specialization.',
|
||
});
|
||
}
|
||
}
|
||
if (
|
||
!(await (
|
||
await database.prepare(
|
||
'SELECT id FROM specializations WHERE id=? AND active=1',
|
||
)
|
||
).get(specializationId))
|
||
) {
|
||
return fail(400, { focusError: 'Choose an active specialization.' });
|
||
}
|
||
if (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_focuses WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?',
|
||
)
|
||
).get(specializationId, name, id || 'new')
|
||
)
|
||
return fail(409, {
|
||
focusError: 'That focus already exists on this specialization.',
|
||
});
|
||
const focusId =
|
||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||
const before = id ? await focusSnapshot(database, id) : null;
|
||
const now = new Date().toISOString();
|
||
if (id) {
|
||
await (
|
||
await database.prepare(`UPDATE specialization_focuses
|
||
SET specialization_id=?,name=?,description=?,primary_attribute=?,display_order=?,active=1,updated_at=? WHERE id=?`)
|
||
).run(
|
||
specializationId,
|
||
name,
|
||
description || null,
|
||
primaryAttribute,
|
||
displayOrder,
|
||
now,
|
||
id,
|
||
);
|
||
} else {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_focuses
|
||
(id,specialization_id,name,description,primary_attribute,display_order,created_at,updated_at)
|
||
VALUES(?,?,?,?,?,?,?,?)`)
|
||
).run(
|
||
focusId,
|
||
specializationId,
|
||
name,
|
||
description || null,
|
||
primaryAttribute,
|
||
displayOrder,
|
||
now,
|
||
now,
|
||
);
|
||
}
|
||
await refreshSpecializationFocusReady(database, focusId);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: id
|
||
? 'specialization_focus.updated'
|
||
: 'specialization_focus.created',
|
||
entityId: focusId,
|
||
before,
|
||
after: await focusSnapshot(database, focusId),
|
||
});
|
||
return {
|
||
focusSaved: true,
|
||
focusMode: id ? 'edit' : 'add',
|
||
resultToken: randomUUID(),
|
||
};
|
||
},
|
||
|
||
saveAbility: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? '');
|
||
const abilityId =
|
||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||
const specializationId = String(form.get('specializationId') ?? '');
|
||
const skillChipCompatibleAbilityId = String(
|
||
form.get('skillChipCompatibleAbilityId') ?? '',
|
||
);
|
||
const name = cleanName(form.get('name'));
|
||
const description = cleanName(form.get('description'));
|
||
const durationSeconds = optionalPositive(form.get('durationSeconds'));
|
||
const cooldownSeconds = optionalPositive(form.get('cooldownSeconds'));
|
||
const effects = parseJsonArray<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),
|
||
}));
|
||
let attack: null | {
|
||
baseDamage: number;
|
||
damageType: string;
|
||
delivery: string;
|
||
hitsPerActivation: number;
|
||
tickIntervalSeconds: number | null;
|
||
damageDurationSeconds: number | null;
|
||
castSeconds: number;
|
||
targetCap: number;
|
||
chargeCount: number;
|
||
chargePolicy: string;
|
||
} = null;
|
||
try {
|
||
const raw = JSON.parse(
|
||
String(form.get('attackJson') ?? '{"enabled":false}'),
|
||
);
|
||
if (raw?.enabled) {
|
||
attack = {
|
||
baseDamage: Number(raw.baseDamage),
|
||
damageType: String(raw.damageType ?? ''),
|
||
delivery: String(raw.delivery ?? ''),
|
||
hitsPerActivation: Number(raw.hitsPerActivation ?? 1),
|
||
tickIntervalSeconds:
|
||
raw.delivery === 'periodic'
|
||
? Number(raw.tickIntervalSeconds)
|
||
: null,
|
||
damageDurationSeconds:
|
||
raw.delivery === 'periodic'
|
||
? Number(raw.damageDurationSeconds)
|
||
: null,
|
||
castSeconds: Number(raw.castSeconds ?? 0),
|
||
targetCap: Number(raw.targetCap ?? 1),
|
||
chargeCount:
|
||
raw.chargePolicy === 'none' ? 1 : Number(raw.chargeCount ?? 1),
|
||
chargePolicy: String(raw.chargePolicy ?? 'none'),
|
||
};
|
||
}
|
||
} catch {
|
||
return fail(400, {
|
||
abilityError: 'The ability attack profile could not be read.',
|
||
});
|
||
}
|
||
if (name.length < 2 || name.length > 100)
|
||
return fail(400, {
|
||
abilityError: 'Ability names must be 2–100 characters.',
|
||
});
|
||
if (description.length < 3 || description.length > 2400)
|
||
return fail(400, {
|
||
abilityError: 'Ability descriptions must be 3–2,400 characters.',
|
||
});
|
||
if (Number.isNaN(durationSeconds) || Number.isNaN(cooldownSeconds)) {
|
||
return fail(400, {
|
||
abilityError: 'Duration and cooldown must be positive when supplied.',
|
||
});
|
||
}
|
||
if (
|
||
effects.length > 12 ||
|
||
effects.some(
|
||
(effect) =>
|
||
effect.label.length > 100 ||
|
||
(effect.label.length > 0 && effect.label.length < 2) ||
|
||
!['Attribute', 'Custom'].includes(effect.type) ||
|
||
(effect.type === 'Attribute' && !effect.statId) ||
|
||
(effect.value !== null && !Number.isFinite(effect.value)) ||
|
||
!EFFECT_UNITS.has(effect.unit),
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
abilityError:
|
||
'Ability effects require valid targets, optional numeric values, and labels no longer than 100 characters.',
|
||
});
|
||
}
|
||
if (
|
||
effects.some(
|
||
(effect) => effect.type === 'Custom' && effect.label.length < 2,
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
abilityError:
|
||
'Custom effects require a concise label because they have no Attribute name to use.',
|
||
});
|
||
}
|
||
if (
|
||
attack &&
|
||
(!Number.isFinite(attack.baseDamage) ||
|
||
attack.baseDamage <= 0 ||
|
||
!['Blast', 'Pierce', 'Shredding'].includes(attack.damageType) ||
|
||
!['direct', 'periodic'].includes(attack.delivery) ||
|
||
!Number.isInteger(attack.hitsPerActivation) ||
|
||
attack.hitsPerActivation <= 0 ||
|
||
!Number.isFinite(attack.castSeconds) ||
|
||
attack.castSeconds < 0 ||
|
||
!Number.isInteger(attack.targetCap) ||
|
||
attack.targetCap <= 0 ||
|
||
!['none', 'independent', 'full_set'].includes(attack.chargePolicy) ||
|
||
!Number.isInteger(attack.chargeCount) ||
|
||
attack.chargeCount <= 0 ||
|
||
(attack.delivery === 'periodic' &&
|
||
(!Number.isFinite(attack.tickIntervalSeconds) ||
|
||
Number(attack.tickIntervalSeconds) <= 0 ||
|
||
!Number.isFinite(attack.damageDurationSeconds) ||
|
||
Number(attack.damageDurationSeconds) <= 0)))
|
||
)
|
||
return fail(400, {
|
||
abilityError: 'Provide a complete and valid intrinsic attack profile.',
|
||
});
|
||
const database = await getDatabase();
|
||
if (id) {
|
||
const existing = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_abilities WHERE id=?',
|
||
)
|
||
).get(id);
|
||
if (!existing)
|
||
return fail(404, {
|
||
abilityError: 'That class ability no longer exists.',
|
||
});
|
||
if (String(existing.specialization_id) !== specializationId) {
|
||
return fail(400, {
|
||
abilityError:
|
||
'A saved class ability cannot be moved to another specialization.',
|
||
});
|
||
}
|
||
}
|
||
if (
|
||
!(await (
|
||
await database.prepare(
|
||
'SELECT id FROM specializations WHERE id=? AND active=1',
|
||
)
|
||
).get(specializationId))
|
||
) {
|
||
return fail(400, { abilityError: 'Choose an active specialization.' });
|
||
}
|
||
if (skillChipCompatibleAbilityId) {
|
||
if (skillChipCompatibleAbilityId === abilityId) {
|
||
return fail(400, {
|
||
abilityError: 'An ability cannot share Skill Chips with itself.',
|
||
});
|
||
}
|
||
const compatibleAbility = await (
|
||
await database.prepare(
|
||
'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1',
|
||
)
|
||
).get(skillChipCompatibleAbilityId);
|
||
if (
|
||
!compatibleAbility ||
|
||
String(compatibleAbility.specialization_id) !== specializationId
|
||
) {
|
||
return fail(400, {
|
||
abilityError:
|
||
'Choose an active Skill Chip-compatible ability from the same specialization.',
|
||
});
|
||
}
|
||
const conflictingLink = await (
|
||
await database.prepare(
|
||
`SELECT ability_id,compatible_ability_id
|
||
FROM specialization_ability_skill_chip_links
|
||
WHERE (ability_id=? OR compatible_ability_id=?)
|
||
AND ability_id<>? AND compatible_ability_id<>?`,
|
||
)
|
||
).get(
|
||
skillChipCompatibleAbilityId,
|
||
skillChipCompatibleAbilityId,
|
||
abilityId,
|
||
abilityId,
|
||
);
|
||
if (conflictingLink) {
|
||
return fail(409, {
|
||
abilityError:
|
||
'Each ability can share one Skill Chip pool. Clear the existing link first.',
|
||
});
|
||
}
|
||
}
|
||
if (
|
||
!(await validateAttributeIds(
|
||
database,
|
||
effects.map((effect) => effect.statId),
|
||
))
|
||
) {
|
||
return fail(400, {
|
||
abilityError: 'One or more selected Attributes are unavailable.',
|
||
});
|
||
}
|
||
const unlabeledAttributeIds = [
|
||
...new Set(
|
||
effects
|
||
.filter((effect) => effect.type === 'Attribute' && !effect.label)
|
||
.map((effect) => effect.statId),
|
||
),
|
||
];
|
||
const fallbackRows = unlabeledAttributeIds.length
|
||
? await (
|
||
await database.prepare(
|
||
`SELECT id,name FROM stat_attributes WHERE id IN (${unlabeledAttributeIds.map(() => '?').join(',')})`,
|
||
)
|
||
).all(...unlabeledAttributeIds)
|
||
: [];
|
||
const fallbackLabels = new Map(
|
||
fallbackRows.map((row) => [String(row.id), cleanName(row.name)]),
|
||
);
|
||
const resolvedEffects = effects.map((effect) => ({
|
||
...effect,
|
||
label: effect.label || fallbackLabels.get(effect.statId) || '',
|
||
}));
|
||
if (resolvedEffects.some((effect) => effect.label.length < 2)) {
|
||
return fail(400, {
|
||
abilityError:
|
||
'Each effect needs either a label or an available Attribute target name.',
|
||
});
|
||
}
|
||
if (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_abilities WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?',
|
||
)
|
||
).get(specializationId, name, id || 'new')
|
||
)
|
||
return fail(409, {
|
||
abilityError: 'That ability already exists on this specialization.',
|
||
});
|
||
let renderMutation = await readGameDataRenderMutation(form);
|
||
try {
|
||
if (renderMutation.file)
|
||
renderMutation.processed = await processManagedImage(
|
||
renderMutation.file,
|
||
false,
|
||
false,
|
||
true,
|
||
);
|
||
} catch (error) {
|
||
return fail(400, {
|
||
abilityError:
|
||
error instanceof ManagedImageValidationError
|
||
? error.message
|
||
: 'The ability render could not be processed.',
|
||
});
|
||
}
|
||
let iconMutation;
|
||
try {
|
||
iconMutation = await readIconMutation(form);
|
||
} catch (error) {
|
||
return fail(400, {
|
||
abilityError:
|
||
error instanceof GameDataIconValidationError
|
||
? error.message
|
||
: 'The ability icon could not be processed.',
|
||
});
|
||
}
|
||
const before = id ? await abilitySnapshot(database, id) : null;
|
||
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
|
||
const previousIcon = iconChanged
|
||
? await readGameDataIcon(database, 'specialization_ability', abilityId)
|
||
: null;
|
||
const now = new Date().toISOString();
|
||
await database.exec('BEGIN');
|
||
try {
|
||
if (id) {
|
||
await (
|
||
await database.prepare(`UPDATE specialization_abilities
|
||
SET specialization_id=?,name=?,description=?,duration_seconds=?,cooldown_seconds=?,active=1,updated_at=? WHERE id=?`)
|
||
).run(
|
||
specializationId,
|
||
name,
|
||
description,
|
||
durationSeconds,
|
||
cooldownSeconds,
|
||
now,
|
||
id,
|
||
);
|
||
} else {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_abilities
|
||
(id,specialization_id,name,description,duration_seconds,cooldown_seconds,created_at,updated_at)
|
||
VALUES(?,?,?,?,?,?,?,?)`)
|
||
).run(
|
||
abilityId,
|
||
specializationId,
|
||
name,
|
||
description,
|
||
durationSeconds,
|
||
cooldownSeconds,
|
||
now,
|
||
now,
|
||
);
|
||
}
|
||
await (
|
||
await database.prepare(
|
||
`DELETE FROM specialization_ability_skill_chip_links
|
||
WHERE ability_id=? OR compatible_ability_id=?`,
|
||
)
|
||
).run(abilityId, abilityId);
|
||
if (skillChipCompatibleAbilityId) {
|
||
const [leftAbilityId, rightAbilityId] = [
|
||
abilityId,
|
||
skillChipCompatibleAbilityId,
|
||
].sort();
|
||
await (
|
||
await database.prepare(
|
||
`INSERT INTO specialization_ability_skill_chip_links
|
||
(ability_id,compatible_ability_id) VALUES(?,?)`,
|
||
)
|
||
).run(leftAbilityId, rightAbilityId);
|
||
}
|
||
await (
|
||
await database.prepare(
|
||
'DELETE FROM specialization_ability_effects WHERE ability_id=?',
|
||
)
|
||
).run(abilityId);
|
||
for (const [index, effect] of resolvedEffects.entries()) {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_ability_effects
|
||
(id,ability_id,effect_order,stat_attribute_id,label,value,unit) VALUES(?,?,?,?,?,?,?)`)
|
||
).run(
|
||
randomUUID(),
|
||
abilityId,
|
||
index + 1,
|
||
effect.statId || null,
|
||
effect.label,
|
||
effect.value,
|
||
effect.unit || null,
|
||
);
|
||
}
|
||
if (attack) {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_ability_attacks
|
||
(ability_id,base_damage,damage_type,delivery,hits_per_activation,tick_interval_seconds,
|
||
damage_duration_seconds,cast_seconds,target_cap,charge_count,charge_policy,updated_at)
|
||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||
ON CONFLICT(ability_id) DO UPDATE SET
|
||
base_damage=excluded.base_damage,
|
||
damage_type=excluded.damage_type,
|
||
delivery=excluded.delivery,
|
||
hits_per_activation=excluded.hits_per_activation,
|
||
tick_interval_seconds=excluded.tick_interval_seconds,
|
||
damage_duration_seconds=excluded.damage_duration_seconds,
|
||
cast_seconds=excluded.cast_seconds,
|
||
target_cap=excluded.target_cap,
|
||
charge_count=excluded.charge_count,
|
||
charge_policy=excluded.charge_policy,
|
||
updated_at=excluded.updated_at`)
|
||
).run(
|
||
abilityId,
|
||
attack.baseDamage,
|
||
attack.damageType,
|
||
attack.delivery,
|
||
attack.hitsPerActivation,
|
||
attack.tickIntervalSeconds,
|
||
attack.damageDurationSeconds,
|
||
attack.castSeconds,
|
||
attack.targetCap,
|
||
attack.chargeCount,
|
||
attack.chargePolicy,
|
||
now,
|
||
);
|
||
} else {
|
||
await (
|
||
await database.prepare(
|
||
'DELETE FROM specialization_ability_attacks WHERE ability_id=?',
|
||
)
|
||
).run(abilityId);
|
||
}
|
||
await applyIconMutation(
|
||
database,
|
||
'specialization_ability',
|
||
abilityId,
|
||
iconMutation,
|
||
operator.id,
|
||
);
|
||
await applyGameDataRender(
|
||
database,
|
||
'specialization_ability',
|
||
abilityId,
|
||
name,
|
||
renderMutation,
|
||
operator.id,
|
||
);
|
||
await refreshSpecializationFocuses(database, specializationId);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: id
|
||
? 'specialization_ability.updated'
|
||
: 'specialization_ability.created',
|
||
entityId: abilityId,
|
||
before,
|
||
after: await abilitySnapshot(database, abilityId),
|
||
});
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
if (iconChanged)
|
||
await restoreIconFile(
|
||
'specialization_ability',
|
||
abilityId,
|
||
previousIcon,
|
||
);
|
||
throw error;
|
||
}
|
||
return {
|
||
abilitySaved: true,
|
||
abilityMode: id ? 'edit' : 'add',
|
||
resultToken: randomUUID(),
|
||
};
|
||
},
|
||
|
||
saveCombatEffect: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? '');
|
||
const specializationId = String(form.get('specializationId') ?? '');
|
||
const name = cleanName(form.get('name'));
|
||
const descriptionTemplate = cleanName(form.get('descriptionTemplate'));
|
||
if (name.length < 2 || name.length > 100) {
|
||
return fail(400, {
|
||
combatEffectError: 'Combat-effect names must be 2–100 characters.',
|
||
});
|
||
}
|
||
if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) {
|
||
return fail(400, {
|
||
combatEffectError:
|
||
'The effect description template must be 3–2,400 characters.',
|
||
});
|
||
}
|
||
const database = await getDatabase();
|
||
const specialization = await (
|
||
await database.prepare(
|
||
'SELECT id FROM specializations WHERE id=? AND active=1',
|
||
)
|
||
).get(specializationId);
|
||
if (!specialization)
|
||
return fail(404, {
|
||
combatEffectError: 'That active specialization no longer exists.',
|
||
});
|
||
if (id) {
|
||
const existing = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_combat_effects WHERE id=?',
|
||
)
|
||
).get(id);
|
||
if (!existing)
|
||
return fail(404, {
|
||
combatEffectError: 'That combat effect no longer exists.',
|
||
});
|
||
if (String(existing.specialization_id) !== specializationId) {
|
||
return fail(400, {
|
||
combatEffectError:
|
||
'A saved combat effect cannot be moved to another specialization.',
|
||
});
|
||
}
|
||
}
|
||
if (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_combat_effects WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?',
|
||
)
|
||
).get(specializationId, name, id || 'new')
|
||
) {
|
||
return fail(409, {
|
||
combatEffectError:
|
||
'That combat-effect name already exists in this specialization.',
|
||
});
|
||
}
|
||
const effectId =
|
||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||
const before = id ? await combatEffectSnapshot(database, id) : null;
|
||
const now = new Date().toISOString();
|
||
if (id) {
|
||
await (
|
||
await database.prepare(`UPDATE specialization_combat_effects
|
||
SET name=?,description_template=?,active=1,updated_at=? WHERE id=?`)
|
||
).run(name, descriptionTemplate, now, id);
|
||
} else {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_combat_effects
|
||
(id,specialization_id,name,description_template,created_at,updated_at)
|
||
VALUES(?,?,?,?,?,?)`)
|
||
).run(effectId, specializationId, name, descriptionTemplate, now, now);
|
||
}
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: id
|
||
? 'specialization_combat_effect.updated'
|
||
: 'specialization_combat_effect.created',
|
||
entityId: effectId,
|
||
before,
|
||
after: await combatEffectSnapshot(database, effectId),
|
||
});
|
||
return {
|
||
combatEffectSaved: true,
|
||
combatEffectMode: id ? 'edit' : 'add',
|
||
resultToken: randomUUID(),
|
||
};
|
||
},
|
||
|
||
saveFocusAbilities: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const focusId = String(form.get('id') ?? form.get('focusId') ?? '');
|
||
const abilityIds = [1, 2, 3].map((slot) =>
|
||
String(form.get(`abilityId${slot}`) ?? ''),
|
||
);
|
||
if (abilityIds.some((id) => !id) || new Set(abilityIds).size !== 3) {
|
||
return fail(400, {
|
||
focusAbilitiesError: 'Assign three different class abilities.',
|
||
});
|
||
}
|
||
const database = await getDatabase();
|
||
const focus = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_focuses WHERE id=? AND active=1',
|
||
)
|
||
).get(focusId);
|
||
if (!focus)
|
||
return fail(404, {
|
||
focusAbilitiesError: 'That active focus no longer exists.',
|
||
});
|
||
if (
|
||
!(await validateAbilityIds(
|
||
database,
|
||
String(focus.specialization_id),
|
||
abilityIds,
|
||
))
|
||
) {
|
||
return fail(400, {
|
||
focusAbilitiesError:
|
||
'Every assigned ability must be active and belong to the same specialization.',
|
||
});
|
||
}
|
||
const before = await focusSnapshot(database, focusId);
|
||
await database.exec('BEGIN');
|
||
try {
|
||
await (
|
||
await database.prepare(
|
||
'DELETE FROM specialization_focus_abilities WHERE focus_id=?',
|
||
)
|
||
).run(focusId);
|
||
for (const [index, abilityId] of abilityIds.entries()) {
|
||
await (
|
||
await database.prepare(
|
||
'INSERT INTO specialization_focus_abilities(focus_id,ability_slot,ability_id) VALUES(?,?,?)',
|
||
)
|
||
).run(focusId, index + 1, abilityId);
|
||
}
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
const readiness = await refreshSpecializationFocusReady(database, focusId);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_focus.abilities_updated',
|
||
entityId: focusId,
|
||
before,
|
||
after: { ...(await focusSnapshot(database, focusId)), readiness },
|
||
});
|
||
return { focusAbilitiesSaved: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
saveNode: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const form = await request.formData();
|
||
const id = String(form.get('id') ?? '');
|
||
const focusId = String(form.get('focusId') ?? '');
|
||
const name = cleanName(form.get('name'));
|
||
const descriptionTemplate = cleanName(form.get('descriptionTemplate'));
|
||
const rowNumber = Number(form.get('rowNumber'));
|
||
const columnNumber = Number(form.get('columnNumber'));
|
||
const maxRank = Number(form.get('maxRank'));
|
||
const ranks = parseJsonArray<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 ?? ''),
|
||
conditionalRule: effect.conditionalRule,
|
||
}))
|
||
: [],
|
||
costs: Object.fromEntries(
|
||
Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [
|
||
resourceId,
|
||
Number(quantity),
|
||
]),
|
||
),
|
||
}),
|
||
);
|
||
if (name.length < 2 || name.length > 100)
|
||
return fail(400, { nodeError: 'Node names must be 2–100 characters.' });
|
||
if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) {
|
||
return fail(400, {
|
||
nodeError: 'The shared node description must be 3–2,400 characters.',
|
||
});
|
||
}
|
||
if (
|
||
!Number.isInteger(rowNumber) ||
|
||
rowNumber < 1 ||
|
||
rowNumber > 20 ||
|
||
!Number.isInteger(columnNumber) ||
|
||
columnNumber < 1 ||
|
||
columnNumber > 12
|
||
) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'Tree placement requires a row from 1–20 and column from 1–12.',
|
||
});
|
||
}
|
||
if (rowNumber === 1 && columnNumber <= 2) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'Top-row columns 1 and 2 are reserved for the free class abilities.',
|
||
});
|
||
}
|
||
if (
|
||
![1, 2, 3].includes(maxRank) ||
|
||
ranks.length !== maxRank ||
|
||
ranks.some(
|
||
(rank, index) =>
|
||
rank.rank !== index + 1 ||
|
||
rank.description.length < 3 ||
|
||
rank.description.length > 2400,
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'Configure one ordered description for every available node rank.',
|
||
});
|
||
}
|
||
const allEffects = ranks.flatMap((rank) => rank.effects);
|
||
const allCombatEffects = ranks.flatMap((rank) => rank.combatEffects);
|
||
if (
|
||
allEffects.length > 36 ||
|
||
allEffects.some(
|
||
(effect) =>
|
||
!['Attribute', 'Ability', 'Custom'].includes(effect.type) ||
|
||
effect.label.length < 2 ||
|
||
effect.label.length > 100 ||
|
||
(effect.value !== null && !Number.isFinite(effect.value)) ||
|
||
!EFFECT_UNITS.has(effect.unit) ||
|
||
(effect.type === 'Attribute' && !effect.statId) ||
|
||
(effect.type === 'Ability' && !effect.abilityId),
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'Every rank effect requires a valid type, target, label, and optional numeric value.',
|
||
});
|
||
}
|
||
if (
|
||
allCombatEffects.length > 18 ||
|
||
allCombatEffects.some(
|
||
(effect) => !effect.effectId || !effect.conditionalRule,
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'Every conditional effect requires a shared effect and a complete calculation rule.',
|
||
});
|
||
}
|
||
const rankOneCosts = ranks[0]?.costs ?? {};
|
||
if (
|
||
Object.values(rankOneCosts).some(
|
||
(quantity) => !Number.isInteger(quantity) || quantity !== 0,
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
nodeError: 'Rank 1 cannot require Electronics Components.',
|
||
});
|
||
}
|
||
if (maxRank >= 2) {
|
||
const costs = ranks[1].costs;
|
||
const invalidResource = Object.entries(costs).some(
|
||
([resourceId, quantity]) =>
|
||
!Number.isInteger(quantity) ||
|
||
quantity < 0 ||
|
||
(!RANK_TWO_RESOURCES.has(resourceId) && quantity !== 0),
|
||
);
|
||
const total = Object.entries(costs)
|
||
.filter(([resourceId]) => RANK_TWO_RESOURCES.has(resourceId))
|
||
.reduce((sum, [, quantity]) => sum + quantity, 0);
|
||
if (invalidResource || total < 1) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'Rank 2 requires positive whole-number quantities of Alpha, Beta, and/or Gamma Components.',
|
||
});
|
||
}
|
||
}
|
||
if (maxRank >= 3) {
|
||
const costs = ranks[2].costs;
|
||
if (
|
||
Number(costs[OMEGA_RESOURCE] ?? 0) !== 1 ||
|
||
Object.entries(costs).some(
|
||
([resourceId, quantity]) =>
|
||
!Number.isInteger(quantity) ||
|
||
quantity < 0 ||
|
||
(resourceId !== OMEGA_RESOURCE && quantity !== 0),
|
||
)
|
||
) {
|
||
return fail(400, {
|
||
nodeError: 'Rank 3 requires exactly one Electronics Omega Component.',
|
||
});
|
||
}
|
||
}
|
||
const database = await getDatabase();
|
||
const parsedCombatRules = new Map<
|
||
(typeof allCombatEffects)[number],
|
||
ConditionalMechanicDraftV1
|
||
>();
|
||
try {
|
||
const activeAttributes = await (
|
||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
||
).all();
|
||
for (const effect of allCombatEffects) {
|
||
parsedCombatRules.set(
|
||
effect,
|
||
parseConditionalRulePayload(
|
||
JSON.stringify(effect.conditionalRule),
|
||
[{ key: 'base', variantKey: 'base' }],
|
||
{
|
||
allowedAttributeIds: new Set(
|
||
activeAttributes.map((row) => String(row.id)),
|
||
),
|
||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||
},
|
||
),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
return fail(400, {
|
||
nodeError:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'One or more conditional mechanics are invalid.',
|
||
});
|
||
}
|
||
if (id) {
|
||
const existing = await (
|
||
await database.prepare(
|
||
'SELECT focus_id FROM specialization_tree_nodes WHERE id=?',
|
||
)
|
||
).get(id);
|
||
if (!existing)
|
||
return fail(404, { nodeError: 'That tree node no longer exists.' });
|
||
if (String(existing.focus_id) !== focusId) {
|
||
return fail(400, {
|
||
nodeError: 'A saved tree node cannot be moved to another focus.',
|
||
});
|
||
}
|
||
}
|
||
const focus = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_focuses WHERE id=? AND active=1',
|
||
)
|
||
).get(focusId);
|
||
if (!focus)
|
||
return fail(404, { nodeError: 'That active focus no longer exists.' });
|
||
if (
|
||
!(await validateAttributeIds(
|
||
database,
|
||
allEffects.map((effect) => effect.statId),
|
||
))
|
||
) {
|
||
return fail(400, {
|
||
nodeError: 'One or more effect Attributes are unavailable.',
|
||
});
|
||
}
|
||
const conditionalAttributeIds = [...parsedCombatRules.values()].flatMap(
|
||
(rule) =>
|
||
mechanicOutcomes(rule)
|
||
.map((outcome) => outcome.attributeId)
|
||
.filter((id): id is string => Boolean(id)),
|
||
);
|
||
if (!(await validateAttributeIds(database, conditionalAttributeIds))) {
|
||
return fail(400, {
|
||
nodeError: 'One or more conditional-effect Attributes are unavailable.',
|
||
});
|
||
}
|
||
if (
|
||
!(await validateAbilityIds(
|
||
database,
|
||
String(focus.specialization_id),
|
||
allEffects.map((effect) => effect.abilityId),
|
||
))
|
||
) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'One or more effect abilities are unavailable for this specialization.',
|
||
});
|
||
}
|
||
const combatEffectIds = [
|
||
...new Set(allCombatEffects.map((effect) => effect.effectId)),
|
||
];
|
||
if (combatEffectIds.length) {
|
||
const count = Number(
|
||
(
|
||
await (
|
||
await database.prepare(
|
||
`SELECT COUNT(*) AS count FROM specialization_combat_effects
|
||
WHERE active=1 AND specialization_id=? AND id IN (${combatEffectIds.map(() => '?').join(',')})`,
|
||
)
|
||
).get(String(focus.specialization_id), ...combatEffectIds)
|
||
)?.count ?? 0,
|
||
);
|
||
if (count !== combatEffectIds.length) {
|
||
return fail(400, {
|
||
nodeError:
|
||
'One or more conditional effects are unavailable for this specialization.',
|
||
});
|
||
}
|
||
}
|
||
if (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND name=? COLLATE NOCASE AND id<>?',
|
||
)
|
||
).get(focusId, name, id || 'new')
|
||
)
|
||
return fail(409, {
|
||
nodeError: 'That node name already exists in this focus.',
|
||
});
|
||
if (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND row_number=? AND column_number=? AND id<>?',
|
||
)
|
||
).get(focusId, rowNumber, columnNumber, id || 'new')
|
||
)
|
||
return fail(409, {
|
||
nodeError: 'Another node already occupies that tree position.',
|
||
});
|
||
let renderMutation = await readGameDataRenderMutation(form);
|
||
try {
|
||
if (renderMutation.file)
|
||
renderMutation.processed = await processManagedImage(
|
||
renderMutation.file,
|
||
false,
|
||
false,
|
||
true,
|
||
);
|
||
} catch (error) {
|
||
return fail(400, {
|
||
nodeError:
|
||
error instanceof ManagedImageValidationError
|
||
? error.message
|
||
: 'The node render could not be processed.',
|
||
});
|
||
}
|
||
let iconMutation;
|
||
try {
|
||
iconMutation = await readIconMutation(form);
|
||
} catch (error) {
|
||
return fail(400, {
|
||
nodeError:
|
||
error instanceof GameDataIconValidationError
|
||
? error.message
|
||
: 'The node icon could not be processed.',
|
||
});
|
||
}
|
||
const nodeId =
|
||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||
const before = id ? await nodeSnapshot(database, id) : null;
|
||
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
|
||
const previousIcon = iconChanged
|
||
? await readGameDataIcon(database, 'specialization_node', nodeId)
|
||
: null;
|
||
const now = new Date().toISOString();
|
||
await database.exec('BEGIN');
|
||
try {
|
||
if (id) {
|
||
await (
|
||
await database.prepare(`UPDATE specialization_tree_nodes
|
||
SET focus_id=?,name=?,description_template=?,row_number=?,column_number=?,max_rank=?,active=1,updated_at=? WHERE id=?`)
|
||
).run(
|
||
focusId,
|
||
name,
|
||
descriptionTemplate,
|
||
rowNumber,
|
||
columnNumber,
|
||
maxRank,
|
||
now,
|
||
id,
|
||
);
|
||
} else {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_tree_nodes
|
||
(id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at)
|
||
VALUES(?,?,?,?,?,?,?,?,?)`)
|
||
).run(
|
||
nodeId,
|
||
focusId,
|
||
name,
|
||
descriptionTemplate,
|
||
rowNumber,
|
||
columnNumber,
|
||
maxRank,
|
||
now,
|
||
now,
|
||
);
|
||
}
|
||
const replacedCombatIds = (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT id FROM specialization_tree_rank_combat_effects WHERE node_id=?',
|
||
)
|
||
).all(nodeId)
|
||
).map((row) => String(row.id));
|
||
for (const attachmentId of replacedCombatIds) {
|
||
await (
|
||
await database.prepare(
|
||
"DELETE FROM conditional_effect_rules WHERE source_type='specialization_tree_rank' AND source_id=?",
|
||
)
|
||
).run(attachmentId);
|
||
}
|
||
await (
|
||
await database.prepare(
|
||
'DELETE FROM specialization_tree_node_ranks WHERE node_id=?',
|
||
)
|
||
).run(nodeId);
|
||
for (const rank of ranks) {
|
||
await (
|
||
await database.prepare(
|
||
'INSERT INTO specialization_tree_node_ranks(node_id,rank,description) VALUES(?,?,?)',
|
||
)
|
||
).run(nodeId, rank.rank, rank.description);
|
||
for (const [index, effect] of rank.effects.entries()) {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_tree_rank_effects
|
||
(id,node_id,rank,effect_order,effect_type,stat_attribute_id,ability_id,label,value,unit)
|
||
VALUES(?,?,?,?,?,?,?,?,?,?)`)
|
||
).run(
|
||
randomUUID(),
|
||
nodeId,
|
||
rank.rank,
|
||
index + 1,
|
||
effect.type,
|
||
effect.statId || null,
|
||
effect.abilityId || null,
|
||
effect.label,
|
||
effect.value,
|
||
effect.unit || null,
|
||
);
|
||
}
|
||
for (const [index, effect] of rank.combatEffects.entries()) {
|
||
const attachmentId = randomUUID();
|
||
const conditionalRule = parsedCombatRules.get(effect);
|
||
if (!conditionalRule)
|
||
throw new Error(
|
||
'A validated conditional mechanic was not available during save.',
|
||
);
|
||
const primaryClause = conditionalRule.clauses[0];
|
||
const reducer = primaryClause?.reducer;
|
||
const legacyTriggerCount = Number(
|
||
reducer && 'threshold' in reducer ? reducer.threshold : 1,
|
||
);
|
||
const legacyWindowSeconds = Number(
|
||
reducer && 'windowSeconds' in reducer
|
||
? reducer.windowSeconds
|
||
: reducer?.kind === 'duration'
|
||
? reducer.seconds
|
||
: 1,
|
||
);
|
||
const legacyPool = conditionalRule.pools[0];
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_tree_rank_combat_effects
|
||
(id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,trigger_window_seconds,
|
||
duration_seconds,max_stacks,stacks_per_trigger,counter_policy,refresh_policy,expiry_policy)
|
||
VALUES(?,?,?,?,?,'enemy_hits_within_window',?,?,?,?,?,'reset_on_proc','refresh_on_stack_gain','clear_all')`)
|
||
).run(
|
||
attachmentId,
|
||
nodeId,
|
||
rank.rank,
|
||
effect.effectId,
|
||
index + 1,
|
||
Number.isInteger(legacyTriggerCount) && legacyTriggerCount > 0
|
||
? legacyTriggerCount
|
||
: 1,
|
||
Number.isFinite(legacyWindowSeconds) && legacyWindowSeconds > 0
|
||
? legacyWindowSeconds
|
||
: 1,
|
||
legacyPool?.expiresAfterSeconds ?? 0.001,
|
||
legacyPool?.max ?? 1,
|
||
1,
|
||
);
|
||
const legacyModifiers = mechanicOutcomes(conditionalRule).filter(
|
||
(outcome) =>
|
||
outcome.kind === 'attribute_modifier' && outcome.attributeId,
|
||
);
|
||
for (const [modifierIndex, modifier] of legacyModifiers.entries()) {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_tree_rank_combat_modifiers
|
||
(id,rank_combat_effect_id,modifier_order,stat_attribute_id,value_per_stack,unit)
|
||
VALUES(?,?,?,?,?,?)`)
|
||
).run(
|
||
randomUUID(),
|
||
attachmentId,
|
||
modifierIndex + 1,
|
||
modifier.attributeId,
|
||
modifier.variants.base.value,
|
||
null,
|
||
);
|
||
}
|
||
await saveConditionalRuleInTransaction(
|
||
database,
|
||
'specialization_tree_rank',
|
||
attachmentId,
|
||
conditionalRule,
|
||
);
|
||
}
|
||
for (const [resourceId, quantity] of Object.entries(rank.costs)) {
|
||
if (Number(quantity) > 0) {
|
||
await (
|
||
await database.prepare(`INSERT INTO specialization_tree_rank_costs
|
||
(node_id,rank,resource_id,quantity) VALUES(?,?,?,?)`)
|
||
).run(nodeId, rank.rank, resourceId, quantity);
|
||
}
|
||
}
|
||
}
|
||
await applyIconMutation(
|
||
database,
|
||
'specialization_node',
|
||
nodeId,
|
||
iconMutation,
|
||
operator.id,
|
||
);
|
||
await applyGameDataRender(
|
||
database,
|
||
'specialization_node',
|
||
nodeId,
|
||
name,
|
||
renderMutation,
|
||
operator.id,
|
||
);
|
||
const readiness = await refreshSpecializationFocusReady(
|
||
database,
|
||
focusId,
|
||
);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: id
|
||
? 'specialization_node.updated'
|
||
: 'specialization_node.created',
|
||
entityId: nodeId,
|
||
before,
|
||
after: { ...(await nodeSnapshot(database, nodeId)), readiness },
|
||
});
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
if (iconChanged)
|
||
await restoreIconFile('specialization_node', nodeId, previousIcon);
|
||
throw error;
|
||
}
|
||
return {
|
||
nodeSaved: true,
|
||
nodeMode: id ? 'edit' : 'add',
|
||
resultToken: randomUUID(),
|
||
};
|
||
},
|
||
|
||
archiveSpecialization: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const before = await specializationSnapshot(database, id);
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specializations SET active=0,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization.archived',
|
||
entityId: id,
|
||
before,
|
||
after: await specializationSnapshot(database, id),
|
||
});
|
||
return { specializationArchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
unarchiveSpecialization: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const before = await specializationSnapshot(database, id);
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specializations SET active=1,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization.unarchived',
|
||
entityId: id,
|
||
before,
|
||
after: await specializationSnapshot(database, id),
|
||
});
|
||
return { specializationUnarchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
archiveFocus: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const before = await focusSnapshot(database, id);
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specialization_focuses SET active=0,builder_ready=0,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_focus.archived',
|
||
entityId: id,
|
||
before,
|
||
after: await focusSnapshot(database, id),
|
||
});
|
||
return { focusArchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
unarchiveFocus: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const before = await focusSnapshot(database, id);
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specialization_focuses SET active=1,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
const readiness = await refreshSpecializationFocusReady(database, id);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_focus.unarchived',
|
||
entityId: id,
|
||
before,
|
||
after: { ...(await focusSnapshot(database, id)), readiness },
|
||
});
|
||
return { focusUnarchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
archiveAbility: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const ability = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_abilities WHERE id=?',
|
||
)
|
||
).get(id);
|
||
const before = await abilitySnapshot(database, id);
|
||
await database.exec('BEGIN');
|
||
try {
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
await (
|
||
await database.prepare(
|
||
`DELETE FROM specialization_ability_skill_chip_links
|
||
WHERE ability_id=? OR compatible_ability_id=?`,
|
||
)
|
||
).run(id, id);
|
||
if (ability)
|
||
await refreshSpecializationFocuses(
|
||
database,
|
||
String(ability.specialization_id),
|
||
);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_ability.archived',
|
||
entityId: id,
|
||
before,
|
||
after: await abilitySnapshot(database, id),
|
||
});
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
return { abilityArchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
unarchiveAbility: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const ability = await (
|
||
await database.prepare(
|
||
'SELECT specialization_id FROM specialization_abilities WHERE id=?',
|
||
)
|
||
).get(id);
|
||
const before = await abilitySnapshot(database, id);
|
||
await database.exec('BEGIN');
|
||
try {
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
if (ability)
|
||
await refreshSpecializationFocuses(
|
||
database,
|
||
String(ability.specialization_id),
|
||
);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_ability.unarchived',
|
||
entityId: id,
|
||
before,
|
||
after: await abilitySnapshot(database, id),
|
||
});
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
return { abilityUnarchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
archiveNode: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const node = await (
|
||
await database.prepare(
|
||
'SELECT focus_id FROM specialization_tree_nodes WHERE id=?',
|
||
)
|
||
).get(id);
|
||
const before = await nodeSnapshot(database, id);
|
||
await database.exec('BEGIN');
|
||
try {
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
const readiness = node
|
||
? await refreshSpecializationFocusReady(database, String(node.focus_id))
|
||
: null;
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_node.archived',
|
||
entityId: id,
|
||
before,
|
||
after: { ...(await nodeSnapshot(database, id)), readiness },
|
||
});
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
return { nodeArchived: true, resultToken: randomUUID() };
|
||
},
|
||
|
||
unarchiveNode: async ({ request, cookies }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'data.manage',
|
||
);
|
||
const id = String((await request.formData()).get('id') ?? '');
|
||
const database = await getDatabase();
|
||
const node = await (
|
||
await database.prepare(
|
||
'SELECT focus_id FROM specialization_tree_nodes WHERE id=?',
|
||
)
|
||
).get(id);
|
||
const before = await nodeSnapshot(database, id);
|
||
await database.exec('BEGIN');
|
||
try {
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?',
|
||
)
|
||
).run(new Date().toISOString(), id);
|
||
const readiness = node
|
||
? await refreshSpecializationFocusReady(database, String(node.focus_id))
|
||
: null;
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'game_data',
|
||
action: 'specialization_node.unarchived',
|
||
entityId: id,
|
||
before,
|
||
after: { ...(await nodeSnapshot(database, id)), readiness },
|
||
});
|
||
await database.exec('COMMIT');
|
||
} catch (error) {
|
||
await database.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
return { nodeUnarchived: true, resultToken: randomUUID() };
|
||
},
|
||
};
|