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
@@ -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>