diff --git a/.gitignore b/.gitignore index 477e971..3dce7dc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +/_img \ No newline at end of file diff --git a/database/schema.sql b/database/schema.sql index fe423b6..07461b3 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -277,7 +277,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS data_change_proposals ( id TEXT PRIMARY KEY, - module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')), + module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')), action TEXT NOT NULL, entity_id TEXT, draft_entity_id TEXT, @@ -322,6 +322,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_login_username_nocase_idx ON users(login CREATE TABLE IF NOT EXISTS stat_attributes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + description TEXT, type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')), category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')), calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive', 'Multiplicative')), @@ -776,8 +777,151 @@ CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values ( FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE, CHECK(max_value>=min_value) ); +CREATE TABLE IF NOT EXISTS specialization_progression_config ( + id TEXT PRIMARY KEY CHECK(id='default'), + xp_points INTEGER NOT NULL CHECK(xp_points>=0), + shd_cache_points INTEGER NOT NULL CHECK(shd_cache_points>=0), + updated_at TEXT NOT NULL +); +INSERT OR IGNORE INTO specialization_progression_config(id,xp_points,shd_cache_points,updated_at) + VALUES('default',30,19,'2026-07-26T00:00:00.000Z'); +CREATE TABLE IF NOT EXISTS specialization_rank_cost_profiles ( + rank INTEGER PRIMARY KEY CHECK(rank BETWEEN 1 AND 3), + point_cost INTEGER NOT NULL CHECK(point_cost>0), + credit_cost INTEGER NOT NULL CHECK(credit_cost>=0), + updated_at TEXT NOT NULL +); +INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at) + VALUES(1,1,2333,'2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at) + VALUES(2,2,10000,'2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at) + VALUES(3,3,90000,'2026-07-26T00:00:00.000Z'); +CREATE TABLE IF NOT EXISTS upgrade_resources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK(resource_type IN ('Currency','Component','Progression')), + quality TEXT CHECK(quality IS NULL OR quality IN ('Standard','Specialized','Superior','High-end')), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS upgrade_resources_name_nocase_idx ON upgrade_resources(name COLLATE NOCASE); +INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at) + VALUES('resource-credits','Credits','Currency',NULL,'2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at) + VALUES('resource-electronics-alpha','Electronics Alpha Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at) + VALUES('resource-electronics-beta','Electronics Beta Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at) + VALUES('resource-electronics-gamma','Electronics Gamma Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at) + VALUES('resource-electronics-omega','Electronics Omega Component','Component','High-end','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z'); +INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at) + VALUES('resource-shd-tech-cache','SHD Tech Cache','Progression','Superior','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z'); +CREATE TABLE IF NOT EXISTS specializations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS specializations_name_nocase_idx ON specializations(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS specialization_focuses ( + id TEXT PRIMARY KEY, + specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + primary_attribute TEXT NOT NULL CHECK(primary_attribute IN ('Firepower','Toughness','Engineering')), + display_order INTEGER NOT NULL DEFAULT 0 CHECK(display_order>=0), + builder_ready INTEGER NOT NULL DEFAULT 0 CHECK(builder_ready IN (0,1)), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(specialization_id,name) +); +CREATE INDEX IF NOT EXISTS specialization_focuses_specialization_idx ON specialization_focuses(specialization_id,display_order,name); +CREATE TABLE IF NOT EXISTS specialization_abilities ( + id TEXT PRIMARY KEY, + specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL, + duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0), + cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(specialization_id,name) +); +CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name); +CREATE TABLE IF NOT EXISTS specialization_ability_effects ( + id TEXT PRIMARY KEY, + ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE, + effect_order INTEGER NOT NULL CHECK(effect_order>=1), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + label TEXT NOT NULL, + value REAL, + unit TEXT, + UNIQUE(ability_id,effect_order) +); +CREATE TABLE IF NOT EXISTS specialization_focus_abilities ( + focus_id TEXT NOT NULL REFERENCES specialization_focuses(id) ON DELETE CASCADE, + ability_slot INTEGER NOT NULL CHECK(ability_slot BETWEEN 1 AND 3), + ability_id TEXT NOT NULL REFERENCES specialization_abilities(id), + PRIMARY KEY(focus_id,ability_slot), + UNIQUE(focus_id,ability_id) +); +CREATE TABLE IF NOT EXISTS specialization_tree_nodes ( + id TEXT PRIMARY KEY, + focus_id TEXT NOT NULL REFERENCES specialization_focuses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description_template TEXT NOT NULL, + row_number INTEGER NOT NULL CHECK(row_number>=1), + column_number INTEGER NOT NULL CHECK(column_number>=1), + max_rank INTEGER NOT NULL CHECK(max_rank BETWEEN 1 AND 3), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(focus_id,row_number,column_number), + UNIQUE(focus_id,name) +); +CREATE INDEX IF NOT EXISTS specialization_tree_nodes_focus_idx ON specialization_tree_nodes(focus_id,row_number,column_number); +CREATE TABLE IF NOT EXISTS specialization_tree_node_ranks ( + node_id TEXT NOT NULL REFERENCES specialization_tree_nodes(id) ON DELETE CASCADE, + rank INTEGER NOT NULL CHECK(rank BETWEEN 1 AND 3), + description TEXT NOT NULL, + PRIMARY KEY(node_id,rank) +); +CREATE TABLE IF NOT EXISTS specialization_tree_rank_effects ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + rank INTEGER NOT NULL, + effect_order INTEGER NOT NULL CHECK(effect_order>=1), + effect_type TEXT NOT NULL CHECK(effect_type IN ('Attribute','Ability','Custom')), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + ability_id TEXT REFERENCES specialization_abilities(id), + label TEXT NOT NULL, + value REAL, + unit TEXT, + FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE, + UNIQUE(node_id,rank,effect_order), + CHECK( + (effect_type='Attribute' AND stat_attribute_id IS NOT NULL) + OR (effect_type='Ability' AND ability_id IS NOT NULL) + OR effect_type='Custom' + ) +); +CREATE TABLE IF NOT EXISTS specialization_tree_rank_costs ( + node_id TEXT NOT NULL, + rank INTEGER NOT NULL, + resource_id TEXT NOT NULL REFERENCES upgrade_resources(id), + quantity INTEGER NOT NULL CHECK(quantity>0), + PRIMARY KEY(node_id,rank,resource_id), + FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE +); CREATE TABLE IF NOT EXISTS game_data_icons ( - entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')), + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')), entity_id TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL CHECK(byte_size > 0), diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 3dca3f2..89538c7 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -447,6 +447,10 @@ squeezed beside long module headings on mobile. so changing one history page does not reset the others. - Each module starts with `module-toolbar`, followed by `module-kpis`. - Editors use `dashboard-editor` and appear before the affected table. +- Optional record descriptions use a full-width narrative field beneath the + identity controls. Preserve the same full-width placement in Oversight + before/after previews so reviewers can compare meaning without reading raw + proposal data. - Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`. - Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably. - User dossiers pair access controls and moderation in a two-column console, @@ -1460,6 +1464,9 @@ proposing a canonical item. Upload fields must state that an icon may be contributed later and show the shared `/missing-item-icon.svg` artwork when no upload exists. Registries, relationship rails, and builder selection controls use the same placeholder instead of initials, empty boxes, or ad hoc glyphs. +Application code references that asset through the shared +`MISSING_ITEM_ICON_URL` constant and renders it through `ItemIcon`; do not +duplicate its path or introduce module-specific missing-icon artwork. Treat the placeholder as an explicit incomplete-data state: keep its subdued SHD styling and useful alternative text where the surrounding item name is not @@ -1630,3 +1637,58 @@ four-by-two matrix; the grid reduces to two columns and then one as available space narrows. Registry cards lead with complete category names. Counts and availability remain quiet footer metadata, and internal abbreviations or workflow-centric states are not exposed as primary public labels. + +### ISAC progression-tree composer + +Use the Specialization workbench pattern for ordered game systems whose +meaning depends on both hierarchy and spatial placement. Keep the registry, +selected parent identity, child navigation, readiness telemetry, and visual +tree in one operational surface. Editors still open above the registry and +follow the standard ISAC lifecycle; the workbench is navigation and context, +not a replacement modal. + +The tree uses a stable 12-column track per row. Row and column are explicit +numeric fields so placement is keyboard operable, auditable, and portable; +never make drag-and-drop the only authoring method. On narrow screens, retain +the geometry in a horizontally scrollable canvas instead of squeezing nodes +until their names and state are unreadable. Controls outside the canvas stack +and maintain at least a 44-pixel touch target. + +Each row header states its derived gate in plain language. Specialization gates +count all available Rank 1 and Rank 2 unlocks in preceding rows, including the +two immediately available ability nodes displayed in the first row. Do not +store or manually edit a second gate value that could diverge. Free nodes use +the orange identity rail plus the explicit `1 / 1 · FREE` state; pending, +archived, incomplete, and builder-ready states must remain textual as well as +colored. + +Treat allocation scope as first-class telemetry. Specialization progression is +unlocked once for a specialization, but every focus receives the full point +allocation independently. Show both the point total and that scope near the +tree; do not imply that focuses draw from one shared spend pool. A focus is +builder-ready only after it has three distinct active abilities, contiguous +tree rows, at least one paid node, and exactly the configured available point +total. + +Compose focus identity from the uploaded specialization artwork and the +canonical Firepower, Toughness, or Engineering icon. Keep the specialization +art primary and the attribute mark as a smaller corner badge. Ability slots 1 +and 3 are mirrored as free tree nodes; slot 2 is labeled Combat Skills-only and +must not appear in the upgrade tree. Universal grenade and medical-kit actions +are not specialization abilities. + +Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of +calculation-ready effects. It provides Attribute, Ability when applicable, and +Custom targets; a numeric value; and the shared bounded unit vocabulary. Keep +the target searchable, show all bounded type and unit choices with segmented +controls, and preserve explicit custom labels for mechanics that calculations +do not yet support. Oversight previews must render effect and rank summaries as +structured cards. Serialized JSON belongs only in the expandable raw-evidence +panel. + +Rank cards present the complete rank description, structured effects, point +cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma +components use the shared blue quality treatment and may be combined in any +positive whole-number quantities for Rank 2. Rank 3 uses the yellow High-end +Omega treatment and exactly one component. Color is supporting hierarchy; +quality names and quantities always remain visible. diff --git a/docs/architecture.md b/docs/architecture.md index 035e5d2..a173f24 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -302,7 +302,7 @@ Icon metadata is optional. Canonical records and contributor proposals may be created without an upload, allowing values and artwork to be contributed in separate dependency-safe changes. Missing artwork is represented at render time by the static `/missing-item-icon.svg` asset through the shared -`ItemIcon` component; the placeholder is never inserted into +`MISSING_ITEM_ICON_URL` constant and `ItemIcon` component; the placeholder is never inserted into `game_data_icons`. A later edit or proposal can attach the real icon without recreating or replacing the canonical item. # Oversight notification dispatch diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 0c99e1c..0ae9fd2 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -183,6 +183,40 @@ if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDe await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); } +const specializationProposalDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" +)).get(); +if (typeof specializationProposalDefinition?.sql === 'string' && !specializationProposalDefinition.sql.includes("'specializations'")) { + console.log('Adding Specializations to moderated data modules.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_specializations ( + id TEXT PRIMARY KEY, + module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')), + action TEXT NOT NULL, + entity_id TEXT, + draft_entity_id TEXT, + applied_entity_id TEXT, + summary TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), + proposed_by TEXT NOT NULL REFERENCES users(id), + reviewed_by TEXT REFERENCES users(id), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT, + applied_at TEXT + )`); + await database.exec(`INSERT INTO data_change_proposals_specializations + SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + FROM data_change_proposals`); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec('ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals'); + await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); + await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); + await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +} + const iconDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" )).get(); @@ -201,6 +235,25 @@ if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'sk await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons'); } +const specializationIconDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" +)).get(); +if (typeof specializationIconDefinition?.sql === 'string' && !specializationIconDefinition.sql.includes("'specialization_node'")) { + console.log('Adding Specialization icon storage support.'); + await database.exec(`CREATE TABLE game_data_icons_specializations ( + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')), + entity_id TEXT NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + byte_size INTEGER NOT NULL CHECK(byte_size > 0), + updated_at TEXT NOT NULL, + PRIMARY KEY(entity_type, entity_id) + )`); + await database.exec(`INSERT INTO game_data_icons_specializations + SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`); + await database.exec('DROP TABLE game_data_icons'); + await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons'); +} + const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) { console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.'); @@ -290,11 +343,14 @@ if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) { const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all(); for (const [name, definition] of [ + ['description', 'TEXT'], ['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'], ['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)'] ]) { if (!attributeColumns.some((column) => column.name === name)) { - console.log(`Recording the reference level for Attribute source ranges: ${name}.`); + console.log(name === 'description' + ? 'Adding optional Attribute descriptions.' + : `Recording the reference level for Attribute source ranges: ${name}.`); await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`); } } diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index 6935b03..6064e07 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -48,6 +48,18 @@ export const ISAC_DATA_TABLES = [ 'gear_mod_quality_values', 'gear_mod_secondary_options', 'gear_mod_secondary_quality_values', + 'specialization_progression_config', + 'specialization_rank_cost_profiles', + 'upgrade_resources', + 'specializations', + 'specialization_focuses', + 'specialization_abilities', + 'specialization_ability_effects', + 'specialization_focus_abilities', + 'specialization_tree_nodes', + 'specialization_tree_node_ranks', + 'specialization_tree_rank_effects', + 'specialization_tree_rank_costs', 'game_data_icons', 'managed_images' ]; diff --git a/src/app.css b/src/app.css index d6e7de6..fc88a78 100644 --- a/src/app.css +++ b/src/app.css @@ -793,3 +793,1218 @@ } .image-library-editor{margin:10px 0}.image-library-form{display:grid;grid-template-columns:minmax(330px,.8fr) minmax(440px,1.2fr);gap:12px;margin-top:12px}.image-library-upload .image-drop-input{height:100%;min-height:168px}.image-library-upload .image-upload-preview{width:112px;height:112px}.image-library-metadata{display:grid;grid-template-columns:1fr 1fr;align-content:start;gap:8px}.image-library-metadata .field{margin:0}.image-library-metadata .full,.image-processing-options{grid-column:1/-1}.image-processing-options{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:#0d1011}.image-processing-options label{min-height:58px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;padding:9px;cursor:pointer}.image-processing-options label+label{border-left:1px solid var(--shd-border)}.image-processing-options input{width:auto;margin:0;accent-color:var(--shd-orange)}.image-processing-options span{display:grid;gap:3px}.image-processing-options b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-processing-options small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.image-processing-options label:has(input:checked){background:#1b170e;box-shadow:inset 0 -2px var(--shd-orange)}.image-processing-options label.disabled{opacity:.42;cursor:not-allowed}.image-metadata-edit{display:grid;grid-template-columns:72px 1fr 1fr 1.4fr;align-items:end;gap:9px;margin-top:10px}.image-metadata-edit>img{width:72px;height:72px;object-fit:contain;padding:5px;border:1px solid var(--shd-border);background:#090b0c}.image-metadata-edit .field{margin:0}.image-library-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:8px;margin-top:8px}.image-library-grid article{min-width:0;border:1px solid var(--shd-border);border-top:2px solid #4a5152;background:var(--shd-surface);transition:var(--shd-transition)}.image-library-grid article.icon{border-top-color:var(--shd-orange)}.image-library-grid article:hover{border-color:#596061;background:#15191a}.image-library-preview{height:150px;display:grid;place-items:center;padding:14px;border-bottom:1px solid var(--shd-border);background:linear-gradient(135deg,#0a0c0d 25%,#101314 25%,#101314 50%,#0a0c0d 50%,#0a0c0d 75%,#101314 75%);background-size:16px 16px}.image-library-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain}.image-library-grid header{position:static;height:auto;min-height:54px;display:flex;align-items:center;justify-content:space-between;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:transparent}.image-library-grid header>div{min-width:0;display:grid;gap:3px}.image-library-grid header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.image-library-grid header span,.image-library-grid header b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-library-grid header span{color:var(--shd-text-muted)}.image-library-grid header b{color:var(--shd-orange)}.image-library-tags{min-height:42px;display:flex;align-content:flex-start;flex-wrap:wrap;gap:4px;padding:8px 10px}.image-library-tags span{width:max-content;padding:3px 5px;border:1px solid #3c4243;color:#92999b;font-size:var(--shd-type-telemetry)}.image-library-tags small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}.image-library-grid footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-top:1px solid var(--shd-border)}.image-library-grid footer span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.image-library-grid footer button{border:0;background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.image-library-empty{grid-column:1/-1;min-height:180px;display:grid;place-content:center;gap:6px;border:1px dashed var(--shd-border);color:var(--shd-text-muted);text-align:center}.image-library-empty strong{color:var(--shd-text)}@media(max-width:1100px){.image-library-grid{grid-template-columns:repeat(3,minmax(180px,1fr))}}@media(max-width:800px){.image-library-form{grid-template-columns:1fr}.image-library-grid{grid-template-columns:repeat(2,minmax(150px,1fr))}.image-metadata-edit{grid-template-columns:72px 1fr}.image-metadata-edit .field:last-child{grid-column:1/-1}}@media(max-width:560px){.image-library-metadata,.image-processing-options,.image-library-grid{grid-template-columns:1fr}.image-library-metadata .full,.image-processing-options{grid-column:auto}.image-processing-options label+label{border-left:0;border-top:1px solid var(--shd-border)}.image-library-preview{height:132px}.image-metadata-edit{grid-template-columns:1fr}.image-metadata-edit>img{width:100%;height:96px}} + +/* Managed image library: keep previews contained and reserve clear drop-zone lanes. */ +.image-library-upload .image-drop-input{ + grid-template-columns:112px minmax(0,1fr); + grid-template-rows:auto auto; + align-content:center; + column-gap:14px; + padding:14px +} +.image-library-upload .image-upload-preview{ + grid-column:1; + grid-row:1/3 +} +.image-library-upload .image-drop-copy{ + grid-column:2; + grid-row:1; + align-self:end +} +.image-library-upload .image-upload-actions{ + grid-column:2; + grid-row:2; + align-self:start +} +.image-library-preview{ + overflow:hidden; + background-color:#0a0c0d; + background-image: + linear-gradient(45deg,#111415 25%,transparent 25%,transparent 75%,#111415 75%), + linear-gradient(45deg,#111415 25%,transparent 25%,transparent 75%,#111415 75%); + background-position:0 0,8px 8px; + background-size:16px 16px +} +.image-library-preview img{ + width:100%; + height:100%; + max-width:none; + max-height:none +} +.image-library-grid article.icon .image-library-preview img{ + width:104px; + height:104px +} +.registry-pagination:has(+ .image-library-grid){ + grid-template-columns:minmax(360px,.45fr) auto 1fr auto +} +.registry-pagination:has(+ .image-library-grid) .pagination-search{ + width:100%; + grid-template-columns:minmax(220px,1fr) auto auto +} +@media(max-width:1100px){ + .registry-pagination:has(+ .image-library-grid){ + grid-template-columns:1fr 1fr + } +} +@media(max-width:700px){ + .image-library-upload .image-drop-input{ + grid-template-columns:96px minmax(0,1fr); + grid-template-rows:auto auto; + padding:10px + } + .image-library-upload .image-upload-preview{ + grid-row:1; + width:96px; + height:96px + } + .image-library-upload .image-drop-copy{ + align-self:center + } + .image-library-upload .image-upload-actions{ + grid-column:1/-1; + grid-row:2 + } + .registry-pagination:has(+ .image-library-grid){ + grid-template-columns:1fr + } +} +@media(max-width:560px){ + .registry-pagination:has(+ .image-library-grid) .pagination-search{ + grid-template-columns:minmax(0,1fr) auto + } + .registry-pagination:has(+ .image-library-grid) .pagination-search>a{ + grid-column:1/3 + } +} + +/* Specialization operations: dense identity editing and a horizontally stable focus-tree composer. */ +.specialization-toolbar{ + align-items:end +} +.specialization-progression-console{ + margin:10px 0; + border:1px solid var(--shd-border); + border-left:2px solid #5f6a6c; + background:#0d1011 +} +.specialization-progression-console[open]{ + border-left-color:var(--shd-orange) +} +.specialization-progression-console>summary{ + min-height:54px; + cursor:pointer; + list-style:none +} +.specialization-progression-console>summary::-webkit-details-marker{ + display:none +} +.specialization-progression-console>summary>div:last-child{ + display:flex; + align-items:center; + gap:10px +} +.specialization-progression-console>summary i{ + color:var(--shd-orange); + font-size:18px; + font-style:normal; + transition:transform var(--shd-transition) +} +.specialization-progression-console[open]>summary i{ + transform:rotate(45deg) +} +.specialization-progression-console form{ + padding:10px; + border-top:1px solid var(--shd-border) +} +.specialization-progression-grid{ + display:grid; + grid-template-columns:minmax(280px,.72fr) minmax(520px,1.28fr); + gap:9px +} +.specialization-progression-grid>section{ + border:1px solid var(--shd-border); + background:#111415 +} +.specialization-progression-grid>section>header, +.specialization-rank-editor>section>header, +.specialization-ability-slots>section>header{ + min-height:36px; + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + padding:7px 9px; + border-bottom:1px solid var(--shd-border); + background:#0d1011 +} +.specialization-progression-grid>section>header span, +.specialization-progression-grid>section>header b, +.specialization-rank-editor>section>header span, +.specialization-rank-editor>section>header b, +.specialization-ability-slots>section>header span, +.specialization-ability-slots>section>header b{ + font-size:var(--shd-type-telemetry); + letter-spacing:.75px +} +.specialization-progression-grid>section>header b, +.specialization-rank-editor>section>header b{ + color:var(--shd-orange) +} +.specialization-progression-grid>section:first-child>div{ + display:grid; + grid-template-columns:1fr 1fr; + gap:7px; + padding:9px +} +.specialization-progression-grid>section>p{ + margin:0; + padding:0 9px 10px; + color:var(--shd-text-muted); + font-size:var(--shd-type-supporting); + line-height:1.45 +} +.specialization-rank-profile>div{ + display:grid; + grid-template-columns:repeat(3,1fr) +} +.specialization-rank-profile>div>div{ + display:grid; + grid-template-columns:auto 1fr 1.2fr; + align-items:end; + gap:6px; + padding:9px +} +.specialization-rank-profile>div>div+div{ + border-left:1px solid var(--shd-border) +} +.specialization-rank-profile>div strong{ + align-self:center; + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + letter-spacing:.7px +} +.specialization-row{ + grid-template-columns:minmax(260px,1.65fr) 100px 145px 145px 90px 72px +} +.specialization-row.selected{ + background:#17160f; + box-shadow:inset 2px 0 var(--shd-orange) +} +.specialization-row.archived{ + opacity:.68 +} +.specialization-row-identity{ + min-width:0; + display:grid; + grid-template-columns:40px minmax(0,1fr); + align-items:center; + gap:9px +} +.specialization-row-identity>img{ + width:40px; + height:40px; + display:block; + object-fit:contain +} +.specialization-row-identity>div{ + min-width:0; + display:grid; + gap:3px +} +.specialization-row-identity strong, +.specialization-row-identity small{ + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap +} +.specialization-row>span{ + display:grid; + align-content:center; + gap:2px +} +.specialization-row>span small{ + font-size:var(--shd-type-telemetry) +} +.specialization-workbench{ + margin-top:12px; + border:1px solid var(--shd-border); + border-top:2px solid var(--shd-orange); + background:#0b0e0f +} +.specialization-workbench-command{ + position:static; + min-height:104px; + height:auto; + display:flex; + align-items:center; + justify-content:space-between; + gap:20px; + padding:14px; + border-bottom:1px solid var(--shd-border); + background: + linear-gradient(100deg,rgba(237,126,17,.08),transparent 42%), + #101314 +} +.specialization-composite-identity{ + min-width:0; + display:grid; + grid-template-columns:76px minmax(0,1fr); + align-items:center; + gap:14px +} +.specialization-composite-identity>img{ + width:76px; + height:76px; + display:block; + object-fit:contain; + padding:7px; + border-color:#4c3d26; + background:#0a0c0d +} +.specialization-composite-identity>div{ + min-width:0; + display:grid; + gap:4px +} +.specialization-composite-identity span, +.specialization-focus-command>div:first-child>span{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry); + font-weight:900; + letter-spacing:1px +} +.specialization-composite-identity h3, +.specialization-focus-command h3{ + margin:0; + font-size:clamp(20px,1.8vw,28px); + letter-spacing:.55px +} +.specialization-composite-identity p, +.specialization-focus-command p{ + max-width:760px; + margin:0; + color:var(--shd-text-muted); + font-size:var(--shd-type-supporting); + line-height:1.5 +} +.specialization-workbench-actions{ + display:flex; + flex-wrap:wrap; + justify-content:flex-end; + gap:6px +} +.specialization-focus-tabs{ + display:flex; + overflow-x:auto; + border-bottom:1px solid var(--shd-border); + background:#0d1011; + scrollbar-color:#505758 #15191a +} +.specialization-focus-tabs>button{ + min-width:220px; + min-height:66px; + display:grid; + grid-template-columns:45px minmax(0,1fr); + align-items:center; + gap:9px; + padding:8px 12px; + border:0; + border-right:1px solid var(--shd-border); + border-bottom:2px solid transparent; + background:#101314; + color:var(--shd-text-muted); + text-align:left; + cursor:pointer +} +.specialization-focus-tabs>button:hover, +.specialization-focus-tabs>button:focus-visible{ + background:#161a1b; + color:var(--shd-text) +} +.specialization-focus-tabs>button.active{ + border-bottom-color:var(--shd-orange); + background:#19160f; + color:var(--shd-text) +} +.specialization-focus-tabs>button.pending-reference{ + border-bottom-color:#c9983f +} +.specialization-focus-tabs>button>span:last-child{ + min-width:0; + display:grid; + gap:4px +} +.specialization-focus-tabs strong, +.specialization-focus-tabs small{ + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap +} +.specialization-focus-tabs strong{ + font-size:13px +} +.specialization-focus-tabs small{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry) +} +.focus-composite-icon{ + position:relative; + width:45px; + height:45px; + display:grid; + place-items:center +} +.focus-composite-icon>img:not(.game-icon){ + width:42px; + height:42px; + display:block; + object-fit:contain +} +.focus-composite-icon>.game-icon{ + position:absolute; + right:-2px; + bottom:-2px; + width:20px; + height:20px; + padding:3px; + border:1px solid #5b4b30; + background:#0b0e0f +} +.specialization-focus-empty{ + min-height:66px; + display:grid; + align-content:center; + gap:3px; + padding:10px 14px +} +.specialization-focus-empty strong{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry) +} +.specialization-focus-empty span{ + color:var(--shd-text-muted); + font-size:var(--shd-type-supporting) +} +.specialization-focus-command{ + display:grid; + grid-template-columns:minmax(260px,1fr) auto auto; + align-items:center; + gap:18px; + padding:13px 14px; + border-bottom:1px solid var(--shd-border); + background:#111415 +} +.specialization-focus-command>div:first-child{ + display:grid; + gap:4px +} +.specialization-focus-telemetry{ + display:grid; + grid-template-columns:repeat(2,minmax(96px,1fr)); + border:1px solid var(--shd-border) +} +.specialization-focus-telemetry span{ + display:grid; + gap:3px; + padding:7px 9px; + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + letter-spacing:.6px +} +.specialization-focus-telemetry span:nth-child(even){ + border-left:1px solid var(--shd-border) +} +.specialization-focus-telemetry span:nth-child(n+3){ + border-top:1px solid var(--shd-border) +} +.specialization-focus-telemetry b{ + color:var(--shd-text) +} +.specialization-focus-telemetry b.complete{ + color:var(--shd-success) +} +.specialization-focus-command>.specialization-mutation{ + display:flex; + gap:6px +} +.specialization-ability-assignment{ + margin:10px +} +.specialization-ability-slots{ + display:grid; + grid-template-columns:repeat(3,1fr); + gap:7px +} +.specialization-ability-slots>section{ + min-width:0; + border:1px solid var(--shd-border); + background:#0d1011 +} +.specialization-ability-slots>section>header b{ + color:var(--shd-text-muted) +} +.specialization-ability-slots .searchable-assignment{ + margin:8px +} +.specialization-ability-slots p{ + min-height:52px; + margin:0; + padding:0 9px 9px; + color:var(--shd-text-muted); + font-size:var(--shd-type-supporting); + line-height:1.4 +} +.specialization-ability-summary{ + display:grid; + grid-template-columns:repeat(3,1fr); + gap:7px; + padding:10px; + border-bottom:1px solid var(--shd-border) +} +.specialization-ability-summary>article{ + position:relative; + min-width:0; + display:grid; + grid-template-columns:54px minmax(0,1fr) auto; + align-items:center; + gap:9px; + padding:9px; + border:1px solid var(--shd-border); + border-top:2px solid #596164; + background:#111415 +} +.specialization-ability-summary>article.tree-linked{ + border-top-color:#9a6c27 +} +.specialization-ability-summary>article>img{ + width:54px; + height:54px; + display:block; + object-fit:contain +} +.specialization-ability-summary article>div{ + min-width:0; + display:grid; + gap:4px +} +.specialization-ability-summary span{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry); + font-weight:900 +} +.specialization-ability-summary strong{ + font-size:13px +} +.specialization-ability-summary p{ + display:-webkit-box; + overflow:hidden; + margin:0; + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + line-height:1.4; + -webkit-box-orient:vertical; + -webkit-line-clamp:2 +} +.specialization-ability-summary button{ + align-self:start; + border:0; + background:transparent; + color:var(--shd-orange); + font-size:var(--shd-type-telemetry); + font-weight:900; + cursor:pointer +} +.specialization-tree-console{ + margin:10px; + border:1px solid var(--shd-border); + background:#090b0c +} +.specialization-tree-console>header{ + position:static; + height:auto; + min-height:54px; + display:flex; + align-items:center; + justify-content:space-between; + gap:16px; + padding:9px 11px; + border-bottom:1px solid var(--shd-border); + background:#111415 +} +.specialization-tree-console>header>div{ + display:grid; + gap:3px +} +.specialization-tree-console>header span, +.specialization-tree-console>header small{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + letter-spacing:.7px +} +.specialization-tree-console>header h3{ + margin:0; + font-size:16px +} +.specialization-tree-console>header b{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry) +} +.specialization-tree-scroll{ + overflow-x:auto; + padding:10px; + scrollbar-color:#596164 #141718 +} +.specialization-tree{ + min-width:1180px; + display:grid; + gap:8px +} +.specialization-tree-row{ + display:grid; + grid-template-columns:160px minmax(930px,1fr) 160px; + align-items:stretch; + border:1px solid var(--shd-border); + background:#0e1112 +} +.specialization-tree-row>header{ + position:static; + height:auto; + display:grid; + align-content:center; + gap:5px; + padding:10px; + border-right:1px solid var(--shd-border); + background:#111415 +} +.specialization-tree-row>header span{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry); + font-weight:900; + letter-spacing:1px +} +.specialization-tree-row>header b{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + line-height:1.35 +} +.specialization-tree-track{ + position:relative; + display:grid; + grid-template-columns:repeat(12,minmax(70px,1fr)); + gap:6px; + align-items:center; + min-height:112px; + padding:8px; + background-image:linear-gradient(90deg,transparent calc(100% - 1px),rgba(255,255,255,.035) 1px); + background-size:calc(100% / 12) 100% +} +.specialization-tree-node{ + position:relative; + min-width:0; + height:94px; + display:grid; + grid-template-rows:42px minmax(0,1fr) auto; + justify-items:center; + gap:3px; + padding:6px 4px; + border:1px solid #3c4243; + border-top:2px solid #687274; + background:#141718; + text-align:center +} +.specialization-tree-node.free{ + border-top-color:var(--shd-orange); + background:#19160f +} +.specialization-tree-node.pending-record{ + border-style:dashed; + border-top-color:#c9983f +} +.specialization-tree-node>img{ + width:40px; + height:40px; + display:block; + object-fit:contain +} +.specialization-tree-node>span{ + width:100%; + overflow:hidden; + color:var(--shd-text); + font-size:var(--shd-type-telemetry); + font-weight:900; + text-overflow:ellipsis; + white-space:nowrap +} +.specialization-tree-node>b{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry) +} +.specialization-tree-node>button{ + position:absolute; + inset:0; + border:0; + background:rgba(11,14,15,.92); + color:var(--shd-orange); + font-size:var(--shd-type-telemetry); + font-weight:900; + opacity:0; + cursor:pointer +} +.specialization-tree-node:hover>button, +.specialization-tree-node:focus-within>button{ + opacity:1 +} +.specialization-tree-add{ + min-height:100%; + border:0; + border-left:1px solid var(--shd-border) +} +.specialization-add-row{ + min-height:64px; + display:grid; + grid-template-columns:auto auto 1fr; + align-items:center; + justify-content:center; + gap:8px; + padding:10px; + border:1px dashed #4e5658; + background:#0d1011; + color:var(--shd-text-muted); + cursor:pointer +} +.specialization-add-row>span{ + color:var(--shd-orange); + font-size:18px +} +.specialization-add-row>b, +.specialization-add-row>small{ + font-size:var(--shd-type-telemetry); + letter-spacing:.65px +} +.specialization-add-row>small{ + justify-self:start +} +.specialization-add-row:hover, +.specialization-add-row:focus-visible{ + border-color:var(--shd-orange); + background:#17140f; + color:var(--shd-text) +} +.specialization-record-drawer{ + margin:0 10px 10px; + border:1px solid var(--shd-border); + background:#0d1011 +} +.specialization-record-drawer>summary{ + min-height:40px; + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + padding:8px 10px; + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + font-weight:900; + letter-spacing:.75px; + cursor:pointer; + list-style:none +} +.specialization-record-drawer>summary::-webkit-details-marker{ + display:none +} +.specialization-record-drawer>summary b{ + color:var(--shd-orange) +} +.specialization-record-drawer>div{ + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:6px; + padding:7px; + border-top:1px solid var(--shd-border) +} +.specialization-record-drawer article{ + min-width:0; + display:grid; + grid-template-columns:40px minmax(0,1fr) auto; + align-items:center; + gap:8px; + padding:7px; + border:1px solid var(--shd-border); + background:#111415 +} +.specialization-record-drawer article.archived{ + opacity:.68 +} +.specialization-record-drawer article>img{ + width:40px; + height:40px; + display:block; + object-fit:contain +} +.specialization-record-drawer article>div{ + min-width:0; + display:grid; + gap:3px +} +.specialization-record-drawer strong, +.specialization-record-drawer small{ + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap +} +.specialization-record-drawer small, +.specialization-record-drawer>div>p{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry) +} +.specialization-record-drawer>div>p{ + grid-column:1/-1; + margin:0; + padding:10px +} +.specialization-identity-editor, +.specialization-focus-editor, +.specialization-ability-editor, +.specialization-node-editor{ + margin:10px 0 +} +.specialization-record-identity{ + display:grid; + grid-template-columns:220px minmax(0,1fr); + align-items:start; + gap:8px +} +.specialization-record-identity>div:last-child{ + display:grid; + grid-template-columns:minmax(260px,.65fr) minmax(360px,1.35fr); + gap:8px +} +.specialization-record-identity>div:last-child>.field:last-child{ + grid-column:2; + grid-row:1/3 +} +.specialization-record-identity textarea{ + min-height:100% +} +.specialization-identity-editor .specialization-record-identity>div:last-child{ + grid-template-columns:1fr; + grid-template-rows:auto 1fr; + align-self:stretch +} +.specialization-identity-editor .specialization-record-identity>div:last-child>.field:last-child{ + grid-column:auto; + grid-row:auto +} +.specialization-identity-editor .specialization-record-identity textarea{ + min-height:126px +} +.specialization-focus-identity{ + display:grid; + grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) 120px; + gap:8px +} +.specialization-focus-identity>.segmented-choice, +.specialization-focus-identity>.focus-description{ + grid-column:1/-1 +} +.specialization-focus-identity .focus-description textarea{ + min-height:72px +} +.specialization-ability-mechanics{ + display:grid; + grid-template-columns:200px 200px minmax(280px,1fr); + gap:8px; + margin-top:8px +} +.specialization-ability-mechanics>div{ + display:grid; + align-content:center; + gap:3px; + padding:8px; + border:1px solid var(--shd-border); + background:#0d1011 +} +.specialization-ability-mechanics span, +.specialization-ability-mechanics small{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry) +} +.specialization-ability-mechanics strong{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry) +} +.specialization-parent-lock>strong{ + min-height:37px; + display:flex; + align-items:center; + padding:7px 9px; + border:1px solid var(--shd-border); + background:#101314; + color:var(--shd-text); + font-size:var(--shd-type-supporting); + letter-spacing:.35px +} +.specialization-node-position{ + display:grid; + grid-template-columns:110px 110px minmax(300px,1fr); + gap:8px +} +.specialization-node-template{ + margin-top:8px +} +.specialization-rank-editor{ + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:8px; + margin-top:8px +} +.specialization-rank-editor>section{ + min-width:0; + border:1px solid var(--shd-border); + background:#111415 +} +.specialization-rank-editor>section>header>div{ + display:grid; + gap:3px +} +.specialization-rank-editor>section>header small{ + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry) +} +.specialization-rank-editor>section>.field{ + margin:8px +} +.specialization-rank-editor textarea{ + min-height:74px +} +.specialization-component-costs{ + display:grid; + grid-template-columns:repeat(3,1fr); + gap:7px; + margin:8px; + padding:8px; + border:1px solid #344e65; + background:#0d1318 +} +.specialization-component-costs legend{ + padding:0 5px; + color:#77b6e9; + font-size:var(--shd-type-telemetry); + font-weight:900 +} +.specialization-component-costs .field span{ + display:flex; + align-items:center; + justify-content:space-between; + gap:4px +} +.specialization-component-costs small{ + color:#77b6e9; + font-size:var(--shd-type-telemetry) +} +.specialization-omega-cost, +.specialization-credit-only{ + min-height:60px; + display:flex; + align-items:center; + justify-content:space-between; + gap:9px; + margin:8px; + padding:8px; + border:1px solid var(--shd-border); + background:#0d1011 +} +.quality-marker.highend{ + display:flex; + align-items:center; + gap:8px; + color:#f2c443; + font-size:var(--shd-type-telemetry); + font-weight:900 +} +.specialization-omega-cost p, +.specialization-credit-only span{ + margin:0; + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry) +} +.specialization-credit-only b{ + color:var(--shd-orange); + font-size:var(--shd-type-telemetry) +} +.structured-effect-editor{ + display:grid; + gap:7px; + padding:8px +} +.structured-effect-row{ + min-width:0; + border:1px solid var(--shd-border); + background:#0d1011 +} +.structured-effect-row>header{ + position:static; + height:auto; + min-height:32px; + display:flex; + align-items:center; + justify-content:space-between; + padding:6px 8px; + border-bottom:1px solid var(--shd-border) +} +.structured-effect-row>header strong, +.structured-effect-row>header button{ + font-size:var(--shd-type-telemetry); + letter-spacing:.7px +} +.structured-effect-grid{ + display:grid; + grid-template-columns:minmax(260px,1.1fr) minmax(240px,1fr) minmax(170px,.8fr) 120px minmax(240px,1fr); + align-items:end; + gap:7px; + padding:8px +} +.structured-effect-grid>.segmented-choice{ + grid-column:auto +} +.effect-custom-target{ + min-height:49px; + display:grid; + align-content:center; + gap:3px; + padding:7px; + border:1px solid var(--shd-border); + background:#111415 +} +.effect-custom-target span, +.effect-custom-target small{ + font-size:var(--shd-type-telemetry) +} +.effect-custom-target small{ + color:var(--shd-text-muted) +} +.structured-effect-empty{ + min-height:72px; + display:grid; + place-content:center; + gap:4px; + border:1px dashed var(--shd-border); + color:var(--shd-text-muted); + text-align:center +} +.structured-effect-empty strong, +.structured-effect-empty span{ + font-size:var(--shd-type-telemetry) +} +.structured-effect-add{ + justify-self:start +} +.specialization-proposal-preview .specialization-fields{ + grid-template-columns:repeat(2,minmax(0,1fr)) +} +.proposal-structured-list, +.proposal-rank-preview{ + display:grid; + gap:5px; + padding:0 8px 8px +} +.proposal-structured-list>header, +.proposal-rank-preview section>header{ + position:static; + height:auto; + min-height:30px; + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + padding:6px 8px; + border:1px solid var(--shd-border); + background:#111415 +} +.proposal-structured-list span, +.proposal-structured-list b, +.proposal-rank-preview span, +.proposal-rank-preview b{ + font-size:var(--shd-type-telemetry); + letter-spacing:.65px +} +.proposal-structured-list b, +.proposal-rank-preview b{ + color:var(--shd-orange) +} +.proposal-structured-list p, +.proposal-rank-preview p, +.proposal-rank-preview small{ + margin:0; + padding:6px 8px; + border:1px solid var(--shd-border); + color:var(--shd-text-muted); + font-size:var(--shd-type-telemetry); + overflow-wrap:anywhere +} +.proposal-rank-preview section{ + display:grid; + gap:3px +} +.data-readonly .specialization-mutation{ + display:none!important +} + +@media(max-width:1280px){ + .specialization-rank-editor{ + grid-template-columns:1fr + } + .structured-effect-grid{ + grid-template-columns:repeat(2,minmax(0,1fr)) + } + .specialization-ability-summary{ + grid-template-columns:1fr + } +} +@media(max-width:1050px){ + .specialization-progression-grid{ + grid-template-columns:1fr + } + .specialization-rank-profile>div>div{ + grid-template-columns:auto 1fr 1fr + } + .specialization-row{ + grid-template-columns:minmax(230px,1.5fr) 80px 120px 120px 84px 68px + } + .specialization-focus-command{ + grid-template-columns:1fr auto + } + .specialization-focus-command>div:first-child{ + grid-column:1/-1 + } + .specialization-ability-slots{ + grid-template-columns:1fr + } + .specialization-record-drawer>div{ + grid-template-columns:1fr 1fr + } + .specialization-record-identity>div:last-child{ + grid-template-columns:1fr + } + .specialization-record-identity>div:last-child>.field:last-child{ + grid-column:auto; + grid-row:auto + } +} +@media(max-width:800px){ + .specialization-workbench-command{ + align-items:stretch; + flex-direction:column + } + .specialization-workbench-actions{ + justify-content:flex-start + } + .specialization-focus-command{ + grid-template-columns:1fr + } + .specialization-focus-command>div:first-child{ + grid-column:auto + } + .specialization-focus-command>.specialization-mutation{ + width:100% + } + .specialization-focus-command>.specialization-mutation>button{ + flex:1 + } + .specialization-record-identity, + .specialization-focus-identity, + .specialization-ability-mechanics, + .specialization-node-position{ + grid-template-columns:1fr + } + .specialization-focus-identity>.segmented-choice, + .specialization-focus-identity>.focus-description{ + grid-column:auto + } + .specialization-component-costs{ + grid-template-columns:1fr + } + .specialization-proposal-preview{ + grid-template-columns:1fr + } +} +@media(max-width:620px){ + .specialization-progression-console>summary{ + align-items:flex-start; + flex-direction:column + } + .specialization-progression-console>summary>div:last-child{ + width:100%; + justify-content:space-between + } + .specialization-progression-grid>section:first-child>div, + .specialization-rank-profile>div{ + grid-template-columns:1fr + } + .specialization-rank-profile>div>div+div{ + border-top:1px solid var(--shd-border); + border-left:0 + } + .specialization-composite-identity{ + grid-template-columns:58px minmax(0,1fr) + } + .specialization-composite-identity>img{ + width:58px; + height:58px + } + .specialization-focus-tabs>button{ + min-width:190px + } + .specialization-focus-telemetry{ + grid-template-columns:1fr 1fr; + width:100% + } + .specialization-record-drawer>div, + .specialization-proposal-preview .specialization-fields, + .structured-effect-grid{ + grid-template-columns:1fr + } + .specialization-record-drawer article{ + grid-template-columns:40px minmax(0,1fr) + } + .specialization-record-drawer article>button{ + grid-column:1/-1; + min-height:44px + } + .specialization-workbench-actions>button, + .specialization-focus-command>.specialization-mutation>button, + .specialization-tree-add, + .specialization-add-row{ + min-height:46px + } +} +@media(prefers-reduced-motion:reduce){ + .specialization-progression-console>summary i{ + transition:none + } +} + +/* Attribute narratives span the dense editor and Oversight comparison surfaces. */ +.attribute-editor .attribute-description{ + grid-column:1/-1 +} +.attribute-editor .attribute-description textarea{ + min-height:72px +} +.attribute-row>div:first-child{ + min-width:0 +} +.attribute-row>div:first-child small{ + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap +} +.proposal-attribute-grid .preview-field.description{ + grid-column:span 4 +} +@media(max-width:520px){ + .proposal-attribute-grid .preview-field.description{ + grid-column:auto + } +} diff --git a/src/lib/components/IconUploadField.svelte b/src/lib/components/IconUploadField.svelte index 8df2064..4343219 100644 --- a/src/lib/components/IconUploadField.svelte +++ b/src/lib/components/IconUploadField.svelte @@ -1,5 +1,7 @@ - +{resolvedAlt} diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index f3fbe9c..d9df659 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -35,6 +35,70 @@ }; return proposed[names[key] ?? key]; }; + const specializationKind = $derived( + action === 'saveProgression' ? 'PROGRESSION PROFILE' + : action === 'saveSpecialization' ? 'SPECIALIZATION' + : action === 'saveFocus' ? 'FOCUS' + : action === 'saveAbility' ? 'CLASS ABILITY' + : action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET' + : 'TREE NODE' + ); + const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record | 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}` : ''}`; + };
@@ -50,6 +114,7 @@ {#if currentSnapshot}
NAME{shown(attributeValue('current', 'name'))}
+
DESCRIPTION · OPTIONAL{shown(attributeValue('current', 'description'))}
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
{field[1]}{shown(attributeValue('current', field[0]))}
{/each} @@ -66,6 +131,7 @@
PROPOSED DEFINITIONAFTER
NAME{shown(attributeValue('proposed', 'name'))}
+
DESCRIPTION · OPTIONAL{shown(attributeValue('proposed', 'description'))}
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
{field[1]}{shown(attributeValue('proposed', field[0]))}
{/each} @@ -76,6 +142,70 @@
+ {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)} +
+
+
CURRENT LIVE · {specializationKind}{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}
+ {#if specializationRecord} +
+ {#each specializationFields as field} +
{field.label}{shown(field.current)}
+ {/each} +
+ {#if action === 'saveAbility'} +
+
STRUCTURED EFFECTS{currentAbilityEffects.length}
+ {#each currentAbilityEffects as effect}

{effectSummary(effect)}

{:else}

NO STRUCTURED EFFECTS

{/each} +
+ {:else if action === 'saveNode'} +
+ {#each currentNodeRanks as rank} +
RANK {rank.rank}{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS

{rank.description}

{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}
+ {/each} +
+ {:else if action === 'saveProgression'} +
+ {#each currentRankProfiles as profile} +
RANK {profile.rank}{profile.point_cost} POINTS

{Number(profile.credit_cost).toLocaleString()} CREDITS

+ {/each} +
+ {/if} + {:else} +
NEW {specializationKind}No live registry record will be replaced.
+ {/if} +
+
+
PROPOSED · {specializationKind}AFTER
+
+ {#each specializationFields as field} +
{field.label}{shown(field.proposed)}
+ {/each} +
+ {#if action === 'saveAbility'} +
+
STRUCTURED EFFECTS{proposedEffects.length}
+ {#each proposedEffects as effect}

{effectSummary(effect)}

{:else}

NO STRUCTURED EFFECTS

{/each} +
+ {:else if action === 'saveNode'} +
+ {#each proposedRanks as rank} +
+
RANK {rank.rank}{rank.effects?.length ?? 0} EFFECTS
+

{rank.description || 'NO RANK DESCRIPTION'}

+ {#each rank.effects ?? [] as effect}{effectSummary(effect)}{/each} + {Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'} +
+ {/each} +
+ {:else if action === 'saveProgression'} +
+ {#each [1,2,3] as rank} +
RANK {rank}{shown(proposed[`rank${rank}PointCost`])} POINTS

{Number(proposed[`rank${rank}CreditCost`] || 0).toLocaleString()} CREDITS

+ {/each} +
+ {/if} +
+
{:else}
PROPOSED {label(module)} RECORDAFTER
diff --git a/src/lib/components/StructuredEffectEditor.svelte b/src/lib/components/StructuredEffectEditor.svelte new file mode 100644 index 0000000..51f546c --- /dev/null +++ b/src/lib/components/StructuredEffectEditor.svelte @@ -0,0 +1,102 @@ + + +
+ {#each effects as effect, index} + {@const labelCanUseTarget = allowTargetLabelFallback && ['Attribute', 'Ability'].includes(effect.type)} +
+
EFFECT {String(index + 1).padStart(2, '0')}
+
+ changeType(index, values[0])} + showIndicator={false} + required + /> + {#if effect.type === 'Attribute'} +
ATTRIBUTE TARGET
+ {:else if effect.type === 'Ability'} +
CLASS ABILITY TARGET
+ {:else} +
CUSTOM MECHANICRecorded for display now and calculation support later.
+ {/if} + + + +
+
+ {:else} +
{emptyLabel}Add structured values when this mechanic should contribute to calculations or an ability preview.
+ {/each} + +
diff --git a/src/lib/item-icons.ts b/src/lib/item-icons.ts new file mode 100644 index 0000000..299305f --- /dev/null +++ b/src/lib/item-icons.ts @@ -0,0 +1 @@ +export const MISSING_ITEM_ICON_URL = '/missing-item-icon.svg'; diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 25bbe57..9a5e350 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -27,7 +27,23 @@ export async function recordIsacAudit(input: { } export async function auditRecord(table: string, id: string): Promise | null> { - if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'gear_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) { + if (![ + 'stat_attributes', + 'talents', + 'talent_triggers', + 'brand_sets', + 'gear', + 'weapons', + 'weapon_mods', + 'gear_mods', + 'os_protocols', + 'skill_chip_sets', + 'specializations', + 'specialization_focuses', + 'specialization_abilities', + 'specialization_tree_nodes', + 'roles' + ].includes(table)) { throw new Error('Unsupported audit table'); } const database = await getDatabase(); diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index 212591d..33e4ffe 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -6,7 +6,7 @@ import { recordIsacAudit } from '$lib/server/audit'; import { getContributorMode } from '$lib/server/contributor-mode'; import { notifyOversightReview } from '$lib/server/oversight-notifications'; -export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const; +export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const; export type DataModule = typeof DATA_MODULES[number]; const proposalResultKeys: Partial>> = { @@ -28,7 +28,15 @@ const proposalResultKeys: Partial { module: 'attributes', action: 'save', kind: 'attribute', - fields: ['type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue'] + fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue'] }])).map((stat) => ({ ...stat, calculation_type: stat.calculationType, @@ -64,8 +64,10 @@ export const actions: Actions = { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim(); + const description = String(form.get('description') ?? '').normalize('NFKC').trim(); const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? ''); if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' }); + if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' }); if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' }); if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' }); const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; }; @@ -76,8 +78,8 @@ export const actions: Actions = { if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' }); const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await auditRecord('stat_attributes', id) : null; - if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id); - else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now); + if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id); + else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) }); const savedItem = await auditRecord('stat_attributes', statId); return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() }; diff --git a/src/routes/isac/attributes/+page.svelte b/src/routes/isac/attributes/+page.svelte index 05519f9..a7e6ae0 100644 --- a/src/routes/isac/attributes/+page.svelte +++ b/src/routes/isac/attributes/+page.svelte @@ -12,8 +12,11 @@ const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value })); const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value })); const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}]; - const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''}); + const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''}); const value = (input: unknown) => input ?? ''; + const attributeMeta = (stat: any) => stat.pending + ? `PROPOSED BY ${stat.proposerName}` + : [stat.description, stat.unit || 'NO UNIT'].filter(Boolean).join(' · '); $effect(() => { if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) { handledResultToken = form.resultToken; @@ -28,6 +31,6 @@ Attributes — SHD.build admin

ATTRIBUTE REGISTRY

ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

-{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} +{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} -
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each visibleStats as stat}
{stat.name}{stat.pending ? `PROPOSED BY ${stat.proposerName}` : stat.unit || 'NO UNIT'}
{stat.type}{stat.category}{stat.calculation_type}{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}{#if stat.pending}{:else}{/if}
{/each}
+
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each visibleStats as stat}
{stat.name}{attributeMeta(stat)}
{stat.type}{stat.category}{stat.calculation_type}{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}{#if stat.pending}{:else}{/if}
{/each}
diff --git a/src/routes/isac/audit/+page.server.ts b/src/routes/isac/audit/+page.server.ts index 7837704..3ca4a95 100644 --- a/src/routes/isac/audit/+page.server.ts +++ b/src/routes/isac/audit/+page.server.ts @@ -113,9 +113,9 @@ export const actions: Actions = { if (action.startsWith('attribute.')) { current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null; if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' }); - await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?, + await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?, weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)) - .run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId); + .run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId); restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null; } else if (action.endsWith('.archived')) { const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : ''; diff --git a/src/routes/isac/data-moderation/+page.server.ts b/src/routes/isac/data-moderation/+page.server.ts index 5d56a27..94b6468 100644 --- a/src/routes/isac/data-moderation/+page.server.ts +++ b/src/routes/isac/data-moderation/+page.server.ts @@ -10,11 +10,59 @@ import type { Actions, PageServerLoad } from './$types'; import { createNotification } from '$lib/server/notifications'; const statuses = ['pending', 'approved', 'rejected', 'failed']; -const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips']; +const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations']; type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string }; +async function specializationProposalSnapshot(database: any, proposal: any) { + const id = String(proposal.entity_id ?? ''); + if (proposal.action === 'saveProgression') { + return { + record: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(), + rankProfiles: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all() + }; + } + if (!id) return null; + if (proposal.action === 'saveSpecialization') { + return { record: await (await database.prepare('SELECT * FROM specializations WHERE id=?')).get(id) ?? null }; + } + if (proposal.action === 'saveFocus') { + return { record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null }; + } + if (proposal.action === 'saveAbility') { + return { + record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null, + effects: await (await database.prepare( + 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order' + )).all(id) + }; + } + if (proposal.action === 'saveFocusAbilities') { + return { + record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null, + abilities: await (await database.prepare( + 'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot' + )).all(id) + }; + } + if (proposal.action === 'saveNode') { + return { + record: await (await database.prepare('SELECT * FROM specialization_tree_nodes WHERE id=?')).get(id) ?? null, + ranks: await (await database.prepare( + 'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank' + )).all(id), + effects: await (await database.prepare( + 'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order' + )).all(id), + costs: await (await database.prepare( + 'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id' + )).all(id) + }; + } + return null; +} + export const load: PageServerLoad = async ({ cookies, url }) => { await requirePermission(await getSessionUser(cookies), 'data.approve'); const database = await getDatabase(); @@ -40,7 +88,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ...proposal, current_snapshot: proposal.module === 'attributes' && proposal.entity_id ? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null - : null + : proposal.module === 'specializations' + ? await specializationProposalSnapshot(database, proposal) + : null }))); const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all(); return { proposals: proposalsWithSnapshots, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules }; diff --git a/src/routes/isac/data-moderation/+page.svelte b/src/routes/isac/data-moderation/+page.svelte index 9697df2..006a41d 100644 --- a/src/routes/isac/data-moderation/+page.svelte +++ b/src/routes/isac/data-moderation/+page.svelte @@ -55,7 +55,7 @@
CHANGECONTRIBUTORSUBMITTEDSTATUS
{#each data.proposals as proposal}
-
{proposal.module === 'skillchips' ? 'CHIP' : proposal.module.slice(0,4).toUpperCase()}
{proposal.summary}{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}
+
{proposal.module === 'skillchips' ? 'CHIP' : proposal.module === 'specializations' ? 'CLASS' : proposal.module.slice(0,4).toUpperCase()}
{proposal.summary}{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}
{proposal.proposer_name}AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}
{date(proposal.created_at)} {proposal.status.toUpperCase()} diff --git a/src/routes/isac/images/+page.svelte b/src/routes/isac/images/+page.svelte index 72168fc..e7b0b53 100644 --- a/src/routes/isac/images/+page.svelte +++ b/src/routes/isac/images/+page.svelte @@ -42,7 +42,7 @@

NEW MANAGED ASSET

UPLOAD + PROCESS

-
+