feat: model specialization combat effects
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user