feat: model specialization combat effects

This commit is contained in:
JB
2026-07-26 19:04:26 -04:00
parent b1656c430f
commit 338ea8761d
34 changed files with 3396 additions and 146 deletions
+167 -2
View File
@@ -42,10 +42,27 @@ 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[];
};
type RankInput = {
rank?: number;
description?: string;
effects?: RankEffectInput[];
combatEffects?: RankCombatEffectInput[];
costs?: Record<string, number | string>;
};
@@ -103,6 +120,9 @@ async function abilitySnapshot(database: Database, id: string) {
}
async function nodeSnapshot(database: Database, id: string) {
const combatEffects = await (await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id);
return {
record: await auditRecord('specialization_tree_nodes', id),
ranks: await (await database.prepare(
@@ -114,10 +134,25 @@ async function nodeSnapshot(database: Database, id: string) {
costs: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
)).all(id),
combatEffects,
combatModifiers: combatEffects.length ? await (await database.prepare(
`SELECT modifier.* FROM specialization_tree_rank_combat_modifiers modifier
JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=modifier.rank_combat_effect_id
WHERE attachment.node_id=? ORDER BY attachment.rank,attachment.effect_order,modifier.modifier_order`
)).all(id) : [],
icon: await gameDataIconMetadata(database, 'specialization_node', id)
};
}
async function combatEffectSnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_combat_effects', id),
attachments: await (await database.prepare(
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
)).all(id)
};
}
async function validateAttributeIds(database: Database, ids: string[]) {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return true;
@@ -158,6 +193,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization', fields: ['description'] },
{ module: 'specializations', action: 'saveFocus', kind: 'focus', fields: ['specializationId', 'description', 'primaryAttribute', 'displayOrder'] },
{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson'] },
{ module: 'specializations', action: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] },
{ module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] }
]);
const pendingSpecializations = pending.filter((item) => item.kind === 'specialization')
@@ -180,7 +216,7 @@ 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, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
databaseLimit ? (await database.prepare(
`SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`
)).all(...args, databaseLimit, databaseOffset) : [],
@@ -192,6 +228,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(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(),
@@ -217,6 +256,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
]);
const pendingFocuses = pending.filter((item) => item.kind === 'focus');
const pendingAbilities = pending.filter((item) => item.kind === 'ability');
const pendingCombatEffects = pending.filter((item) => item.kind === 'combatEffect');
const pendingNodes = pending.filter((item) => item.kind === 'node');
const iconUrl = (type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
@@ -275,6 +315,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
ranks: nodeRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
...rank,
effects: nodeEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
combatEffects: nodeCombatEffects
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
.map((effect) => ({
...effect,
modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id)
})),
costs: Object.fromEntries(nodeCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank))
.map((cost) => [String(cost.resource_id), Number(cost.quantity)]))
})),
@@ -302,6 +348,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
}),
abilities: loadedAbilities,
combatEffects: [
...pendingCombatEffects.map((item) => ({
...item,
specialization_id: item.specializationId,
description_template: item.descriptionTemplate,
active: 1,
pending: true
})),
...combatEffects
],
nodes: loadedNodes,
resources,
rankProfiles,
@@ -310,7 +366,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingSpecializations.length,
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'node'].includes(item.kind)).length
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'combatEffect', 'node'].includes(item.kind)).length
},
pagination,
filter,
@@ -584,6 +640,61 @@ export const actions: Actions = {
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();
@@ -647,6 +758,19 @@ export const actions: Actions = {
value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value),
unit: cleanName(effect.unit)
})) : [],
combatEffects: Array.isArray(rank.combatEffects) ? rank.combatEffects.map((effect) => ({
effectId: String(effect.effectId ?? ''),
triggerCount: Number(effect.triggerCount),
triggerWindowSeconds: Number(effect.triggerWindowSeconds),
durationSeconds: Number(effect.durationSeconds),
maxStacks: Number(effect.maxStacks),
stacksPerTrigger: Number(effect.stacksPerTrigger ?? 1),
modifiers: Array.isArray(effect.modifiers) ? effect.modifiers.map((modifier) => ({
statId: String(modifier.statId ?? ''),
valuePerStack: Number(modifier.valuePerStack),
unit: cleanName(modifier.unit)
})) : []
})) : [],
costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)]))
}));
if (name.length < 2 || name.length > 100) return fail(400, { nodeError: 'Node names must be 2–100 characters.' });
@@ -665,6 +789,7 @@ export const actions: Actions = {
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)
@@ -672,6 +797,18 @@ export const actions: Actions = {
|| (effect.type === 'Ability' && !effect.abilityId))) {
return fail(400, { nodeError: 'Every rank effect requires a valid type, target, label, and optional numeric value.' });
}
if (allCombatEffects.length > 18 || allCombatEffects.some((effect) =>
!effect.effectId
|| !Number.isInteger(effect.triggerCount) || effect.triggerCount < 1 || effect.triggerCount > 1000
|| !Number.isFinite(effect.triggerWindowSeconds) || effect.triggerWindowSeconds <= 0 || effect.triggerWindowSeconds > 3600
|| !Number.isFinite(effect.durationSeconds) || effect.durationSeconds <= 0 || effect.durationSeconds > 86400
|| !Number.isInteger(effect.maxStacks) || effect.maxStacks < 1 || effect.maxStacks > 1000
|| !Number.isInteger(effect.stacksPerTrigger) || effect.stacksPerTrigger < 1 || effect.stacksPerTrigger > effect.maxStacks
|| effect.modifiers.length < 1 || effect.modifiers.length > 12
|| effect.modifiers.some((modifier) => !modifier.statId || !Number.isFinite(modifier.valuePerStack) || !EFFECT_UNITS.has(modifier.unit))
)) {
return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' });
}
const rankOneCosts = ranks[0]?.costs ?? {};
if (Object.values(rankOneCosts).some((quantity) => !Number.isInteger(quantity) || quantity !== 0)) {
return fail(400, { nodeError: 'Rank 1 cannot require Electronics Components.' });
@@ -713,9 +850,22 @@ 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)))) {
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.' });
@@ -755,6 +905,21 @@ export const actions: Actions = {
.run(randomUUID(), nodeId, rank.rank, index + 1, effect.type, effect.statId || null,
effect.abilityId || null, effect.label, effect.value, effect.unit || null);
}
for (const [index, effect] of rank.combatEffects.entries()) {
const attachmentId = randomUUID();
await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_effects
(id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,trigger_window_seconds,
duration_seconds,max_stacks,stacks_per_trigger,counter_policy,refresh_policy,expiry_policy)
VALUES(?,?,?,?,?,'enemy_hits_within_window',?,?,?,?,?,'reset_on_proc','refresh_on_stack_gain','clear_all')`))
.run(attachmentId, nodeId, rank.rank, effect.effectId, index + 1, effect.triggerCount,
effect.triggerWindowSeconds, effect.durationSeconds, effect.maxStacks, effect.stacksPerTrigger);
for (const [modifierIndex, modifier] of effect.modifiers.entries()) {
await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_modifiers
(id,rank_combat_effect_id,modifier_order,stat_attribute_id,value_per_stack,unit)
VALUES(?,?,?,?,?,?)`))
.run(randomUUID(), attachmentId, modifierIndex + 1, modifier.statId, modifier.valuePerStack, modifier.unit || null);
}
}
for (const [resourceId, quantity] of Object.entries(rank.costs)) {
if (Number(quantity) > 0) {
await (await database.prepare(`INSERT INTO specialization_tree_rank_costs