feat(isac): add specialization data management

This commit is contained in:
JB
2026-07-26 16:20:04 -04:00
parent ed31748102
commit 6722665f5c
25 changed files with 3369 additions and 25 deletions
+2
View File
@@ -36,3 +36,5 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/_img
+146 -2
View File
@@ -277,7 +277,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS data_change_proposals (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')),
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
@@ -322,6 +322,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_login_username_nocase_idx ON users(login
CREATE TABLE IF NOT EXISTS stat_attributes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive', 'Multiplicative')),
@@ -776,8 +777,151 @@ CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
);
CREATE TABLE IF NOT EXISTS specialization_progression_config (
id TEXT PRIMARY KEY CHECK(id='default'),
xp_points INTEGER NOT NULL CHECK(xp_points>=0),
shd_cache_points INTEGER NOT NULL CHECK(shd_cache_points>=0),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO specialization_progression_config(id,xp_points,shd_cache_points,updated_at)
VALUES('default',30,19,'2026-07-26T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS specialization_rank_cost_profiles (
rank INTEGER PRIMARY KEY CHECK(rank BETWEEN 1 AND 3),
point_cost INTEGER NOT NULL CHECK(point_cost>0),
credit_cost INTEGER NOT NULL CHECK(credit_cost>=0),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at)
VALUES(1,1,2333,'2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at)
VALUES(2,2,10000,'2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at)
VALUES(3,3,90000,'2026-07-26T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS upgrade_resources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK(resource_type IN ('Currency','Component','Progression')),
quality TEXT CHECK(quality IS NULL OR quality IN ('Standard','Specialized','Superior','High-end')),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS upgrade_resources_name_nocase_idx ON upgrade_resources(name COLLATE NOCASE);
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-credits','Credits','Currency',NULL,'2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-alpha','Electronics Alpha Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-beta','Electronics Beta Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-gamma','Electronics Gamma Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-omega','Electronics Omega Component','Component','High-end','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-shd-tech-cache','SHD Tech Cache','Progression','Superior','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS specializations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS specializations_name_nocase_idx ON specializations(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS specialization_focuses (
id TEXT PRIMARY KEY,
specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
primary_attribute TEXT NOT NULL CHECK(primary_attribute IN ('Firepower','Toughness','Engineering')),
display_order INTEGER NOT NULL DEFAULT 0 CHECK(display_order>=0),
builder_ready INTEGER NOT NULL DEFAULT 0 CHECK(builder_ready IN (0,1)),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(specialization_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_focuses_specialization_idx ON specialization_focuses(specialization_id,display_order,name);
CREATE TABLE IF NOT EXISTS specialization_abilities (
id TEXT PRIMARY KEY,
specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL,
duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0),
cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(specialization_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
effect_order INTEGER NOT NULL CHECK(effect_order>=1),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
label TEXT NOT NULL,
value REAL,
unit TEXT,
UNIQUE(ability_id,effect_order)
);
CREATE TABLE IF NOT EXISTS specialization_focus_abilities (
focus_id TEXT NOT NULL REFERENCES specialization_focuses(id) ON DELETE CASCADE,
ability_slot INTEGER NOT NULL CHECK(ability_slot BETWEEN 1 AND 3),
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
PRIMARY KEY(focus_id,ability_slot),
UNIQUE(focus_id,ability_id)
);
CREATE TABLE IF NOT EXISTS specialization_tree_nodes (
id TEXT PRIMARY KEY,
focus_id TEXT NOT NULL REFERENCES specialization_focuses(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description_template TEXT NOT NULL,
row_number INTEGER NOT NULL CHECK(row_number>=1),
column_number INTEGER NOT NULL CHECK(column_number>=1),
max_rank INTEGER NOT NULL CHECK(max_rank BETWEEN 1 AND 3),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(focus_id,row_number,column_number),
UNIQUE(focus_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_tree_nodes_focus_idx ON specialization_tree_nodes(focus_id,row_number,column_number);
CREATE TABLE IF NOT EXISTS specialization_tree_node_ranks (
node_id TEXT NOT NULL REFERENCES specialization_tree_nodes(id) ON DELETE CASCADE,
rank INTEGER NOT NULL CHECK(rank BETWEEN 1 AND 3),
description TEXT NOT NULL,
PRIMARY KEY(node_id,rank)
);
CREATE TABLE IF NOT EXISTS specialization_tree_rank_effects (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
rank INTEGER NOT NULL,
effect_order INTEGER NOT NULL CHECK(effect_order>=1),
effect_type TEXT NOT NULL CHECK(effect_type IN ('Attribute','Ability','Custom')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
ability_id TEXT REFERENCES specialization_abilities(id),
label TEXT NOT NULL,
value REAL,
unit TEXT,
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE,
UNIQUE(node_id,rank,effect_order),
CHECK(
(effect_type='Attribute' AND stat_attribute_id IS NOT NULL)
OR (effect_type='Ability' AND ability_id IS NOT NULL)
OR effect_type='Custom'
)
);
CREATE TABLE IF NOT EXISTS specialization_tree_rank_costs (
node_id TEXT NOT NULL,
rank INTEGER NOT NULL,
resource_id TEXT NOT NULL REFERENCES upgrade_resources(id),
quantity INTEGER NOT NULL CHECK(quantity>0),
PRIMARY KEY(node_id,rank,resource_id),
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS game_data_icons (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
+62
View File
@@ -447,6 +447,10 @@ squeezed beside long module headings on mobile.
so changing one history page does not reset the others.
- Each module starts with `module-toolbar`, followed by `module-kpis`.
- Editors use `dashboard-editor` and appear before the affected table.
- Optional record descriptions use a full-width narrative field beneath the
identity controls. Preserve the same full-width placement in Oversight
before/after previews so reviewers can compare meaning without reading raw
proposal data.
- Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`.
- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably.
- User dossiers pair access controls and moderation in a two-column console,
@@ -1460,6 +1464,9 @@ proposing a canonical item. Upload fields must state that an icon may be
contributed later and show the shared `/missing-item-icon.svg` artwork when no
upload exists. Registries, relationship rails, and builder selection controls
use the same placeholder instead of initials, empty boxes, or ad hoc glyphs.
Application code references that asset through the shared
`MISSING_ITEM_ICON_URL` constant and renders it through `ItemIcon`; do not
duplicate its path or introduce module-specific missing-icon artwork.
Treat the placeholder as an explicit incomplete-data state: keep its subdued
SHD styling and useful alternative text where the surrounding item name is not
@@ -1630,3 +1637,58 @@ four-by-two matrix; the grid reduces to two columns and then one as available
space narrows. Registry cards lead with complete category names. Counts and
availability remain quiet footer metadata, and internal abbreviations or
workflow-centric states are not exposed as primary public labels.
### ISAC progression-tree composer
Use the Specialization workbench pattern for ordered game systems whose
meaning depends on both hierarchy and spatial placement. Keep the registry,
selected parent identity, child navigation, readiness telemetry, and visual
tree in one operational surface. Editors still open above the registry and
follow the standard ISAC lifecycle; the workbench is navigation and context,
not a replacement modal.
The tree uses a stable 12-column track per row. Row and column are explicit
numeric fields so placement is keyboard operable, auditable, and portable;
never make drag-and-drop the only authoring method. On narrow screens, retain
the geometry in a horizontally scrollable canvas instead of squeezing nodes
until their names and state are unreadable. Controls outside the canvas stack
and maintain at least a 44-pixel touch target.
Each row header states its derived gate in plain language. Specialization gates
count all available Rank 1 and Rank 2 unlocks in preceding rows, including the
two immediately available ability nodes displayed in the first row. Do not
store or manually edit a second gate value that could diverge. Free nodes use
the orange identity rail plus the explicit `1 / 1 · FREE` state; pending,
archived, incomplete, and builder-ready states must remain textual as well as
colored.
Treat allocation scope as first-class telemetry. Specialization progression is
unlocked once for a specialization, but every focus receives the full point
allocation independently. Show both the point total and that scope near the
tree; do not imply that focuses draw from one shared spend pool. A focus is
builder-ready only after it has three distinct active abilities, contiguous
tree rows, at least one paid node, and exactly the configured available point
total.
Compose focus identity from the uploaded specialization artwork and the
canonical Firepower, Toughness, or Engineering icon. Keep the specialization
art primary and the attribute mark as a smaller corner badge. Ability slots 1
and 3 are mirrored as free tree nodes; slot 2 is labeled Combat Skills-only and
must not appear in the upgrade tree. Universal grenade and medical-kit actions
are not specialization abilities.
Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of
calculation-ready effects. It provides Attribute, Ability when applicable, and
Custom targets; a numeric value; and the shared bounded unit vocabulary. Keep
the target searchable, show all bounded type and unit choices with segmented
controls, and preserve explicit custom labels for mechanics that calculations
do not yet support. Oversight previews must render effect and rank summaries as
structured cards. Serialized JSON belongs only in the expandable raw-evidence
panel.
Rank cards present the complete rank description, structured effects, point
cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma
components use the shared blue quality treatment and may be combined in any
positive whole-number quantities for Rank 2. Rank 3 uses the yellow High-end
Omega treatment and exactly one component. Color is supporting hierarchy;
quality names and quantities always remain visible.
+1 -1
View File
@@ -302,7 +302,7 @@ Icon metadata is optional. Canonical records and contributor proposals may be
created without an upload, allowing values and artwork to be contributed in
separate dependency-safe changes. Missing artwork is represented at render
time by the static `/missing-item-icon.svg` asset through the shared
`ItemIcon` component; the placeholder is never inserted into
`MISSING_ITEM_ICON_URL` constant and `ItemIcon` component; the placeholder is never inserted into
`game_data_icons`. A later edit or proposal can attach the real icon without
recreating or replacing the canonical item.
# Oversight notification dispatch
+57 -1
View File
@@ -183,6 +183,40 @@ if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDe
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
}
const specializationProposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
if (typeof specializationProposalDefinition?.sql === 'string' && !specializationProposalDefinition.sql.includes("'specializations'")) {
console.log('Adding Specializations to moderated data modules.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec(`CREATE TABLE data_change_proposals_specializations (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
reviewed_by TEXT REFERENCES users(id),
review_note TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_specializations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec('ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals');
await database.exec('PRAGMA foreign_keys=ON');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
}
const iconDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
)).get();
@@ -201,6 +235,25 @@ if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'sk
await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons');
}
const specializationIconDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
)).get();
if (typeof specializationIconDefinition?.sql === 'string' && !specializationIconDefinition.sql.includes("'specialization_node'")) {
console.log('Adding Specialization icon storage support.');
await database.exec(`CREATE TABLE game_data_icons_specializations (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(entity_type, entity_id)
)`);
await database.exec(`INSERT INTO game_data_icons_specializations
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
await database.exec('DROP TABLE game_data_icons');
await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons');
}
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
@@ -290,11 +343,14 @@ if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) {
const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all();
for (const [name, definition] of [
['description', 'TEXT'],
['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'],
['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)']
]) {
if (!attributeColumns.some((column) => column.name === name)) {
console.log(`Recording the reference level for Attribute source ranges: ${name}.`);
console.log(name === 'description'
? 'Adding optional Attribute descriptions.'
: `Recording the reference level for Attribute source ranges: ${name}.`);
await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`);
}
}
+12
View File
@@ -48,6 +48,18 @@ export const ISAC_DATA_TABLES = [
'gear_mod_quality_values',
'gear_mod_secondary_options',
'gear_mod_secondary_quality_values',
'specialization_progression_config',
'specialization_rank_cost_profiles',
'upgrade_resources',
'specializations',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_effects',
'specialization_focus_abilities',
'specialization_tree_nodes',
'specialization_tree_node_ranks',
'specialization_tree_rank_effects',
'specialization_tree_rank_costs',
'game_data_icons',
'managed_images'
];
+1215
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
let {
currentUrl = '',
label = 'ICON',
@@ -9,7 +11,7 @@
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl={MISSING_ITEM_ICON_URL} {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>OPTIONAL · SVG, PNG, WEBP, AVIF, OR JPEG · OPAQUE BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
<small>OPTIONAL · SAVE NOW, ADD AN ICON LATER · SVG, PNG, WEBP, AVIF, OR JPEG · BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
</fieldset>
+5 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
let {
src = '',
alt = '',
@@ -8,6 +10,8 @@
alt?: string;
className?: string;
} = $props();
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || '/missing-item-icon.svg'} {alt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
@@ -35,6 +35,70 @@
};
return proposed[names[key] ?? key];
};
const specializationKind = $derived(
action === 'saveProgression' ? 'PROGRESSION PROFILE'
: action === 'saveSpecialization' ? 'SPECIALIZATION'
: action === 'saveFocus' ? 'FOCUS'
: action === 'saveAbility' ? 'CLASS ABILITY'
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const specializationFields = $derived(
action === 'saveProgression'
? [
{ label: 'XP POINTS', current: specializationRecord?.xp_points, proposed: proposed.xpPoints },
{ label: 'SHD TECH CACHE POINTS', current: specializationRecord?.shd_cache_points, proposed: proposed.cachePoints }
]
: action === 'saveSpecialization'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveFocus'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'PRIMARY ATTRIBUTE', current: specializationRecord?.primary_attribute, proposed: proposed.primaryAttribute },
{ label: 'DISPLAY ORDER', current: specializationRecord?.display_order, proposed: proposed.displayOrder },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveAbility'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveNode'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'FOCUS', current: specializationRecord?.focus_id, proposed: proposed.focusId },
{ label: 'ROW', current: specializationRecord?.row_number, proposed: proposed.rowNumber },
{ label: 'COLUMN', current: specializationRecord?.column_number, proposed: proposed.columnNumber },
{ label: 'MAXIMUM RANK', current: specializationRecord?.max_rank, proposed: proposed.maxRank },
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
]
: [
{ label: 'FOCUS', current: specializationRecord?.name ?? specializationRecord?.id, proposed: proposed.id },
{ label: 'ABILITY SLOT 1 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 1)?.ability_id, proposed: proposed.abilityId1 },
{ label: 'ABILITY SLOT 2 · COMBAT SKILLS', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 2)?.ability_id, proposed: proposed.abilityId2 },
{ label: 'ABILITY SLOT 3 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 3)?.ability_id, proposed: proposed.abilityId3 }
]
);
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
</script>
<section class="proposal-change-preview" aria-label="Proposed data comparison">
@@ -50,6 +114,7 @@
{#if currentSnapshot}
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('current', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
@@ -66,6 +131,7 @@
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('proposed', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
@@ -76,6 +142,70 @@
</div>
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if specializationRecord}
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
<section><header><span>RANK {profile.rank}</span><b>{profile.point_cost} POINTS</b></header><p>{Number(profile.credit_cost).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
{:else}
<div class="proposal-empty-state"><b>NEW {specializationKind}</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {specializationKind}</span><b>AFTER</b></header>
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} EFFECTS</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
<section><header><span>RANK {rank}</span><b>{shown(proposed[`rank${rank}PointCost`])} POINTS</b></header><p>{Number(proposed[`rank${rank}CreditCost`] || 0).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
</article>
</div>
{:else}
<div class="proposal-editor-preview generic after">
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
@@ -0,0 +1,102 @@
<script lang="ts">
import SearchableAssignment from './SearchableAssignment.svelte';
import SegmentedChoice from './SegmentedChoice.svelte';
type RegistryItem = { id: string; name: string; pending?: boolean; proposerName?: string };
export type StructuredEffect = {
type: 'Attribute' | 'Ability' | 'Custom';
statId: string;
abilityId: string;
label: string;
value: number | string | null;
unit: string;
};
let {
effects = $bindable([]),
attributes,
abilities = [],
idPrefix = 'effect',
emptyLabel = 'NO STRUCTURED EFFECTS',
addLabel = 'ADD EFFECT',
allowTargetLabelFallback = false
}: {
effects?: StructuredEffect[];
attributes: RegistryItem[];
abilities?: RegistryItem[];
idPrefix?: string;
emptyLabel?: string;
addLabel?: string;
allowTargetLabelFallback?: boolean;
} = $props();
const effectTypes = $derived([
{ value: 'Attribute', label: 'ATTRIBUTE' },
...(abilities.length ? [{ value: 'Ability', label: 'ABILITY' }] : []),
{ value: 'Custom', label: 'CUSTOM' }
]);
const units = [
{ value: '', label: 'NONE' },
{ value: '%', label: '%' },
{ value: 's', label: 'SECONDS' },
{ value: 'Shots', label: 'SHOTS' },
{ value: 'm', label: 'METERS' }
];
const blank = (): StructuredEffect => ({
type: 'Custom',
statId: '',
abilityId: '',
label: '',
value: '',
unit: ''
});
const changeType = (index: number, type: string) => {
effects[index] = {
...effects[index],
type: type as StructuredEffect['type'],
statId: type === 'Attribute' ? effects[index].statId : '',
abilityId: type === 'Ability' ? effects[index].abilityId : ''
};
};
</script>
<div class="structured-effect-editor">
{#each effects as effect, index}
{@const labelCanUseTarget = allowTargetLabelFallback && ['Attribute', 'Ability'].includes(effect.type)}
<section class="structured-effect-row">
<header><strong>EFFECT {String(index + 1).padStart(2, '0')}</strong><button type="button" class="danger-link" onclick={() => effects = effects.filter((_, itemIndex) => itemIndex !== index)}>REMOVE</button></header>
<div class="structured-effect-grid">
<SegmentedChoice
legend="EFFECT TYPE"
name={`${idPrefix}-type-${index}`}
options={effectTypes}
bind:value={effect.type}
onchange={(values) => changeType(index, values[0])}
showIndicator={false}
required
/>
{#if effect.type === 'Attribute'}
<div class="field"><span>ATTRIBUTE TARGET</span><SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={effect.statId}/></div>
{:else if effect.type === 'Ability'}
<div class="field"><span>CLASS ABILITY TARGET</span><SearchableAssignment items={abilities} mode="single" compact subjectLabel="CLASS ABILITY" searchPlaceholder="Search abilities…" bind:value={effect.abilityId}/></div>
{:else}
<div class="effect-custom-target"><span>CUSTOM MECHANIC</span><small>Recorded for display now and calculation support later.</small></div>
{/if}
<label class="field effect-label">
EFFECT LABEL{labelCanUseTarget ? ' · OPTIONAL' : ''}
<input
bind:value={effect.label}
maxlength="100"
placeholder={labelCanUseTarget ? 'Uses the selected target name when blank' : 'e.g. Damage Reduction'}
required={!labelCanUseTarget}
/>
</label>
<label class="field">VALUE<input type="number" step="any" bind:value={effect.value} placeholder="OPTIONAL"/></label>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit} showIndicator={false}/>
</div>
</section>
{:else}
<div class="structured-effect-empty"><strong>{emptyLabel}</strong><span>Add structured values when this mechanic should contribute to calculations or an ability preview.</span></div>
{/each}
<button type="button" class="secondary structured-effect-add" onclick={() => effects = [...effects, blank()]}>{addLabel} +</button>
</div>
+1
View File
@@ -0,0 +1 @@
export const MISSING_ITEM_ICON_URL = '/missing-item-icon.svg';
+17 -1
View File
@@ -27,7 +27,23 @@ export async function recordIsacAudit(input: {
}
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'gear_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
if (![
'stat_attributes',
'talents',
'talent_triggers',
'brand_sets',
'gear',
'weapons',
'weapon_mods',
'gear_mods',
'os_protocols',
'skill_chip_sets',
'specializations',
'specialization_focuses',
'specialization_abilities',
'specialization_tree_nodes',
'roles'
].includes(table)) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
+10 -2
View File
@@ -6,7 +6,7 @@ import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
export type DataModule = typeof DATA_MODULES[number];
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
@@ -28,7 +28,15 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' }
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } }
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: { saved: 'specializationSaved', mode: 'specializationMode' },
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' }
}
};
export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) {
+5 -2
View File
@@ -4,13 +4,16 @@ import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const;
export const GAME_ICON_TYPES = [
'talent', 'brand_set', 'os_protocol', 'skill_chip',
'specialization', 'specialization_ability', 'specialization_node'
] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip)-[0-9a-f-]{36}\.webp$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
export class GameDataIconValidationError extends Error {}
+1 -1
View File
@@ -6,7 +6,7 @@ import { BackgroundRemovalError, removeBackgroundPreview } from './remove-backgr
export const MANAGED_IMAGE_CATEGORIES = [
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'System'
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System'
] as const;
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
+1
View File
@@ -13,6 +13,7 @@
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
{ href: '/isac/specializations', label: 'Specializations', module: 'specializations' },
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
{ href: '/isac/brands', label: 'Brand sets', module: 'brands' },
+5 -3
View File
@@ -15,7 +15,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
}])).map((stat) => ({
...stat,
calculation_type: stat.calculationType,
@@ -64,8 +64,10 @@ export const actions: Actions = {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' });
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; };
@@ -76,8 +78,8 @@ export const actions: Actions = {
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' });
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('stat_attributes', id) : null;
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
const savedItem = await auditRecord('stat_attributes', statId);
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
+6 -3
View File
@@ -12,8 +12,11 @@
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
const attributeMeta = (stat: any) => stat.pending
? `PROPOSED BY ${stat.proposerName}`
: [stat.description, stat.unit || 'NO UNIT'].filter(Boolean).join(' · ');
$effect(() => {
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
@@ -28,6 +31,6 @@
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
{#if editing}<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each visibleStats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending}><div><strong>{stat.name}</strong><small>{stat.pending ? `PROPOSED BY ${stat.proposerName}` : stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{/each}</div>
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each visibleStats as stat}<div class="dashboard-row attribute-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending}><div><strong>{stat.name}</strong><small title={attributeMeta(stat)}>{attributeMeta(stat)}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{/each}</div>
+2 -2
View File
@@ -113,9 +113,9 @@ export const actions: Actions = {
if (action.startsWith('attribute.')) {
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
.run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
.run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
} else if (action.endsWith('.archived')) {
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
@@ -10,11 +10,59 @@ import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
async function specializationProposalSnapshot(database: any, proposal: any) {
const id = String(proposal.entity_id ?? '');
if (proposal.action === 'saveProgression') {
return {
record: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
rankProfiles: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all()
};
}
if (!id) return null;
if (proposal.action === 'saveSpecialization') {
return { record: await (await database.prepare('SELECT * FROM specializations WHERE id=?')).get(id) ?? null };
}
if (proposal.action === 'saveFocus') {
return { record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null };
}
if (proposal.action === 'saveAbility') {
return {
record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null,
effects: await (await database.prepare(
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
)).all(id)
};
}
if (proposal.action === 'saveFocusAbilities') {
return {
record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null,
abilities: await (await database.prepare(
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot'
)).all(id)
};
}
if (proposal.action === 'saveNode') {
return {
record: await (await database.prepare('SELECT * FROM specialization_tree_nodes WHERE id=?')).get(id) ?? null,
ranks: await (await database.prepare(
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank'
)).all(id),
effects: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id),
costs: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
)).all(id)
};
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
@@ -40,7 +88,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
: null
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null
})));
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals: proposalsWithSnapshots, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
+1 -1
View File
@@ -55,7 +55,7 @@
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
{#each data.proposals as proposal}
<div class="dashboard-row moderation-row mobile-detail-row">
<div class="change-identity"><span>{proposal.module === 'skillchips' ? 'CHIP' : proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}</small></div></div>
<div class="change-identity"><span>{proposal.module === 'skillchips' ? 'CHIP' : proposal.module === 'specializations' ? 'CLASS' : proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}</small></div></div>
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
<span>{date(proposal.created_at)}</span>
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="editor-title"><div><p class="eyebrow">NEW MANAGED ASSET</p><h3>UPLOAD + PROCESS</h3></div><button type="button" aria-label="Cancel image upload" onclick={() => adding = false}>×</button></div>
<PendingForm action="?/upload" enctype="multipart/form-data" successMessage="Image added to the library.">
<div class="image-library-form">
<div class="image-library-upload"><ImageUploadInput name="image" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" required {resetToken} emptyLabel="SELECT IMAGE"/></div>
<div class="image-library-upload"><ImageUploadInput name="image" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" required {resetToken} emptyLabel="NO PREVIEW"/></div>
<div class="image-library-metadata">
<label class="field">ASSET NAME<input name="name" maxlength="80" required placeholder="e.g. Weapon critical hit"/></label>
<label class="field">ASSIGNMENT CATEGORY<select name="category" required>{#each data.categories as category}<option>{category}</option>{/each}</select></label>
@@ -0,0 +1,907 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Database } from '@tursodatabase/database';
import type { Actions, PageServerLoad } from './$types';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { deserializeProposal, getPendingEntities } from '$lib/server/data-moderation';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation
} from '$lib/server/game-data-icons';
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
const RANK_TWO_RESOURCES = new Set([
'resource-electronics-alpha',
'resource-electronics-beta',
'resource-electronics-gamma'
]);
const OMEGA_RESOURCE = 'resource-electronics-omega';
const EFFECT_UNITS = new Set(['', '%', 's', 'Shots', 'm']);
type AbilityEffectInput = {
type?: string;
statId?: string;
label?: string;
value?: number | string | null;
unit?: string;
};
type RankEffectInput = AbilityEffectInput & {
type?: string;
abilityId?: string;
};
type RankInput = {
rank?: number;
description?: string;
effects?: RankEffectInput[];
costs?: Record<string, number | string>;
};
const cleanName = (value: unknown) => String(value ?? '').normalize('NFKC').trim();
const optionalPositive = (value: unknown) => {
const text = String(value ?? '').trim();
if (!text) return null;
const numeric = Number(text);
return Number.isFinite(numeric) && numeric > 0 ? numeric : Number.NaN;
};
const parseJsonArray = <T>(value: unknown): T[] => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
async function specializationSnapshot(database: Database, id: string) {
const record = await auditRecord('specializations', id);
const focusIds = (await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=? ORDER BY display_order,name'
)).all(id)).map((row) => String(row.id));
const abilityIds = (await (await database.prepare(
'SELECT id FROM specialization_abilities WHERE specialization_id=? ORDER BY name'
)).all(id)).map((row) => String(row.id));
return {
record,
icon: await gameDataIconMetadata(database, 'specialization', id),
focusIds,
abilityIds
};
}
async function focusSnapshot(database: Database, id: string) {
const record = await auditRecord('specialization_focuses', id);
const abilities = await (await database.prepare(
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot'
)).all(id);
const nodeIds = (await (await database.prepare(
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? ORDER BY row_number,column_number'
)).all(id)).map((row) => String(row.id));
return { record, abilities, nodeIds };
}
async function abilitySnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_abilities', id),
effects: await (await database.prepare(
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
)).all(id),
icon: await gameDataIconMetadata(database, 'specialization_ability', id)
};
}
async function nodeSnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_tree_nodes', id),
ranks: await (await database.prepare(
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank'
)).all(id),
effects: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id),
costs: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
)).all(id),
icon: await gameDataIconMetadata(database, 'specialization_node', id)
};
}
async function refreshFocusReady(database: Database, focusId: string) {
const focus = await (await database.prepare(
'SELECT id,active FROM specialization_focuses WHERE id=?'
)).get(focusId);
if (!focus) return { ready: false, pointTotal: 0, availablePoints: 0, abilityCount: 0 };
const [config, abilityCountRow, nodes, rankProfiles] = await Promise.all([
(await database.prepare("SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)).get(focusId),
(await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number'
)).all(focusId),
(await database.prepare('SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank')).all()
]);
const pointForRank = new Map(rankProfiles.map((profile) => [Number(profile.rank), Number(profile.point_cost)]));
const pointTotal = nodes.reduce((sum, node) => {
for (let rank = 1; rank <= Number(node.max_rank); rank += 1) sum += pointForRank.get(rank) ?? rank;
return sum;
}, 0);
const rows = [...new Set(nodes.map((node) => Number(node.row_number)))];
const contiguousRows = rows.every((row, index) => row === index + 1);
const abilityCount = Number(abilityCountRow?.count ?? 0);
const availablePoints = Number(config?.xp_points ?? 30) + Number(config?.shd_cache_points ?? 19);
const ready = Number(focus.active) === 1
&& abilityCount === 3
&& nodes.length > 0
&& contiguousRows
&& pointTotal === availablePoints;
await (await database.prepare(
'UPDATE specialization_focuses SET builder_ready=?,updated_at=? WHERE id=?'
)).run(ready ? 1 : 0, new Date().toISOString(), focusId);
return { ready, pointTotal, availablePoints, abilityCount };
}
async function refreshSpecializationFocuses(database: Database, specializationId: string) {
const focuses = await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=?'
)).all(specializationId);
for (const focus of focuses) await refreshFocusReady(database, String(focus.id));
}
async function validateAttributeIds(database: Database, ids: string[]) {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return true;
const count = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${unique.map(() => '?').join(',')})`
)).get(...unique))?.count ?? 0);
return count === unique.length;
}
async function validateAbilityIds(database: Database, specializationId: string, ids: string[]) {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return true;
const count = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM specialization_abilities
WHERE active=1 AND specialization_id=? AND id IN (${unique.map(() => '?').join(',')})`
)).get(specializationId, ...unique))?.count ?? 0);
return count === unique.length;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const filter = ['active', 'ready', 'draft', 'archived'].includes(url.searchParams.get('filter') ?? '')
? String(url.searchParams.get('filter'))
: '';
const { search, pattern } = getRegistrySearch(url);
const conditions = [
...(filter === 'active' ? ['specializations.active=1']
: filter === 'ready' ? ['EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)']
: filter === 'draft' ? ['specializations.active=1 AND NOT EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)']
: filter === 'archived' ? ['specializations.active=0']
: []),
...(search ? ["specializations.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const pending = await getPendingEntities(user.id, [
{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization', fields: ['description'] },
{ module: 'specializations', action: 'saveFocus', kind: 'focus', fields: ['specializationId', 'description', 'primaryAttribute', 'displayOrder'] },
{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson'] },
{ module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] }
]);
const pendingSpecializations = pending.filter((item) => item.kind === 'specialization')
.filter((item) => !search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
const pendingAbilitySetRows = await (await database.prepare(`SELECT entity_id,payload_json,created_at
FROM data_change_proposals
WHERE module='specializations' AND action='saveFocusAbilities' AND status='pending' AND entity_id IS NOT NULL
ORDER BY created_at DESC`)).all();
const pendingAbilitySets = new Map<string, string[]>();
for (const row of pendingAbilitySetRows) {
const focusId = String(row.entity_id);
if (pendingAbilitySets.has(focusId)) continue;
const proposal = deserializeProposal(String(row.payload_json));
pendingAbilitySets.set(focusId, [1, 2, 3].map((slot) => String(proposal.get(`abilityId${slot}`) ?? '')));
}
const canonicalTotal = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM specializations ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, canonicalTotal + pendingSpecializations.length);
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, 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) : [],
(await database.prepare('SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_ability_effects ORDER BY ability_id,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
(await database.prepare('SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number')).all(),
(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 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(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT entity_type,entity_id,updated_at FROM game_data_icons
WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)).all(),
(await database.prepare(`SELECT
(SELECT COUNT(*) FROM specializations) AS total,
(SELECT COUNT(*) FROM specializations WHERE active=1) AS active,
(SELECT COUNT(*) FROM specializations WHERE active=0) AS archived,
(SELECT COUNT(*) FROM specializations specialization WHERE specialization.active=1
AND EXISTS (SELECT 1 FROM specialization_focuses focus
WHERE focus.specialization_id=specialization.id AND focus.builder_ready=1)) AS ready,
(SELECT COUNT(*) FROM specializations specialization WHERE specialization.active=1
AND NOT EXISTS (SELECT 1 FROM specialization_focuses focus
WHERE focus.specialization_id=specialization.id AND focus.builder_ready=1)) AS draft,
(SELECT COUNT(*) FROM specialization_focuses WHERE active=1) AS focuses,
(SELECT COUNT(*) FROM specialization_focuses WHERE builder_ready=1) AS ready_focuses,
(SELECT COUNT(*) FROM specialization_abilities WHERE active=1) AS abilities`)).get()
]);
const pendingFocuses = pending.filter((item) => item.kind === 'focus');
const pendingAbilities = pending.filter((item) => item.kind === 'ability');
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);
return icon ? gameDataIconUrl(type, id, icon.updated_at) : '';
};
const loadedSpecializations = [
...pendingOnPage.map((item) => ({ ...item, description: item.description, active: 1, pending: true, iconUrl: '' })),
...specializations.map((item) => ({ ...item, iconUrl: iconUrl('specialization', String(item.id)) }))
];
const loadedFocuses = [
...pendingFocuses.map((item) => ({
...item,
specialization_id: item.specializationId,
primary_attribute: item.primaryAttribute,
display_order: Number(item.displayOrder || 0),
builder_ready: 0,
active: 1,
pending: true
})),
...focuses
];
const loadedAbilities = [
...pendingAbilities.map((item) => ({
...item,
specialization_id: item.specializationId,
duration_seconds: item.durationSeconds || null,
cooldown_seconds: item.cooldownSeconds || null,
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
active: 1,
pending: true,
iconUrl: ''
})),
...abilities.map((ability) => ({
...ability,
effects: abilityEffects.filter((effect) => effect.ability_id === ability.id),
iconUrl: iconUrl('specialization_ability', String(ability.id))
}))
];
const loadedNodes = [
...pendingNodes.map((item) => ({
...item,
focus_id: item.focusId,
description_template: item.descriptionTemplate,
row_number: Number(item.rowNumber),
column_number: Number(item.columnNumber),
max_rank: Number(item.maxRank),
ranks: parseJsonArray<RankInput>(item.ranksJson),
active: 1,
pending: true,
iconUrl: ''
})),
...nodes.map((node) => ({
...node,
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)),
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)]))
})),
iconUrl: iconUrl('specialization_node', String(node.id))
}))
];
const availablePoints = Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19);
return {
specializations: loadedSpecializations,
focuses: loadedFocuses.map((focus) => {
const assigned = focusAbilities.filter((link) => link.focus_id === focus.id);
const pendingAbilityIds = pendingAbilitySets.get(String(focus.id));
const focusNodes = loadedNodes.filter((node) => node.focus_id === focus.id && Number(node.active) === 1);
const pointTotal = focusNodes.reduce((sum, node) => node.ranks.reduce((rankSum: number, rank: { rank: number }) => {
const profile = rankProfiles.find((item) => Number(item.rank) === Number(rank.rank));
return rankSum + Number(profile?.point_cost ?? rank.rank);
}, sum), 0);
return {
...focus,
abilityIds: pendingAbilityIds ?? [1, 2, 3].map((slot) => String(assigned.find((link) => Number(link.ability_slot) === slot)?.ability_id ?? '')),
abilitySetPending: Boolean(pendingAbilityIds),
pointTotal,
nodeCount: focusNodes.length,
maxRow: Math.max(0, ...focusNodes.map((node) => Number(node.row_number)))
};
}),
abilities: loadedAbilities,
nodes: loadedNodes,
resources,
rankProfiles,
progression: { ...progression, availablePoints },
attributes,
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingSpecializations.length,
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'node'].includes(item.kind)).length
},
pagination,
filter,
search
};
};
export const actions: Actions = {
saveProgression: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const xpPoints = Number(form.get('xpPoints'));
const cachePoints = Number(form.get('cachePoints'));
const rankProfiles = [1, 2, 3].map((rank) => ({
rank,
pointCost: Number(form.get(`rank${rank}PointCost`)),
creditCost: Number(form.get(`rank${rank}CreditCost`))
}));
if (![xpPoints, cachePoints].every((value) => Number.isInteger(value) && value >= 0) || xpPoints + cachePoints <= 0) {
return fail(400, { progressionError: 'Progression sources require non-negative whole-number point totals.' });
}
if (rankProfiles.some((profile) => !Number.isInteger(profile.pointCost) || profile.pointCost <= 0
|| !Number.isInteger(profile.creditCost) || profile.creditCost < 0)) {
return fail(400, { progressionError: 'Every rank requires positive whole-number point costs and non-negative Credit costs.' });
}
const database = await getDatabase();
const before = {
progression: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
ranks: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all()
};
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare(`UPDATE specialization_progression_config
SET xp_points=?,shd_cache_points=?,updated_at=? WHERE id='default'`)).run(xpPoints, cachePoints, now);
for (const profile of rankProfiles) {
await (await database.prepare(`UPDATE specialization_rank_cost_profiles
SET point_cost=?,credit_cost=?,updated_at=? WHERE rank=?`))
.run(profile.pointCost, profile.creditCost, now, profile.rank);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
const after = {
progression: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
ranks: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all()
};
const specializationIds = await (await database.prepare('SELECT id FROM specializations')).all();
for (const specialization of specializationIds) await refreshSpecializationFocuses(database, String(specialization.id));
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'specialization.progression_updated',
entityId: 'specialization-progression',
before,
after
});
return { progressionSaved: true, resultToken: randomUUID() };
},
saveSpecialization: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = cleanName(form.get('name'));
const description = cleanName(form.get('description'));
if (name.length < 2 || name.length > 80) return fail(400, { specializationError: 'Specialization names must be 2–80 characters.' });
if (description.length > 1200) return fail(400, { specializationError: 'Descriptions must be 1,200 characters or fewer.' });
const database = await getDatabase();
if (await (await database.prepare(
'SELECT id FROM specializations WHERE name=? COLLATE NOCASE AND id<>?'
)).get(name, id || 'new')) return fail(409, { specializationError: 'That specialization name already exists.' });
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
return fail(400, { specializationError: error instanceof GameDataIconValidationError ? error.message : 'The specialization icon could not be processed.' });
}
const specializationId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await specializationSnapshot(database, id) : null;
const now = new Date().toISOString();
if (id) {
await (await database.prepare(
'UPDATE specializations SET name=?,description=?,active=1,updated_at=? WHERE id=?'
)).run(name, description || null, now, id);
} else {
await (await database.prepare(
'INSERT INTO specializations(id,name,description,created_at,updated_at) VALUES(?,?,?,?,?)'
)).run(specializationId, name, description || null, now, now);
}
await applyIconMutation(database, 'specialization', specializationId, iconMutation);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization.updated' : 'specialization.created',
entityId: specializationId,
before,
after: await specializationSnapshot(database, specializationId)
});
return { specializationSaved: true, specializationMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveFocus: 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 description = cleanName(form.get('description'));
const primaryAttribute = String(form.get('primaryAttribute') ?? '');
const displayOrder = Number(form.get('displayOrder') ?? 0);
if (name.length < 2 || name.length > 80) return fail(400, { focusError: 'Focus names must be 2–80 characters.' });
if (description.length > 1200) return fail(400, { focusError: 'Descriptions must be 1,200 characters or fewer.' });
if (!PRIMARY_ATTRIBUTES.includes(primaryAttribute as (typeof PRIMARY_ATTRIBUTES)[number])) {
return fail(400, { focusError: 'Choose Firepower, Toughness, or Engineering as the primary attribute.' });
}
if (!Number.isInteger(displayOrder) || displayOrder < 0 || displayOrder > 999) {
return fail(400, { focusError: 'Display order must be a whole number between 0 and 999.' });
}
const database = await getDatabase();
if (id) {
const existing = await (await database.prepare(
'SELECT specialization_id FROM specialization_focuses WHERE id=?'
)).get(id);
if (!existing) return fail(404, { focusError: 'That focus no longer exists.' });
if (String(existing.specialization_id) !== specializationId) {
return fail(400, { focusError: 'A saved focus cannot be moved to another specialization.' });
}
}
if (!await (await database.prepare('SELECT id FROM specializations WHERE id=? AND active=1')).get(specializationId)) {
return fail(400, { focusError: 'Choose an active specialization.' });
}
if (await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?'
)).get(specializationId, name, id || 'new')) return fail(409, { focusError: 'That focus already exists on this specialization.' });
const focusId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await focusSnapshot(database, id) : null;
const now = new Date().toISOString();
if (id) {
await (await database.prepare(`UPDATE specialization_focuses
SET specialization_id=?,name=?,description=?,primary_attribute=?,display_order=?,active=1,updated_at=? WHERE id=?`))
.run(specializationId, name, description || null, primaryAttribute, displayOrder, now, id);
} else {
await (await database.prepare(`INSERT INTO specialization_focuses
(id,specialization_id,name,description,primary_attribute,display_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`))
.run(focusId, specializationId, name, description || null, primaryAttribute, displayOrder, now, now);
}
await refreshFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_focus.updated' : 'specialization_focus.created',
entityId: focusId,
before,
after: await focusSnapshot(database, focusId)
});
return { focusSaved: true, focusMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveAbility: 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 description = cleanName(form.get('description'));
const durationSeconds = optionalPositive(form.get('durationSeconds'));
const cooldownSeconds = optionalPositive(form.get('cooldownSeconds'));
const effects = parseJsonArray<AbilityEffectInput>(form.get('effectsJson')).map((effect) => ({
type: String(effect.type ?? (effect.statId ? 'Attribute' : 'Custom')),
statId: String(effect.statId ?? ''),
label: cleanName(effect.label),
value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value),
unit: cleanName(effect.unit)
}));
if (name.length < 2 || name.length > 100) return fail(400, { abilityError: 'Ability names must be 2–100 characters.' });
if (description.length < 3 || description.length > 2400) return fail(400, { abilityError: 'Ability descriptions must be 3–2,400 characters.' });
if (Number.isNaN(durationSeconds) || Number.isNaN(cooldownSeconds)) {
return fail(400, { abilityError: 'Duration and cooldown must be positive when supplied.' });
}
if (effects.length > 12 || effects.some((effect) => effect.label.length > 100
|| (effect.label.length > 0 && effect.label.length < 2)
|| !['Attribute', 'Custom'].includes(effect.type)
|| (effect.type === 'Attribute' && !effect.statId)
|| (effect.value !== null && !Number.isFinite(effect.value)) || !EFFECT_UNITS.has(effect.unit))) {
return fail(400, { abilityError: 'Ability effects require valid targets, optional numeric values, and labels no longer than 100 characters.' });
}
if (effects.some((effect) => effect.type === 'Custom' && effect.label.length < 2)) {
return fail(400, { abilityError: 'Custom effects require a concise label because they have no Attribute name to use.' });
}
const database = await getDatabase();
if (id) {
const existing = await (await database.prepare(
'SELECT specialization_id FROM specialization_abilities WHERE id=?'
)).get(id);
if (!existing) return fail(404, { abilityError: 'That class ability no longer exists.' });
if (String(existing.specialization_id) !== specializationId) {
return fail(400, { abilityError: 'A saved class ability cannot be moved to another specialization.' });
}
}
if (!await (await database.prepare('SELECT id FROM specializations WHERE id=? AND active=1')).get(specializationId)) {
return fail(400, { abilityError: 'Choose an active specialization.' });
}
if (!await validateAttributeIds(database, effects.map((effect) => effect.statId))) {
return fail(400, { abilityError: 'One or more selected Attributes are unavailable.' });
}
const unlabeledAttributeIds = [...new Set(effects
.filter((effect) => effect.type === 'Attribute' && !effect.label)
.map((effect) => effect.statId))];
const fallbackRows = unlabeledAttributeIds.length
? await (await database.prepare(
`SELECT id,name FROM stat_attributes WHERE id IN (${unlabeledAttributeIds.map(() => '?').join(',')})`
)).all(...unlabeledAttributeIds)
: [];
const fallbackLabels = new Map(fallbackRows.map((row) => [String(row.id), cleanName(row.name)]));
const resolvedEffects = effects.map((effect) => ({
...effect,
label: effect.label || fallbackLabels.get(effect.statId) || ''
}));
if (resolvedEffects.some((effect) => effect.label.length < 2)) {
return fail(400, { abilityError: 'Each effect needs either a label or an available Attribute target name.' });
}
if (await (await database.prepare(
'SELECT id FROM specialization_abilities WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?'
)).get(specializationId, name, id || 'new')) return fail(409, { abilityError: 'That ability already exists on this specialization.' });
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
return fail(400, { abilityError: error instanceof GameDataIconValidationError ? error.message : 'The ability icon could not be processed.' });
}
const abilityId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await abilitySnapshot(database, id) : null;
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id) {
await (await database.prepare(`UPDATE specialization_abilities
SET specialization_id=?,name=?,description=?,duration_seconds=?,cooldown_seconds=?,active=1,updated_at=? WHERE id=?`))
.run(specializationId, name, description, durationSeconds, cooldownSeconds, now, id);
} else {
await (await database.prepare(`INSERT INTO specialization_abilities
(id,specialization_id,name,description,duration_seconds,cooldown_seconds,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`))
.run(abilityId, specializationId, name, description, durationSeconds, cooldownSeconds, now, now);
}
await (await database.prepare('DELETE FROM specialization_ability_effects WHERE ability_id=?')).run(abilityId);
for (const [index, effect] of resolvedEffects.entries()) {
await (await database.prepare(`INSERT INTO specialization_ability_effects
(id,ability_id,effect_order,stat_attribute_id,label,value,unit) VALUES(?,?,?,?,?,?,?)`))
.run(randomUUID(), abilityId, index + 1, effect.statId || null, effect.label, effect.value, effect.unit || null);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation);
await refreshSpecializationFocuses(database, specializationId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_ability.updated' : 'specialization_ability.created',
entityId: abilityId,
before,
after: await abilitySnapshot(database, abilityId)
});
return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveFocusAbilities: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const focusId = String(form.get('id') ?? form.get('focusId') ?? '');
const abilityIds = [1, 2, 3].map((slot) => String(form.get(`abilityId${slot}`) ?? ''));
if (abilityIds.some((id) => !id) || new Set(abilityIds).size !== 3) {
return fail(400, { focusAbilitiesError: 'Assign three different class abilities.' });
}
const database = await getDatabase();
const focus = await (await database.prepare(
'SELECT specialization_id FROM specialization_focuses WHERE id=? AND active=1'
)).get(focusId);
if (!focus) return fail(404, { focusAbilitiesError: 'That active focus no longer exists.' });
if (!await validateAbilityIds(database, String(focus.specialization_id), abilityIds)) {
return fail(400, { focusAbilitiesError: 'Every assigned ability must be active and belong to the same specialization.' });
}
const before = await focusSnapshot(database, focusId);
await database.exec('BEGIN');
try {
await (await database.prepare('DELETE FROM specialization_focus_abilities WHERE focus_id=?')).run(focusId);
for (const [index, abilityId] of abilityIds.entries()) {
await (await database.prepare(
'INSERT INTO specialization_focus_abilities(focus_id,ability_slot,ability_id) VALUES(?,?,?)'
)).run(focusId, index + 1, abilityId);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
const readiness = await refreshFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'specialization_focus.abilities_updated',
entityId: focusId,
before,
after: { ...(await focusSnapshot(database, focusId)), readiness }
});
return { focusAbilitiesSaved: true, resultToken: randomUUID() };
},
saveNode: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const focusId = String(form.get('focusId') ?? '');
const name = cleanName(form.get('name'));
const descriptionTemplate = cleanName(form.get('descriptionTemplate'));
const rowNumber = Number(form.get('rowNumber'));
const columnNumber = Number(form.get('columnNumber'));
const maxRank = Number(form.get('maxRank'));
const ranks = parseJsonArray<RankInput>(form.get('ranksJson')).map((rank) => ({
rank: Number(rank.rank),
description: cleanName(rank.description),
effects: Array.isArray(rank.effects) ? rank.effects.map((effect) => ({
type: String(effect.type ?? 'Custom'),
statId: String(effect.statId ?? ''),
abilityId: String(effect.abilityId ?? ''),
label: cleanName(effect.label),
value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value),
unit: cleanName(effect.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.' });
if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) {
return fail(400, { nodeError: 'The shared node description must be 3–2,400 characters.' });
}
if (!Number.isInteger(rowNumber) || rowNumber < 1 || rowNumber > 20
|| !Number.isInteger(columnNumber) || columnNumber < 1 || columnNumber > 12) {
return fail(400, { nodeError: 'Tree placement requires a row from 1–20 and column from 1–12.' });
}
if (rowNumber === 1 && columnNumber <= 2) {
return fail(400, { nodeError: 'Top-row columns 1 and 2 are reserved for the free class abilities.' });
}
if (![1, 2, 3].includes(maxRank) || ranks.length !== maxRank
|| ranks.some((rank, index) => rank.rank !== index + 1 || rank.description.length < 3 || rank.description.length > 2400)) {
return fail(400, { nodeError: 'Configure one ordered description for every available node rank.' });
}
const allEffects = ranks.flatMap((rank) => rank.effects);
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)
|| (effect.type === 'Attribute' && !effect.statId)
|| (effect.type === 'Ability' && !effect.abilityId))) {
return fail(400, { nodeError: 'Every rank effect requires a valid type, target, label, and optional numeric value.' });
}
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.' });
}
if (maxRank >= 2) {
const costs = ranks[1].costs;
const invalidResource = Object.entries(costs).some(([resourceId, quantity]) =>
!Number.isInteger(quantity) || quantity < 0 || (!RANK_TWO_RESOURCES.has(resourceId) && quantity !== 0)
);
const total = Object.entries(costs).filter(([resourceId]) => RANK_TWO_RESOURCES.has(resourceId))
.reduce((sum, [, quantity]) => sum + quantity, 0);
if (invalidResource || total < 1) {
return fail(400, { nodeError: 'Rank 2 requires positive whole-number quantities of Alpha, Beta, and/or Gamma Components.' });
}
}
if (maxRank >= 3) {
const costs = ranks[2].costs;
if (Number(costs[OMEGA_RESOURCE] ?? 0) !== 1
|| Object.entries(costs).some(([resourceId, quantity]) =>
!Number.isInteger(quantity) || quantity < 0 || (resourceId !== OMEGA_RESOURCE && quantity !== 0)
)) {
return fail(400, { nodeError: 'Rank 3 requires exactly one Electronics Omega Component.' });
}
}
const database = await getDatabase();
if (id) {
const existing = await (await database.prepare(
'SELECT focus_id FROM specialization_tree_nodes WHERE id=?'
)).get(id);
if (!existing) return fail(404, { nodeError: 'That tree node no longer exists.' });
if (String(existing.focus_id) !== focusId) {
return fail(400, { nodeError: 'A saved tree node cannot be moved to another focus.' });
}
}
const focus = await (await database.prepare(
'SELECT specialization_id FROM specialization_focuses WHERE id=? AND active=1'
)).get(focusId);
if (!focus) return fail(404, { nodeError: 'That active focus no longer exists.' });
if (!await validateAttributeIds(database, allEffects.map((effect) => effect.statId))) {
return fail(400, { nodeError: 'One or more 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.' });
}
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.' });
if (await (await database.prepare(
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND row_number=? AND column_number=? AND id<>?'
)).get(focusId, rowNumber, columnNumber, id || 'new')) return fail(409, { nodeError: 'Another node already occupies that tree position.' });
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
return fail(400, { nodeError: error instanceof GameDataIconValidationError ? error.message : 'The node icon could not be processed.' });
}
const nodeId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await nodeSnapshot(database, id) : null;
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id) {
await (await database.prepare(`UPDATE specialization_tree_nodes
SET focus_id=?,name=?,description_template=?,row_number=?,column_number=?,max_rank=?,active=1,updated_at=? WHERE id=?`))
.run(focusId, name, descriptionTemplate, rowNumber, columnNumber, maxRank, now, id);
} else {
await (await database.prepare(`INSERT INTO specialization_tree_nodes
(id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`))
.run(nodeId, focusId, name, descriptionTemplate, rowNumber, columnNumber, maxRank, now, now);
}
await (await database.prepare('DELETE FROM specialization_tree_node_ranks WHERE node_id=?')).run(nodeId);
for (const rank of ranks) {
await (await database.prepare(
'INSERT INTO specialization_tree_node_ranks(node_id,rank,description) VALUES(?,?,?)'
)).run(nodeId, rank.rank, rank.description);
for (const [index, effect] of rank.effects.entries()) {
await (await database.prepare(`INSERT INTO specialization_tree_rank_effects
(id,node_id,rank,effect_order,effect_type,stat_attribute_id,ability_id,label,value,unit)
VALUES(?,?,?,?,?,?,?,?,?,?)`))
.run(randomUUID(), nodeId, rank.rank, index + 1, effect.type, effect.statId || null,
effect.abilityId || null, effect.label, effect.value, effect.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
(node_id,rank,resource_id,quantity) VALUES(?,?,?,?)`))
.run(nodeId, rank.rank, resourceId, quantity);
}
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation);
const readiness = await refreshFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_node.updated' : 'specialization_node.created',
entityId: nodeId,
before,
after: { ...(await nodeSnapshot(database, nodeId)), readiness }
});
return { nodeSaved: true, nodeMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archiveSpecialization: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const before = await specializationSnapshot(database, id);
await (await database.prepare('UPDATE specializations SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization.archived', entityId: id, before, after: await specializationSnapshot(database, id) });
return { specializationArchived: true, resultToken: randomUUID() };
},
unarchiveSpecialization: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const before = await specializationSnapshot(database, id);
await (await database.prepare('UPDATE specializations SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization.unarchived', entityId: id, before, after: await specializationSnapshot(database, id) });
return { specializationUnarchived: true, resultToken: randomUUID() };
},
archiveFocus: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const before = await focusSnapshot(database, id);
await (await database.prepare(
'UPDATE specialization_focuses SET active=0,builder_ready=0,updated_at=? WHERE id=?'
)).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.archived', entityId: id, before, after: await focusSnapshot(database, id) });
return { focusArchived: true, resultToken: randomUUID() };
},
unarchiveFocus: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const before = await focusSnapshot(database, id);
await (await database.prepare('UPDATE specialization_focuses SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = await refreshFocusReady(database, id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.unarchived', entityId: id, before, after: { ...(await focusSnapshot(database, id)), readiness } });
return { focusUnarchived: true, resultToken: randomUUID() };
},
archiveAbility: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id);
const before = await abilitySnapshot(database, id);
await (await database.prepare('UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.archived', entityId: id, before, after: await abilitySnapshot(database, id) });
return { abilityArchived: true, resultToken: randomUUID() };
},
unarchiveAbility: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id);
const before = await abilitySnapshot(database, id);
await (await database.prepare('UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.unarchived', entityId: id, before, after: await abilitySnapshot(database, id) });
return { abilityUnarchived: true, resultToken: randomUUID() };
},
archiveNode: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
return { nodeArchived: true, resultToken: randomUUID() };
},
unarchiveNode: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
return { nodeUnarchived: true, resultToken: randomUUID() };
}
};
@@ -0,0 +1,624 @@
<script lang="ts">
import { onMount } from 'svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
let { data, form } = $props();
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string };
type FocusEditor = {
id: string;
specializationId: string;
name: string;
description: string;
primaryAttribute: string;
displayOrder: number | string;
active: number;
};
type AbilityEditor = {
id: string;
specializationId: string;
name: string;
description: string;
durationSeconds: number | string;
cooldownSeconds: number | string;
effects: StructuredEffect[];
active: number;
iconUrl?: string;
};
type RankEditor = {
rank: number;
description: string;
effects: StructuredEffect[];
costs: Record<string, number | string>;
};
type NodeEditor = {
id: string;
focusId: string;
name: string;
descriptionTemplate: string;
rowNumber: number | string;
columnNumber: number | string;
maxRank: string;
ranks: RankEditor[];
active: number;
iconUrl?: string;
};
const primaryOptions = [
{ value: 'Firepower', label: 'FIREPOWER', icon: 'firepower' as const, iconOnly: true },
{ value: 'Toughness', label: 'TOUGHNESS', icon: 'toughness' as const, iconOnly: true },
{ value: 'Engineering', label: 'ENGINEERING', icon: 'engineering' as const, iconOnly: true }
];
const rankOptions = [1, 2, 3].map((rank) => ({ value: String(rank), label: `RANK ${rank}` }));
const blankEffect = (): StructuredEffect => ({ type: 'Custom', statId: '', abilityId: '', label: '', value: '', unit: '' });
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
const blankFocus = (specializationId = ''): FocusEditor => ({
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
});
const blankAbility = (specializationId = ''): AbilityEditor => ({
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', effects: [], active: 1, iconUrl: ''
});
const blankRank = (rank: number): RankEditor => ({
rank,
description: '',
effects: [],
costs: rank === 2
? { 'resource-electronics-alpha': 0, 'resource-electronics-beta': 0, 'resource-electronics-gamma': 0 }
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
});
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
id: '', focusId, name: '', descriptionTemplate: '', rowNumber,
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: ''
});
const normalizedEffect = (effect: any): StructuredEffect => ({
type: (effect.effect_type ?? (effect.stat_attribute_id ? 'Attribute' : 'Custom')) as StructuredEffect['type'],
statId: String(effect.stat_attribute_id ?? effect.statId ?? ''),
abilityId: String(effect.ability_id ?? effect.abilityId ?? ''),
label: String(effect.label ?? ''),
value: effect.value ?? '',
unit: String(effect.unit ?? '')
});
let specializationEditing = $state<SpecializationEditor | null>(null);
let focusEditing = $state<FocusEditor | null>(null);
let abilityEditing = $state<AbilityEditor | null>(null);
let nodeEditing = $state<NodeEditor | null>(null);
let progressionEditing = $state(false);
let configuringAbilities = $state(false);
let selectedSpecializationId = $state('');
let selectedFocusId = $state('');
let abilitySlots = $state<string[]>(['', '', '']);
let handledResultToken = $state('');
let iconResetToken = $state('');
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 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 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);
const selectedSpecialization = $derived(specializationById(selectedSpecializationId));
const selectedFocus = $derived(focusById(selectedFocusId));
const selectedFocusAbilities = $derived(selectedFocus ? abilityList(String(selectedFocus.specialization_id)) : []);
const selectedFocusNodes = $derived(selectedFocus ? activeNodesForFocus(String(selectedFocus.id)) : []);
const selectedMaxRow = $derived(Math.max(1, ...selectedFocusNodes.map((node: any) => Number(node.row_number))));
const availablePoints = $derived(Number(data.progression.availablePoints));
const rankProfile = (rank: number) => data.rankProfiles.find((profile: any) => Number(profile.rank) === rank);
const rankPointCost = (rank: number) => Number(rankProfile(rank)?.point_cost ?? rank);
const rankCreditCost = (rank: number) => Number(rankProfile(rank)?.credit_cost ?? 0);
const nodePointCost = (node: any) => Array.from({ length: Number(node.max_rank) }, (_, index) => rankPointCost(index + 1))
.reduce((sum, value) => sum + value, 0);
const focusTreePointTotal = (focusId: string) => activeNodesForFocus(focusId).reduce((sum: number, node: any) => sum + nodePointCost(node), 0);
const focusReadyCount = (specializationId: string) => focusList(specializationId).filter((focus: any) => Number(focus.builder_ready) === 1).length;
const gateForRow = (row: number) => row === 1 ? 0 : (
(selectedFocus?.abilityIds?.[0] ? 1 : 0)
+ (selectedFocus?.abilityIds?.[2] ? 1 : 0)
+ selectedFocusNodes.filter((node: any) => Number(node.row_number) < row)
.reduce((sum: number, node: any) => sum + Math.min(Number(node.max_rank), 2), 0)
);
const assignedAbility = (slot: number) => abilityById(String(selectedFocus?.abilityIds?.[slot - 1] ?? ''));
const qualityClass = (quality: string) => quality.toLocaleLowerCase().replaceAll('-', '').replaceAll(' ', '');
const selectSpecialization = (id: string) => {
selectedSpecializationId = id;
const focuses = focusList(id);
selectedFocusId = focuses.some((focus: any) => focus.id === selectedFocusId)
? selectedFocusId
: String(focuses[0]?.id ?? '');
configuringAbilities = false;
nodeEditing = null;
};
const selectFocus = (id: string) => {
selectedFocusId = id;
const focus = focusById(id);
abilitySlots = [...(focus?.abilityIds ?? ['', '', ''])];
configuringAbilities = false;
nodeEditing = null;
};
const editSpecialization = (item: any) => {
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '' };
focusEditing = abilityEditing = nodeEditing = null;
};
const editFocus = (item: any) => {
focusEditing = {
id: item.id,
specializationId: item.specialization_id,
name: item.name,
description: item.description ?? '',
primaryAttribute: item.primary_attribute,
displayOrder: item.display_order,
active: Number(item.active)
};
specializationEditing = abilityEditing = nodeEditing = null;
};
const editAbility = (item: any) => {
abilityEditing = {
id: item.id,
specializationId: item.specialization_id,
name: item.name,
description: item.description,
durationSeconds: item.duration_seconds ?? '',
cooldownSeconds: item.cooldown_seconds ?? '',
effects: (item.effects ?? []).map(normalizedEffect),
active: Number(item.active),
iconUrl: item.iconUrl ?? ''
};
specializationEditing = focusEditing = nodeEditing = null;
};
const editNode = (item: any) => {
nodeEditing = {
id: item.id,
focusId: item.focus_id,
name: item.name,
descriptionTemplate: item.description_template,
rowNumber: item.row_number,
columnNumber: item.column_number,
maxRank: String(item.max_rank),
ranks: (item.ranks ?? []).map((rank: any) => ({
rank: Number(rank.rank),
description: rank.description,
effects: (rank.effects ?? []).map(normalizedEffect),
costs: { ...(rank.costs ?? {}) }
})),
active: Number(item.active),
iconUrl: item.iconUrl ?? ''
};
specializationEditing = focusEditing = abilityEditing = null;
};
const changeMaxRank = (value: string) => {
if (!nodeEditing) return;
const count = Number(value);
nodeEditing.maxRank = value;
nodeEditing.ranks = Array.from({ length: count }, (_, index) =>
nodeEditing?.ranks[index] ?? blankRank(index + 1)
);
};
const openAbilityConfiguration = () => {
if (!selectedFocus) return;
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
configuringAbilities = true;
};
const addNodeToRow = (row: number) => {
if (!selectedFocus) return;
const occupied = new Set(selectedFocusNodes.filter((node: any) => Number(node.row_number) === row)
.map((node: any) => Number(node.column_number)));
const minimum = row === 1 ? 3 : 1;
let column = minimum;
while (occupied.has(column) && column < 12) column += 1;
nodeEditing = { ...blankNode(String(selectedFocus.id), row), columnNumber: column };
specializationEditing = focusEditing = abilityEditing = null;
};
onMount(() => {
const first = data.specializations.find((item: any) => !item.pending) ?? data.specializations[0];
if (first) selectSpecialization(String(first.id));
});
$effect(() => {
const token = String(form?.resultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
if (form?.progressionSaved) progressionEditing = false;
if (form?.specializationSaved) {
if (form.specializationMode === 'add') {
specializationEditing = blankSpecialization();
iconResetToken = token;
} else specializationEditing = null;
}
if (form?.focusSaved) focusEditing = form.focusMode === 'add' ? blankFocus(selectedSpecializationId) : null;
if (form?.abilitySaved) {
if (form.abilityMode === 'add') {
abilityEditing = blankAbility(selectedSpecializationId);
iconResetToken = token;
} else abilityEditing = null;
}
if (form?.focusAbilitiesSaved) configuringAbilities = false;
if (form?.nodeSaved) {
if (form.nodeMode === 'add') {
nodeEditing = blankNode(selectedFocusId, Number(nodeEditing?.rowNumber ?? 1));
iconResetToken = token;
} else nodeEditing = null;
}
if (form?.specializationArchived || form?.specializationUnarchived) specializationEditing = null;
if (form?.focusArchived || form?.focusUnarchived) focusEditing = null;
if (form?.abilityArchived || form?.abilityUnarchived) abilityEditing = null;
if (form?.nodeArchived || form?.nodeUnarchived) nodeEditing = null;
});
</script>
<svelte:head><title>Specializations — ISAC</title></svelte:head>
<div class="module-toolbar specialization-toolbar">
<div>
<p class="eyebrow">AGENT CLASS + FOCUS PROGRESSION</p>
<h2>SPECIALIZATIONS</h2>
<p>Define class abilities, focus identities, upgrade trees, rank effects, and material investment.</p>
</div>
<button class="primary" onclick={() => {
specializationEditing = blankSpecialization();
focusEditing = abilityEditing = nodeEditing = null;
}}>ADD SPECIALIZATION <span>+</span></button>
</div>
<details class="specialization-progression-console" bind:open={progressionEditing}>
<summary class="selection-head">
<div><span>SHARED SPECIALIZATION PROGRESSION</span><small>UNLOCKED ONCE PER SPECIALIZATION · AVAILABLE IN FULL TO EVERY FOCUS</small></div>
<div><b>{availablePoints} POINTS AVAILABLE PER FOCUS</b><i aria-hidden="true">+</i></div>
</summary>
<PendingForm action="?/saveProgression">
<input type="hidden" name="id" value="default"/>
<input type="hidden" name="name" value="Specialization progression"/>
<div class="specialization-progression-grid">
<section>
<header><span>POINT ACQUISITION</span><b>{Number(data.progression.xp_points)} + {Number(data.progression.shd_cache_points)} = {availablePoints}</b></header>
<div>
<label class="field">SPECIALIZATION XP<input name="xpPoints" type="number" min="0" step="1" value={data.progression.xp_points} required/></label>
<label class="field">SHD TECH CACHES<input name="cachePoints" type="number" min="0" step="1" value={data.progression.shd_cache_points} required/></label>
</div>
<p>Unlock progress is shared across the specialization. Spending remains independent inside every focus tree.</p>
</section>
<section class="specialization-rank-profile">
<header><span>RANK COST PROFILE</span><b>FIXED BY TARGET RANK</b></header>
<div>
{#each data.rankProfiles as profile}
<div>
<strong>RANK {profile.rank}</strong>
<label class="field">POINTS<input name={`rank${profile.rank}PointCost`} type="number" min="1" step="1" value={profile.point_cost} required/></label>
<label class="field">CREDITS<input name={`rank${profile.rank}CreditCost`} type="number" min="0" step="1" value={profile.credit_cost} required/></label>
</div>
{/each}
</div>
</section>
</div>
{#if form?.progressionError}<p class="error">{form.progressionError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => progressionEditing = false}>CANCEL</button><button class="primary">SAVE PROGRESSION →</button></div>
</PendingForm>
</details>
<KpiFilters active={data.filter} items={[
{ value: '', label: 'SPECIALIZATIONS', count: data.summary.total, description: 'Show every class definition' },
{ value: 'ready', label: 'BUILDER-READY', count: data.summary.ready, description: 'Contains at least one complete focus' },
{ value: 'draft', label: 'IN PROGRESS', count: data.summary.draft, description: 'No complete focus tree yet' },
{ value: 'archived', label: 'ARCHIVED', count: data.summary.archived, description: 'Retained outside active data' }
]}/>
{#if specializationEditing}
<div class="dashboard-editor specialization-identity-editor">
<div class="editor-title"><div><p class="eyebrow">{specializationEditing.id ? 'MODIFY SPECIALIZATION' : 'NEW SPECIALIZATION'}</p><h3>{specializationEditing.id ? specializationEditing.name : 'CLASS DEFINITION'}</h3></div><button aria-label="Close specialization editor" onclick={() => specializationEditing = null}>×</button></div>
<PendingForm action="?/saveSpecialization" enctype="multipart/form-data">
<input type="hidden" name="id" value={specializationEditing.id}/>
<div class="isac-record-identity specialization-record-identity">
<IconUploadField currentUrl={specializationEditing.iconUrl} label="SPECIALIZATION ICON" resetToken={iconResetToken}/>
<div>
<label class="field isac-record-name">NAME<input name="name" bind:value={specializationEditing.name} maxlength="80" required/></label>
<label class="field">DESCRIPTION<textarea name="description" bind:value={specializationEditing.description} maxlength="1200" placeholder="Describe the specialization's class identity and purpose."></textarea></label>
</div>
</div>
{#if form?.specializationError}<p class="error">{form.specializationError}</p>{/if}
<div class="form-actions split-actions">
<div>{#if specializationEditing.id}<button class={specializationEditing.active ? 'moderation-action danger' : 'moderation-action restore'} formaction={specializationEditing.active ? '?/archiveSpecialization' : '?/unarchiveSpecialization'} name="id" value={specializationEditing.id}>{specializationEditing.active ? 'ARCHIVE SPECIALIZATION' : 'UNARCHIVE SPECIALIZATION'}</button>{/if}</div>
<div><button type="button" class="secondary" onclick={() => specializationEditing = null}>CANCEL</button><button class="primary">SAVE SPECIALIZATION →</button></div>
</div>
</PendingForm>
</div>
{/if}
{#if focusEditing}
<div class="dashboard-editor specialization-focus-editor">
<div class="editor-title"><div><p class="eyebrow">{focusEditing.id ? 'MODIFY FOCUS' : 'NEW FOCUS'}</p><h3>{focusEditing.id ? focusEditing.name : 'FOCUS DEFINITION'}</h3></div><button aria-label="Close focus editor" onclick={() => focusEditing = null}>×</button></div>
<PendingForm action="?/saveFocus">
<input type="hidden" name="id" value={focusEditing.id}/>
<div class="specialization-focus-identity">
<label class="field isac-record-name">NAME<input name="name" bind:value={focusEditing.name} maxlength="80" required/></label>
{#if focusEditing.id}
<div class="field specialization-parent-lock"><span>SPECIALIZATION · FIXED RELATIONSHIP</span><strong>{specializationById(focusEditing.specializationId)?.name ?? focusEditing.specializationId}</strong><input type="hidden" name="specializationId" value={focusEditing.specializationId}/></div>
{:else}
<div class="field"><span>SPECIALIZATION</span><SearchableAssignment items={data.specializations} mode="single" compact fieldName="specializationId" subjectLabel="SPECIALIZATION" searchPlaceholder="Search specializations…" bind:value={focusEditing.specializationId}/></div>
{/if}
<label class="field focus-order">DISPLAY ORDER<input name="displayOrder" type="number" min="0" max="999" step="1" bind:value={focusEditing.displayOrder} required/></label>
<SegmentedChoice legend="PRIMARY ATTRIBUTE" name="primaryAttribute" options={primaryOptions} bind:value={focusEditing.primaryAttribute} required/>
<label class="field focus-description">DESCRIPTION<textarea name="description" bind:value={focusEditing.description} maxlength="1200" placeholder="Describe how this focus changes the specialization's playstyle."></textarea></label>
</div>
{#if form?.focusError}<p class="error">{form.focusError}</p>{/if}
<div class="form-actions split-actions">
<div>{#if focusEditing.id}<button class={focusEditing.active ? 'moderation-action danger' : 'moderation-action restore'} formaction={focusEditing.active ? '?/archiveFocus' : '?/unarchiveFocus'} name="id" value={focusEditing.id}>{focusEditing.active ? 'ARCHIVE FOCUS' : 'UNARCHIVE FOCUS'}</button>{/if}</div>
<div><button type="button" class="secondary" onclick={() => focusEditing = null}>CANCEL</button><button class="primary">SAVE FOCUS →</button></div>
</div>
</PendingForm>
</div>
{/if}
{#if abilityEditing}
<div class="dashboard-editor specialization-ability-editor">
<div class="editor-title"><div><p class="eyebrow">{abilityEditing.id ? 'MODIFY CLASS ABILITY' : 'NEW CLASS ABILITY'}</p><h3>{abilityEditing.id ? abilityEditing.name : 'ABILITY DEFINITION'}</h3></div><button aria-label="Close ability editor" onclick={() => abilityEditing = null}>×</button></div>
<PendingForm action="?/saveAbility" enctype="multipart/form-data">
<input type="hidden" name="id" value={abilityEditing.id}/>
<input type="hidden" name="effectsJson" value={JSON.stringify(abilityEditing.effects)}/>
<div class="isac-record-identity specialization-record-identity">
<IconUploadField currentUrl={abilityEditing.iconUrl} label="ABILITY ICON" resetToken={iconResetToken}/>
<div>
<label class="field isac-record-name">NAME<input name="name" bind:value={abilityEditing.name} maxlength="100" required/></label>
{#if abilityEditing.id}
<div class="field specialization-parent-lock"><span>SPECIALIZATION · FIXED RELATIONSHIP</span><strong>{specializationById(abilityEditing.specializationId)?.name ?? abilityEditing.specializationId}</strong><input type="hidden" name="specializationId" value={abilityEditing.specializationId}/></div>
{:else}
<div class="field"><span>SPECIALIZATION</span><SearchableAssignment items={data.specializations} mode="single" compact fieldName="specializationId" subjectLabel="SPECIALIZATION" searchPlaceholder="Search specializations…" bind:value={abilityEditing.specializationId}/></div>
{/if}
<label class="field ability-description">DESCRIPTION<textarea name="description" bind:value={abilityEditing.description} maxlength="2400" required placeholder="Describe the ability and its combat behavior."></textarea></label>
</div>
</div>
<div class="specialization-ability-mechanics">
<label class="field">DURATION · SECONDS<input name="durationSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.durationSeconds} placeholder="OPTIONAL"/></label>
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
</div>
<div class="selection-head"><div><span>STRUCTURED COMBAT FACTS</span><small>ATTRIBUTE REFERENCES OR CUSTOM DISPLAY VALUES</small></div><b>{abilityEditing.effects.length} EFFECTS</b></div>
<StructuredEffectEditor bind:effects={abilityEditing.effects} attributes={data.attributes} idPrefix="ability-effect" addLabel="ADD COMBAT FACT" allowTargetLabelFallback/>
{#if form?.abilityError}<p class="error">{form.abilityError}</p>{/if}
<div class="form-actions split-actions">
<div>{#if abilityEditing.id}<button class={abilityEditing.active ? 'moderation-action danger' : 'moderation-action restore'} formaction={abilityEditing.active ? '?/archiveAbility' : '?/unarchiveAbility'} name="id" value={abilityEditing.id}>{abilityEditing.active ? 'ARCHIVE ABILITY' : 'UNARCHIVE ABILITY'}</button>{/if}</div>
<div><button type="button" class="secondary" onclick={() => abilityEditing = null}>CANCEL</button><button class="primary">SAVE ABILITY →</button></div>
</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>
<PendingForm action="?/saveNode" enctype="multipart/form-data">
<input type="hidden" name="id" value={nodeEditing.id}/>
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
<div class="isac-record-identity specialization-record-identity node-record-identity">
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
<div>
<label class="field isac-record-name">NAME<input name="name" bind:value={nodeEditing.name} maxlength="100" required/></label>
{#if nodeEditing.id}
<div class="field specialization-parent-lock"><span>FOCUS · FIXED RELATIONSHIP</span><strong>{focusById(nodeEditing.focusId)?.name ?? nodeEditing.focusId}</strong><input type="hidden" name="focusId" value={nodeEditing.focusId}/></div>
{:else}
<div class="field"><span>FOCUS</span><SearchableAssignment items={data.focuses} mode="single" compact fieldName="focusId" subjectLabel="FOCUS" searchPlaceholder="Search focuses…" bind:value={nodeEditing.focusId}/></div>
{/if}
<div class="specialization-node-position">
<label class="field">ROW<input name="rowNumber" type="number" min="1" max="20" step="1" bind:value={nodeEditing.rowNumber} required/></label>
<label class="field">COLUMN<input name="columnNumber" type="number" min="1" max="12" step="1" bind:value={nodeEditing.columnNumber} required/></label>
<SegmentedChoice legend="MAXIMUM RANK" name="maxRank" options={rankOptions} bind:value={nodeEditing.maxRank} onchange={(values) => changeMaxRank(values[0])} showIndicator={false} required/>
</div>
</div>
</div>
<label class="field specialization-node-template">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={nodeEditing.descriptionTemplate} maxlength="2400" required placeholder="Use {1}, {2}, and so on for ordered effect values when useful."></textarea></label>
<div class="specialization-rank-editor">
{#each nodeEditing.ranks as rank}
<section>
<header>
<div><span>RANK {rank.rank}</span><small>{rankPointCost(rank.rank)} POINT{rankPointCost(rank.rank) === 1 ? '' : 'S'} · {rankCreditCost(rank.rank).toLocaleString()} CREDITS</small></div>
<b>{rank.rank === 1 ? 'CREDITS ONLY' : rank.rank === 2 ? 'SPECIALIZED COMPONENTS' : 'HIGH-END COMPONENT'}</b>
</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`}/>
{#if rank.rank === 2}
<fieldset class="specialization-component-costs specialized">
<legend>RANK 2 ELECTRONICS COMPONENTS · ONE OR MORE TYPES</legend>
{#each [
['resource-electronics-alpha','ALPHA'],
['resource-electronics-beta','BETA'],
['resource-electronics-gamma','GAMMA']
] as resource}
<label class="field"><span>{resource[1]} <small>SPECIALIZED</small></span><input type="number" min="0" step="1" bind:value={rank.costs[resource[0]]}/></label>
{/each}
</fieldset>
{:else if rank.rank === 3}
<div class="specialization-omega-cost">
<div class="quality-marker highend"><span>OMEGA COMPONENT</span><b>× 1</b></div>
<p>Rank 3 always requires one High-end Electronics Omega Component.</p>
</div>
{:else}
<div class="specialization-credit-only"><span>NO COMPONENT COST</span><b>{rankCreditCost(1).toLocaleString()} CREDITS</b></div>
{/if}
</section>
{/each}
</div>
{#if form?.nodeError}<p class="error">{form.nodeError}</p>{/if}
<div class="form-actions split-actions">
<div>{#if nodeEditing.id}<button class={nodeEditing.active ? 'moderation-action danger' : 'moderation-action restore'} formaction={nodeEditing.active ? '?/archiveNode' : '?/unarchiveNode'} name="id" value={nodeEditing.id}>{nodeEditing.active ? 'ARCHIVE TREE NODE' : 'UNARCHIVE TREE NODE'}</button>{/if}</div>
<div><button type="button" class="secondary" onclick={() => nodeEditing = null}>CANCEL</button><button class="primary">SAVE TREE NODE →</button></div>
</div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row specialization-row table-heading"><span>SPECIALIZATION</span><span>FOCUSES</span><span>ABILITIES</span><span>READINESS</span><span>STATE</span><span></span></div>
{#each data.specializations as item}
<div class="dashboard-row specialization-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:selected={selectedSpecializationId === item.id}>
<div class="specialization-row-identity"><ItemIcon src={item.iconUrl} alt={item.name}/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.description || String(item.id).slice(0,8).toUpperCase()}</small></div></div>
<span>{focusList(item.id).length}<small> FOCUS{focusList(item.id).length === 1 ? '' : 'ES'}</small></span>
<span>{abilityList(item.id).length}<small> CLASS ABILITIES</small></span>
<span>{focusReadyCount(item.id)} / {focusList(item.id).length}<small> BUILDER-READY</small></span>
<b class:pending={item.pending} class:configured={Number(item.active) === 1} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>
<button onclick={() => selectSpecialization(item.id)}>OPEN →</button>
</div>
{:else}
<div class="empty-dashboard"><strong>NO SPECIALIZATIONS REGISTERED</strong><span>Add a specialization, then define its focuses and three class abilities.</span></div>
{/each}
</div>
{#if selectedSpecialization}
<section class="specialization-workbench">
<header class="specialization-workbench-command">
<div class="specialization-composite-identity">
<ItemIcon src={selectedSpecialization.iconUrl} alt={selectedSpecialization.name}/>
<div><span>ACTIVE CLASS WORKBENCH</span><h3>{selectedSpecialization.name}</h3><p>{selectedSpecialization.description || 'No specialization description has been entered yet.'}</p></div>
</div>
<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="primary" onclick={() => { abilityEditing = blankAbility(selectedSpecialization.id); specializationEditing = focusEditing = nodeEditing = null; }}>ADD ABILITY +</button>
</div>
</header>
<div class="specialization-focus-tabs" role="tablist" aria-label={`${selectedSpecialization.name} focuses`}>
{#each focusList(selectedSpecialization.id) as focus}
<button type="button" role="tab" aria-selected={selectedFocusId === focus.id} class:active={selectedFocusId === focus.id} class:pending-reference={focus.pending} onclick={() => selectFocus(focus.id)}>
<span class="focus-composite-icon"><ItemIcon src={selectedSpecialization.iconUrl} alt=""/><GameIcon name={focus.primary_attribute.toLocaleLowerCase()} decorative/></span>
<span><strong>{focus.name}</strong><small>{focus.primary_attribute.toUpperCase()} · {focus.pending ? 'PENDING FOCUS' : focus.abilitySetPending ? 'ABILITY SET PENDING' : Number(focus.builder_ready) === 1 ? 'BUILDER-READY' : `${focus.pointTotal} / ${availablePoints} POINTS`}</small></span>
</button>
{:else}
<div class="specialization-focus-empty"><strong>NO FOCUSES DEFINED</strong><span>Add the first focus to begin its independent 49-point tree.</span></div>
{/each}
</div>
{#if selectedFocus}
<div class="specialization-focus-command">
<div>
<span>{selectedFocus.primary_attribute.toUpperCase()} FOCUS</span>
<h3>{selectedFocus.name}</h3>
<p>{selectedFocus.description || 'No focus description has been entered yet.'}</p>
</div>
<div class="specialization-focus-telemetry">
<span>POINTS <b class:complete={focusTreePointTotal(selectedFocus.id) === availablePoints}>{focusTreePointTotal(selectedFocus.id)} / {availablePoints}</b></span>
<span>NODES <b>{selectedFocusNodes.length}</b></span>
<span>ROWS <b>{selectedMaxRow}</b></span>
<span>STATE <b class:complete={Number(selectedFocus.builder_ready) === 1}>{selectedFocus.abilitySetPending ? 'PENDING' : Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'DRAFT'}</b></span>
</div>
<div class="specialization-mutation">
<button class="secondary" onclick={() => editFocus(selectedFocus)}>EDIT FOCUS</button>
<button class="primary" onclick={openAbilityConfiguration}>CONFIGURE ABILITIES →</button>
</div>
</div>
{#if configuringAbilities}
<div class="dashboard-editor specialization-ability-assignment">
<div class="editor-title"><div><p class="eyebrow">FOCUS CLASS ABILITIES</p><h3>{selectedFocus.name.toUpperCase()} ABILITY SET</h3></div><button aria-label="Close ability assignment" onclick={() => configuringAbilities = false}>×</button></div>
<PendingForm action="?/saveFocusAbilities">
<input type="hidden" name="id" value={selectedFocus.id}/>
<input type="hidden" name="name" value={`${selectedFocus.name} class abilities`}/>
<div class="specialization-ability-slots">
{#each [1,2,3] as slot}
<section>
<header><span>ABILITY SLOT {slot}</span><b>{slot === 2 ? 'COMBAT SKILLS ONLY' : 'FREE TREE NODE · 1 / 1'}</b></header>
<SearchableAssignment items={selectedFocusAbilities} mode="single" fieldName={`abilityId${slot}`} subjectLabel={`ABILITY SLOT ${slot}`} searchPlaceholder="Search class abilities…" bind:value={abilitySlots[slot - 1]}/>
<p>{slot === 2 ? 'Immediately available but not represented inside the focus upgrade tree.' : `Immediately available at no cost in top-row column ${slot === 1 ? 1 : 2}.`}</p>
</section>
{/each}
</div>
{#if form?.focusAbilitiesError}<p class="error">{form.focusAbilitiesError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => configuringAbilities = false}>CANCEL</button><button class="primary" disabled={abilitySlots.some((id) => !id) || new Set(abilitySlots).size !== 3}>SAVE ABILITY SET →</button></div>
</PendingForm>
</div>
{/if}
<div class="specialization-ability-summary">
{#each [1,2,3] as slot}
{@const ability = assignedAbility(slot)}
<article class:tree-linked={slot !== 2}>
<ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability slot ${slot}`}/>
<div><span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span><strong>{ability?.name ?? 'NOT ASSIGNED'}</strong><p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p></div>
{#if ability}<button class="specialization-mutation" onclick={() => editAbility(ability)}>EDIT</button>{/if}
</article>
{/each}
</div>
<details class="specialization-record-drawer">
<summary>
<span>CLASS ABILITY REGISTRY</span>
<b>{data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id).length} RECORDS</b>
</summary>
<div>
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
<article class:archived={Number(ability.active) === 0}>
<ItemIcon src={ability.iconUrl} alt={ability.name}/>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? 'ACTIVE CLASS ABILITY' : 'ARCHIVED CLASS ABILITY'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
</article>
{:else}
<p>No class abilities are registered for this specialization.</p>
{/each}
</div>
</details>
<div class="specialization-tree-console">
<header>
<div><span>FOCUS UPGRADE TREE</span><h3>{selectedFocus.name}</h3></div>
<div><b>{focusTreePointTotal(selectedFocus.id)} / {availablePoints} PAID POINTS</b><small>EVERY FOCUS RECEIVES ITS OWN FULL ALLOCATION</small></div>
</header>
<div class="specialization-tree-scroll">
<div class="specialization-tree">
{#each Array.from({ length: selectedMaxRow }, (_, index) => index + 1) as row}
<section class="specialization-tree-row">
<header><span>ROW {String(row).padStart(2,'0')}</span><b>{row === 1 ? 'AVAILABLE IMMEDIATELY' : `${gateForRow(row)} PRIOR RANK UNLOCKS REQUIRED`}</b></header>
<div class="specialization-tree-track">
{#if row === 1}
{#each [1,3] as abilitySlot, index}
{@const ability = assignedAbility(abilitySlot)}
<article class="specialization-tree-node free" style={`grid-column:${index + 1}`}>
<ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilitySlot}`}/>
<span>{ability?.name ?? `ABILITY ${abilitySlot} NOT ASSIGNED`}</span>
<b>1 / 1 · FREE</b>
</article>
{/each}
{/if}
{#each selectedFocusNodes.filter((node: any) => Number(node.row_number) === row) as node}
<article class="specialization-tree-node" class:pending-record={node.pending} style={`grid-column:${node.column_number}`}>
<ItemIcon src={node.iconUrl} alt={node.name}/>
<span>{node.name}</span>
<b>{node.pending ? 'PENDING' : `0 / ${node.max_rank} · ${nodePointCost(node)} PTS`}</b>
{#if !node.pending}<button class="specialization-mutation" aria-label={`Edit ${node.name}`} onclick={() => editNode(node)}>EDIT</button>{/if}
</article>
{/each}
</div>
<button class="secondary specialization-tree-add specialization-mutation" onclick={() => addNodeToRow(row)}>ADD NODE TO ROW {String(row).padStart(2,'0')} +</button>
</section>
{/each}
<button class="specialization-add-row specialization-mutation" onclick={() => addNodeToRow(selectedMaxRow + 1)}><span>+</span><b>ADD NEXT TREE ROW</b><small>GATE WILL BE DERIVED FROM ALL PRIOR RANK 1 + 2 UNLOCKS</small></button>
</div>
</div>
</div>
{#if archivedNodesForFocus(selectedFocus.id).length}
<details class="specialization-record-drawer archived-records">
<summary><span>ARCHIVED TREE NODES</span><b>{archivedNodesForFocus(selectedFocus.id).length} RETAINED</b></summary>
<div>
{#each archivedNodesForFocus(selectedFocus.id) as node}
<article class="archived">
<ItemIcon src={node.iconUrl} alt={node.name}/>
<div><strong>{node.name}</strong><small>ROW {node.row_number} · COLUMN {node.column_number} · {node.max_rank} RANK{Number(node.max_rank) === 1 ? '' : 'S'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editNode(node)}>REVIEW + RESTORE →</button>
</article>
{/each}
</div>
</details>
{/if}
{/if}
</section>
{/if}