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
|
||||
|
||||
@@ -32,10 +32,32 @@
|
||||
active: number;
|
||||
iconUrl?: string;
|
||||
};
|
||||
type CombatEffectEditor = {
|
||||
id: string;
|
||||
specializationId: string;
|
||||
name: string;
|
||||
descriptionTemplate: string;
|
||||
active: number;
|
||||
};
|
||||
type RankCombatModifier = {
|
||||
statId: string;
|
||||
valuePerStack: number | string;
|
||||
unit: string;
|
||||
};
|
||||
type RankCombatEffect = {
|
||||
effectId: string;
|
||||
triggerCount: number | string;
|
||||
triggerWindowSeconds: number | string;
|
||||
durationSeconds: number | string;
|
||||
maxStacks: number | string;
|
||||
stacksPerTrigger: number | string;
|
||||
modifiers: RankCombatModifier[];
|
||||
};
|
||||
type RankEditor = {
|
||||
rank: number;
|
||||
description: string;
|
||||
effects: StructuredEffect[];
|
||||
combatEffects: RankCombatEffect[];
|
||||
costs: Record<string, number | string>;
|
||||
};
|
||||
type NodeEditor = {
|
||||
@@ -57,6 +79,13 @@
|
||||
{ value: 'Engineering', label: 'ENGINEERING', icon: 'engineering' as const, iconOnly: true }
|
||||
];
|
||||
const rankOptions = [1, 2, 3].map((rank) => ({ value: String(rank), label: `RANK ${rank}` }));
|
||||
const unitOptions = [
|
||||
{ value: '', label: 'NONE' },
|
||||
{ value: '%', label: '%' },
|
||||
{ value: 's', label: 'SECONDS' },
|
||||
{ value: 'Shots', label: 'SHOTS' },
|
||||
{ value: 'm', label: 'METERS' }
|
||||
];
|
||||
const blankEffect = (): StructuredEffect => ({ type: 'Custom', statId: '', abilityId: '', label: '', value: '', unit: '' });
|
||||
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
|
||||
const blankFocus = (specializationId = ''): FocusEditor => ({
|
||||
@@ -65,10 +94,19 @@
|
||||
const blankAbility = (specializationId = ''): AbilityEditor => ({
|
||||
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', effects: [], active: 1, iconUrl: ''
|
||||
});
|
||||
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
|
||||
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
|
||||
});
|
||||
const blankCombatModifier = (): RankCombatModifier => ({ statId: '', valuePerStack: '', unit: '%' });
|
||||
const blankRankCombatEffect = (): RankCombatEffect => ({
|
||||
effectId: '', triggerCount: 1, triggerWindowSeconds: 1, durationSeconds: 10,
|
||||
maxStacks: 1, stacksPerTrigger: 1, modifiers: [blankCombatModifier()]
|
||||
});
|
||||
const blankRank = (rank: number): RankEditor => ({
|
||||
rank,
|
||||
description: '',
|
||||
effects: [],
|
||||
combatEffects: [],
|
||||
costs: rank === 2
|
||||
? { 'resource-electronics-alpha': 0, 'resource-electronics-beta': 0, 'resource-electronics-gamma': 0 }
|
||||
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
|
||||
@@ -89,6 +127,7 @@
|
||||
let specializationEditing = $state<SpecializationEditor | null>(null);
|
||||
let focusEditing = $state<FocusEditor | null>(null);
|
||||
let abilityEditing = $state<AbilityEditor | null>(null);
|
||||
let combatEffectEditing = $state<CombatEffectEditor | null>(null);
|
||||
let nodeEditing = $state<NodeEditor | null>(null);
|
||||
let progressionEditing = $state(false);
|
||||
let configuringAbilities = $state(false);
|
||||
@@ -101,11 +140,15 @@
|
||||
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
|
||||
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
|
||||
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
|
||||
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
|
||||
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
|
||||
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
|
||||
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
|
||||
ability.specialization_id === specializationId && Number(ability.active) === 1
|
||||
);
|
||||
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
|
||||
effect.specialization_id === specializationId && Number(effect.active) === 1
|
||||
);
|
||||
const nodesForFocus = (focusId: string) => data.nodes.filter((node: any) => node.focus_id === focusId);
|
||||
const activeNodesForFocus = (focusId: string) => nodesForFocus(focusId).filter((node: any) => Number(node.active) === 1);
|
||||
const archivedNodesForFocus = (focusId: string) => nodesForFocus(focusId).filter((node: any) => Number(node.active) === 0);
|
||||
@@ -139,6 +182,7 @@
|
||||
: String(focuses[0]?.id ?? '');
|
||||
configuringAbilities = false;
|
||||
nodeEditing = null;
|
||||
combatEffectEditing = null;
|
||||
};
|
||||
const selectFocus = (id: string) => {
|
||||
selectedFocusId = id;
|
||||
@@ -176,6 +220,17 @@
|
||||
iconUrl: item.iconUrl ?? ''
|
||||
};
|
||||
specializationEditing = focusEditing = nodeEditing = null;
|
||||
combatEffectEditing = null;
|
||||
};
|
||||
const editCombatEffect = (item: any) => {
|
||||
combatEffectEditing = {
|
||||
id: item.id,
|
||||
specializationId: item.specialization_id,
|
||||
name: item.name,
|
||||
descriptionTemplate: item.description_template,
|
||||
active: Number(item.active)
|
||||
};
|
||||
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
|
||||
};
|
||||
const editNode = (item: any) => {
|
||||
nodeEditing = {
|
||||
@@ -190,6 +245,19 @@
|
||||
rank: Number(rank.rank),
|
||||
description: rank.description,
|
||||
effects: (rank.effects ?? []).map(normalizedEffect),
|
||||
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
|
||||
effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''),
|
||||
triggerCount: effect.trigger_count ?? effect.triggerCount ?? 1,
|
||||
triggerWindowSeconds: effect.trigger_window_seconds ?? effect.triggerWindowSeconds ?? 1,
|
||||
durationSeconds: effect.duration_seconds ?? effect.durationSeconds ?? 10,
|
||||
maxStacks: effect.max_stacks ?? effect.maxStacks ?? 1,
|
||||
stacksPerTrigger: effect.stacks_per_trigger ?? effect.stacksPerTrigger ?? 1,
|
||||
modifiers: (effect.modifiers ?? []).map((modifier: any) => ({
|
||||
statId: String(modifier.stat_attribute_id ?? modifier.statId ?? ''),
|
||||
valuePerStack: modifier.value_per_stack ?? modifier.valuePerStack ?? '',
|
||||
unit: String(modifier.unit ?? '%')
|
||||
}))
|
||||
})),
|
||||
costs: { ...(rank.costs ?? {}) }
|
||||
})),
|
||||
active: Number(item.active),
|
||||
@@ -205,6 +273,19 @@
|
||||
nodeEditing?.ranks[index] ?? blankRank(index + 1)
|
||||
);
|
||||
};
|
||||
const addRankCombatEffect = (rank: RankEditor) => {
|
||||
rank.combatEffects = [...rank.combatEffects, blankRankCombatEffect()];
|
||||
};
|
||||
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
|
||||
rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index);
|
||||
};
|
||||
const addCombatModifier = (effect: RankCombatEffect) => {
|
||||
effect.modifiers = [...effect.modifiers, blankCombatModifier()];
|
||||
};
|
||||
const removeCombatModifier = (effect: RankCombatEffect, index: number) => {
|
||||
if (effect.modifiers.length <= 1) return;
|
||||
effect.modifiers = effect.modifiers.filter((_, itemIndex) => itemIndex !== index);
|
||||
};
|
||||
const openAbilityConfiguration = () => {
|
||||
if (!selectedFocus) return;
|
||||
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
|
||||
@@ -244,6 +325,9 @@
|
||||
iconResetToken = token;
|
||||
} else abilityEditing = null;
|
||||
}
|
||||
if (form?.combatEffectSaved) {
|
||||
combatEffectEditing = form.combatEffectMode === 'add' ? blankCombatEffect(selectedSpecializationId) : null;
|
||||
}
|
||||
if (form?.focusAbilitiesSaved) configuringAbilities = false;
|
||||
if (form?.nodeSaved) {
|
||||
if (form.nodeMode === 'add') {
|
||||
@@ -394,6 +478,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if combatEffectEditing}
|
||||
<div class="dashboard-editor specialization-combat-effect-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{combatEffectEditing.id ? 'MODIFY COMBAT EFFECT' : 'NEW COMBAT EFFECT'}</p><h3>{combatEffectEditing.id ? combatEffectEditing.name : 'SHARED EFFECT DEFINITION'}</h3></div><button aria-label="Close combat effect editor" onclick={() => combatEffectEditing = null}>×</button></div>
|
||||
<PendingForm action="?/saveCombatEffect">
|
||||
<input type="hidden" name="id" value={combatEffectEditing.id}/>
|
||||
{#if combatEffectEditing.id}
|
||||
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
|
||||
{:else}
|
||||
<div class="field"><span>SPECIALIZATION</span><SearchableAssignment items={data.specializations} mode="single" compact fieldName="specializationId" subjectLabel="SPECIALIZATION" searchPlaceholder="Search specializations…" bind:value={combatEffectEditing.specializationId}/></div>
|
||||
{/if}
|
||||
<div class="form-grid combat-effect-definition-grid">
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={combatEffectEditing.name} maxlength="100" required placeholder="Relentless"/></label>
|
||||
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {maxStacks} stacks.'}></textarea></label>
|
||||
</div>
|
||||
<p class="configuration-note">Use {`{triggerCount}`}, {`{triggerWindow}`}, {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered Attribute modifiers. Trigger and stack values are configured on each tree rank that grants this effect.</p>
|
||||
{#if form?.combatEffectError}<p class="error">{form.combatEffectError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if nodeEditing}
|
||||
<div class="dashboard-editor specialization-node-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{nodeEditing.id ? 'MODIFY TREE NODE' : 'NEW TREE NODE'}</p><h3>{nodeEditing.id ? nodeEditing.name : 'UPGRADE DEFINITION'}</h3></div><button aria-label="Close tree node editor" onclick={() => nodeEditing = null}>×</button></div>
|
||||
@@ -426,6 +531,34 @@
|
||||
</header>
|
||||
<label class="field">RANK {rank.rank} DESCRIPTION<textarea bind:value={rank.description} maxlength="2400" required placeholder={`Describe the complete Rank ${rank.rank} effect.`}></textarea></label>
|
||||
<StructuredEffectEditor bind:effects={rank.effects} attributes={data.attributes} abilities={abilityList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} idPrefix={`node-rank-${rank.rank}`} addLabel={`ADD RANK ${rank.rank} EFFECT`}/>
|
||||
<div class="rank-combat-effects">
|
||||
<div class="selection-head"><div><span>CONDITIONAL + STACKING EFFECTS</span><small>BUILDER-CONTROLLABLE COMBAT MODEL</small></div><button type="button" class="secondary" onclick={() => addRankCombatEffect(rank)}>ADD CONDITIONAL EFFECT +</button></div>
|
||||
{#each rank.combatEffects as combatEffect, combatIndex}
|
||||
<section class="rank-combat-effect">
|
||||
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></header>
|
||||
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
|
||||
<div class="combat-trigger-grid">
|
||||
<label class="field">ENEMY HITS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.triggerCount} required/></label>
|
||||
<label class="field">WITHIN · SECONDS<input type="number" min="0.01" max="3600" step="any" bind:value={combatEffect.triggerWindowSeconds} required/></label>
|
||||
<label class="field">BUFF DURATION · SECONDS<input type="number" min="0.01" max="86400" step="any" bind:value={combatEffect.durationSeconds} required/></label>
|
||||
<label class="field">MAX STACKS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.maxStacks} required/></label>
|
||||
<label class="field">STACKS PER TRIGGER<input type="number" min="1" max={Number(combatEffect.maxStacks) || 1} step="1" bind:value={combatEffect.stacksPerTrigger} required/></label>
|
||||
</div>
|
||||
<div class="combat-lifecycle-facts"><span>COUNTER <b>RESETS AFTER PROC</b></span><span>STACK GAIN <b>REFRESHES TIMER</b></span><span>EXPIRY <b>CLEARS ALL STACKS</b></span></div>
|
||||
<div class="combat-modifier-list">
|
||||
{#each combatEffect.modifiers as modifier, modifierIndex}
|
||||
<div>
|
||||
<div class="field"><span>ATTRIBUTE PER STACK</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={modifier.statId}/></div>
|
||||
<label class="field">VALUE PER STACK<input type="number" step="any" bind:value={modifier.valuePerStack} required/></label>
|
||||
<SegmentedChoice legend="UNIT" name={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}-unit`} options={unitOptions} columns={5} bind:value={modifier.unit} showIndicator={false}/>
|
||||
<button type="button" class="moderation-action danger" disabled={combatEffect.modifiers.length <= 1} onclick={() => removeCombatModifier(combatEffect, modifierIndex)}>REMOVE</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="secondary" onclick={() => addCombatModifier(combatEffect)}>ADD ATTRIBUTE MODIFIER +</button>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if rank.rank === 2}
|
||||
<fieldset class="specialization-component-costs specialized">
|
||||
<legend>RANK 2 ELECTRONICS COMPONENTS · ONE OR MORE TYPES</legend>
|
||||
@@ -484,6 +617,7 @@
|
||||
<div class="specialization-workbench-actions specialization-mutation">
|
||||
<button class="secondary" onclick={() => editSpecialization(selectedSpecialization)}>EDIT SPECIALIZATION</button>
|
||||
<button class="secondary" onclick={() => { focusEditing = blankFocus(selectedSpecialization.id); specializationEditing = abilityEditing = nodeEditing = null; }}>ADD FOCUS +</button>
|
||||
<button class="secondary" onclick={() => { combatEffectEditing = blankCombatEffect(selectedSpecialization.id); specializationEditing = focusEditing = abilityEditing = nodeEditing = null; }}>ADD COMBAT EFFECT +</button>
|
||||
<button class="primary" onclick={() => { abilityEditing = blankAbility(selectedSpecialization.id); specializationEditing = focusEditing = nodeEditing = null; }}>ADD ABILITY +</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -574,6 +708,22 @@
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
<details class="specialization-record-drawer">
|
||||
<summary>
|
||||
<span>SHARED COMBAT EFFECT REGISTRY</span>
|
||||
<b>{combatEffectList(selectedSpecialization.id).length} RECORDS</b>
|
||||
</summary>
|
||||
<div>
|
||||
{#each data.combatEffects.filter((effect: any) => effect.specialization_id === selectedSpecialization.id) as effect}
|
||||
<article class:archived={Number(effect.active) === 0}>
|
||||
<div><strong>{effect.name}</strong><small>{effect.pending ? 'PENDING OVERSIGHT' : Number(effect.active) === 1 ? 'AVAILABLE TO FOCUS TREE RANKS' : 'ARCHIVED EFFECT'}</small><p>{effect.description_template}</p></div>
|
||||
{#if !effect.pending}<button class="secondary specialization-mutation" onclick={() => editCombatEffect(effect)}>EDIT →</button>{/if}
|
||||
</article>
|
||||
{:else}
|
||||
<p>No shared combat effects are registered for this specialization.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="specialization-tree-console">
|
||||
<header>
|
||||
|
||||
Reference in New Issue
Block a user