feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+288 -72
View File
@@ -1,5 +1,7 @@
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';
@@ -10,15 +12,26 @@ import { deserializeProposal, getPendingEntities } from '$lib/server/data-modera
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconStorageDirectory,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation
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';
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
const RANK_TWO_RESOURCES = new Set([
@@ -42,20 +55,9 @@ type RankEffectInput = AbilityEffectInput & {
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[];
conditionalRule?: unknown;
};
type RankInput = {
@@ -82,6 +84,36 @@ const parseJsonArray = <T>(value: unknown): T[] => {
}
};
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(
@@ -112,6 +144,9 @@ async function focusSnapshot(database: Database, id: string) {
async function abilitySnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_abilities', 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),
@@ -123,6 +158,9 @@ 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(
@@ -140,6 +178,7 @@ async function nodeSnapshot(database: Database, id: string) {
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)
};
}
@@ -199,7 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson', 'attackJson'] },
{ module: 'specializations', action: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] },
{ module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] }
]);
@@ -223,12 +262,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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([
const [specializations, focuses, abilities, abilityAttacks, 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 ${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_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(),
@@ -265,6 +305,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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) : '';
@@ -291,6 +335,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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),
active: 1,
pending: true,
@@ -299,6 +344,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})),
...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),
iconUrl: iconUrl('specialization_ability', String(ability.id))
@@ -326,7 +372,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
.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)
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)]))
@@ -553,6 +600,37 @@ export const actions: Actions = {
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)) {
@@ -568,6 +646,20 @@ export const actions: Actions = {
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(
@@ -611,6 +703,10 @@ export const actions: Actions = {
}
const abilityId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
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 {
@@ -630,21 +726,55 @@ export const actions: Actions = {
(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 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;
}
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, 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)
});
return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
@@ -768,16 +898,7 @@ export const actions: Actions = {
})) : [],
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)
})) : []
conditionalRule: effect.conditionalRule
})) : [],
costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)]))
}));
@@ -806,16 +927,9 @@ export const actions: Actions = {
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))
!effect.effectId || !effect.conditionalRule
)) {
return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' });
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)) {
@@ -842,6 +956,26 @@ export const actions: Actions = {
}
}
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=?'
@@ -858,7 +992,9 @@ export const actions: Actions = {
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)))) {
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))) {
@@ -888,6 +1024,10 @@ export const actions: Actions = {
}
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 {
@@ -901,6 +1041,14 @@ export const actions: Actions = {
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(
@@ -915,18 +1063,57 @@ export const actions: Actions = {
}
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, effect.triggerCount,
effect.triggerWindowSeconds, effect.durationSeconds, effect.maxStacks, effect.stacksPerTrigger);
for (const [modifierIndex, modifier] of effect.modifiers.entries()) {
.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.statId, modifier.valuePerStack, modifier.unit || null);
.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) {
@@ -936,21 +1123,22 @@ export const actions: Actions = {
}
}
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, 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;
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, 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 }
});
return { nodeSaved: true, nodeMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
@@ -1003,9 +1191,16 @@ export const actions: Actions = {
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) });
await database.exec('BEGIN');
try {
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) });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { abilityArchived: true, resultToken: randomUUID() };
},
@@ -1015,9 +1210,16 @@ export const actions: Actions = {
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) });
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() };
},
@@ -1027,9 +1229,16 @@ export const actions: Actions = {
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 } });
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() };
},
@@ -1039,9 +1248,16 @@ export const actions: Actions = {
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 } });
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() };
}
};