feat(isac): add specialization data management
This commit is contained in:
@@ -36,3 +36,5 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
/_img
|
||||||
+146
-2
@@ -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 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 (
|
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
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,
|
action TEXT NOT NULL,
|
||||||
entity_id TEXT,
|
entity_id TEXT,
|
||||||
draft_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 (
|
CREATE TABLE IF NOT EXISTS stat_attributes (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')),
|
type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')),
|
||||||
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')),
|
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')),
|
||||||
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive', 'Multiplicative')),
|
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,
|
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)
|
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 (
|
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,
|
entity_id TEXT NOT NULL,
|
||||||
storage_key TEXT NOT NULL UNIQUE,
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
|
|||||||
@@ -447,6 +447,10 @@ squeezed beside long module headings on mobile.
|
|||||||
so changing one history page does not reset the others.
|
so changing one history page does not reset the others.
|
||||||
- Each module starts with `module-toolbar`, followed by `module-kpis`.
|
- Each module starts with `module-toolbar`, followed by `module-kpis`.
|
||||||
- Editors use `dashboard-editor` and appear before the affected table.
|
- 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`.
|
- Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`.
|
||||||
- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably.
|
- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably.
|
||||||
- User dossiers pair access controls and moderation in a two-column console,
|
- 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
|
contributed later and show the shared `/missing-item-icon.svg` artwork when no
|
||||||
upload exists. Registries, relationship rails, and builder selection controls
|
upload exists. Registries, relationship rails, and builder selection controls
|
||||||
use the same placeholder instead of initials, empty boxes, or ad hoc glyphs.
|
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
|
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
|
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
|
space narrows. Registry cards lead with complete category names. Counts and
|
||||||
availability remain quiet footer metadata, and internal abbreviations or
|
availability remain quiet footer metadata, and internal abbreviations or
|
||||||
workflow-centric states are not exposed as primary public labels.
|
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.
|
||||||
|
|||||||
@@ -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
|
created without an upload, allowing values and artwork to be contributed in
|
||||||
separate dependency-safe changes. Missing artwork is represented at render
|
separate dependency-safe changes. Missing artwork is represented at render
|
||||||
time by the static `/missing-item-icon.svg` asset through the shared
|
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
|
`game_data_icons`. A later edit or proposal can attach the real icon without
|
||||||
recreating or replacing the canonical item.
|
recreating or replacing the canonical item.
|
||||||
# Oversight notification dispatch
|
# Oversight notification dispatch
|
||||||
|
|||||||
+57
-1
@@ -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');
|
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(
|
const iconDefinition = await (await database.prepare(
|
||||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
|
||||||
)).get();
|
)).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');
|
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();
|
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
|
||||||
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
|
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
|
||||||
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
|
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();
|
const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all();
|
||||||
for (const [name, definition] of [
|
for (const [name, definition] of [
|
||||||
|
['description', 'TEXT'],
|
||||||
['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'],
|
['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)']
|
['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)']
|
||||||
]) {
|
]) {
|
||||||
if (!attributeColumns.some((column) => column.name === name)) {
|
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}`);
|
await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'gear_mod_quality_values',
|
'gear_mod_quality_values',
|
||||||
'gear_mod_secondary_options',
|
'gear_mod_secondary_options',
|
||||||
'gear_mod_secondary_quality_values',
|
'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',
|
'game_data_icons',
|
||||||
'managed_images'
|
'managed_images'
|
||||||
];
|
];
|
||||||
|
|||||||
+1215
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||||
|
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
currentUrl = '',
|
currentUrl = '',
|
||||||
label = 'ICON',
|
label = 'ICON',
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
|
|
||||||
<fieldset class="icon-upload-field">
|
<fieldset class="icon-upload-field">
|
||||||
<legend>{label}</legend>
|
<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}
|
{#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>
|
</fieldset>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
src = '',
|
src = '',
|
||||||
alt = '',
|
alt = '',
|
||||||
@@ -8,6 +10,8 @@
|
|||||||
alt?: string;
|
alt?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
||||||
</script>
|
</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];
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="proposal-change-preview" aria-label="Proposed data comparison">
|
<section class="proposal-change-preview" aria-label="Proposed data comparison">
|
||||||
@@ -50,6 +114,7 @@
|
|||||||
{#if currentSnapshot}
|
{#if currentSnapshot}
|
||||||
<div class="proposal-attribute-grid">
|
<div class="proposal-attribute-grid">
|
||||||
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
|
<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}
|
{#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>
|
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -66,6 +131,7 @@
|
|||||||
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
|
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
|
||||||
<div class="proposal-attribute-grid">
|
<div class="proposal-attribute-grid">
|
||||||
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
|
<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}
|
{#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>
|
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -76,6 +142,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<div class="proposal-editor-preview generic after">
|
<div class="proposal-editor-preview generic after">
|
||||||
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
|
<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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const MISSING_ITEM_ICON_URL = '/missing-item-icon.svg';
|
||||||
+17
-1
@@ -27,7 +27,23 @@ export async function recordIsacAudit(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
|
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');
|
throw new Error('Unsupported audit table');
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { recordIsacAudit } from '$lib/server/audit';
|
|||||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
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];
|
export type DataModule = typeof DATA_MODULES[number];
|
||||||
|
|
||||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
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' },
|
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||||
saveProfile: { saved: 'profileSaved' }
|
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) {
|
export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) {
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import path from 'node:path';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
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 type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||||
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
||||||
export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
||||||
export const GAME_ICON_SIZE = 256;
|
export const GAME_ICON_SIZE = 256;
|
||||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
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 {}
|
export class GameDataIconValidationError extends Error {}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { BackgroundRemovalError, removeBackgroundPreview } from './remove-backgr
|
|||||||
|
|
||||||
export const MANAGED_IMAGE_CATEGORIES = [
|
export const MANAGED_IMAGE_CATEGORIES = [
|
||||||
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
|
'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;
|
] as const;
|
||||||
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
|
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
const MAX_PIXELS = 24_000_000;
|
const MAX_PIXELS = 24_000_000;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
let readOnlyBlocked = $state(false);
|
let readOnlyBlocked = $state(false);
|
||||||
const dataSections = [
|
const dataSections = [
|
||||||
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
||||||
|
{ href: '/isac/specializations', label: 'Specializations', module: 'specializations' },
|
||||||
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
|
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
|
||||||
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
|
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
|
||||||
{ href: '/isac/brands', label: 'Brand sets', module: 'brands' },
|
{ href: '/isac/brands', label: 'Brand sets', module: 'brands' },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
module: 'attributes',
|
module: 'attributes',
|
||||||
action: 'save',
|
action: 'save',
|
||||||
kind: 'attribute',
|
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) => ({
|
}])).map((stat) => ({
|
||||||
...stat,
|
...stat,
|
||||||
calculation_type: stat.calculationType,
|
calculation_type: stat.calculationType,
|
||||||
@@ -64,8 +64,10 @@ export const actions: Actions = {
|
|||||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
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') ?? '');
|
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 (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 (!['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.' });
|
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; };
|
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.' });
|
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 now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
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);
|
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,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);
|
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) });
|
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);
|
const savedItem = await auditRecord('stat_attributes', statId);
|
||||||
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
|
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
|
||||||
|
|||||||
@@ -12,8 +12,11 @@
|
|||||||
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
||||||
const calculationOptions = ['Additive', 'Multiplicative'].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 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 value = (input: unknown) => input ?? '';
|
||||||
|
const attributeMeta = (stat: any) => stat.pending
|
||||||
|
? `PROPOSED BY ${stat.proposerName}`
|
||||||
|
: [stat.description, stat.unit || 'NO UNIT'].filter(Boolean).join(' · ');
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
|
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
@@ -28,6 +31,6 @@
|
|||||||
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
<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>
|
<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'}]}/>
|
<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}/>
|
<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>
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ export const actions: Actions = {
|
|||||||
if (action.startsWith('attribute.')) {
|
if (action.startsWith('attribute.')) {
|
||||||
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
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.' });
|
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=?`))
|
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;
|
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||||
} else if (action.endsWith('.archived')) {
|
} else if (action.endsWith('.archived')) {
|
||||||
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
|
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';
|
import { createNotification } from '$lib/server/notifications';
|
||||||
|
|
||||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
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 =
|
type SerializedEntry =
|
||||||
| { name: string; kind: 'text'; value: string }
|
| { name: string; kind: 'text'; value: string }
|
||||||
| { name: string; kind: 'file'; value: string; fileName: string; type: 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 }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
@@ -40,7 +88,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
...proposal,
|
...proposal,
|
||||||
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
|
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
|
||||||
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
|
? 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();
|
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 };
|
return { proposals: proposalsWithSnapshots, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
|
||||||
|
|||||||
@@ -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>
|
<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}
|
{#each data.proposals as proposal}
|
||||||
<div class="dashboard-row moderation-row mobile-detail-row">
|
<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>
|
<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>{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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
<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.">
|
<PendingForm action="?/upload" enctype="multipart/form-data" successMessage="Image added to the library.">
|
||||||
<div class="image-library-form">
|
<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">
|
<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">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>
|
<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}
|
||||||
Reference in New Issue
Block a user