diff --git a/database/schema.sql b/database/schema.sql index 134dd07..02bc5bf 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -926,6 +926,266 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes ( ); CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order); +-- Phase 2 mechanic persistence. Revision rows are append-only by application +-- convention. No database triggers enforce immutability. +CREATE TABLE IF NOT EXISTS mechanic_authored_sources ( + id TEXT PRIMARY KEY, + owner_type TEXT NOT NULL CHECK(owner_type IN ( + 'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank' + )), + owner_id TEXT NOT NULL CHECK(length(trim(owner_id))>0), + template TEXT NOT NULL CHECK(length(trim(template))>0), + authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0), + source_fingerprint TEXT NOT NULL CHECK( + length(source_fingerprint)=64 + AND lower(source_fingerprint)=source_fingerprint + AND source_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + talent_scope TEXT CHECK(talent_scope IS NULL OR talent_scope IN ('standard','exotic')), + talent_has_tier_ii INTEGER CHECK(talent_has_tier_ii IS NULL OR talent_has_tier_ii IN (0,1)), + specialization_rank_description TEXT, + specialization_effect_id TEXT, + specialization_effect_template TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(owner_type,owner_id), + CHECK( + (owner_type='talent' + AND talent_scope IS NOT NULL + AND specialization_rank_description IS NULL + AND specialization_effect_id IS NULL + AND specialization_effect_template IS NULL) + OR + (owner_type='specialization_tree_rank' + AND talent_scope IS NULL + AND talent_has_tier_ii IS NULL + AND specialization_rank_description IS NOT NULL + AND length(trim(specialization_rank_description))>0 + AND specialization_effect_id IS NOT NULL + AND length(trim(specialization_effect_id))>0 + AND specialization_effect_template IS NOT NULL + AND length(trim(specialization_effect_template))>0) + OR + (owner_type IN ('os_protocol_talent','skill_chip_talent') + AND talent_scope IS NULL + AND talent_has_tier_ii IS NULL + AND specialization_rank_description IS NULL + AND specialization_effect_id IS NULL + AND specialization_effect_template IS NULL) + ) +); +CREATE INDEX IF NOT EXISTS mechanic_authored_sources_owner_idx + ON mechanic_authored_sources(owner_type,owner_id); +CREATE TABLE IF NOT EXISTS mechanic_authored_variants ( + source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE, + variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0), + materialized TEXT NOT NULL CHECK(length(materialized)>0), + sort_order INTEGER NOT NULL CHECK(sort_order>=0), + PRIMARY KEY(source_id,variant_key), + UNIQUE(source_id,sort_order) +); +CREATE INDEX IF NOT EXISTS mechanic_authored_variants_source_order_idx + ON mechanic_authored_variants(source_id,sort_order); +CREATE TABLE IF NOT EXISTS mechanic_authored_slots ( + source_id TEXT NOT NULL, + variant_key TEXT NOT NULL, + slot_key TEXT NOT NULL CHECK(length(trim(slot_key))>0), + display_value TEXT NOT NULL CHECK( + display_value=trim(display_value) + AND display_value GLOB '*[0-9]*' + AND display_value NOT GLOB '*[^0-9.+-]*' + AND substr(display_value,2) NOT GLOB '*[+-]*' + AND length(display_value)-length(replace(display_value,'.',''))<=1 + AND substr(display_value,-1)<>'.' + AND display_value NOT IN ('+','-','.','+.','-.') + ), + sort_order INTEGER NOT NULL CHECK(sort_order>=0), + unit TEXT, + stat_attribute_id TEXT REFERENCES stat_attributes(id), + PRIMARY KEY(source_id,variant_key,slot_key), + UNIQUE(source_id,variant_key,sort_order), + FOREIGN KEY(source_id,variant_key) + REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS mechanic_authored_slots_variant_order_idx + ON mechanic_authored_slots(source_id,variant_key,sort_order); +CREATE INDEX IF NOT EXISTS mechanic_authored_slots_attribute_idx + ON mechanic_authored_slots(stat_attribute_id) + WHERE stat_attribute_id IS NOT NULL; +CREATE TABLE IF NOT EXISTS mechanic_model_revisions ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL CHECK(revision_number>0), + source_fingerprint TEXT NOT NULL CHECK( + length(source_fingerprint)=64 + AND lower(source_fingerprint)=source_fingerprint + AND source_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + mechanic_contract_version INTEGER NOT NULL CHECK(mechanic_contract_version>0), + program_hash TEXT NOT NULL CHECK( + length(program_hash)=64 + AND lower(program_hash)=program_hash + AND program_hash NOT GLOB '*[^0-9a-f]*' + ), + simulation_readiness TEXT NOT NULL CHECK(simulation_readiness IN ('ready','requires_assumptions','captured_only')), + scenario_inputs_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(scenario_inputs_json)), + source_variants_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(source_variants_json)), + program_name TEXT NOT NULL CHECK(length(trim(program_name))>0), + origin TEXT NOT NULL CHECK(length(trim(origin))>0), + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + UNIQUE(source_id,revision_number), + UNIQUE(source_id,id) +); +CREATE INDEX IF NOT EXISTS mechanic_model_revisions_source_created_idx + ON mechanic_model_revisions(source_id,revision_number DESC,created_at DESC); +CREATE INDEX IF NOT EXISTS mechanic_model_revisions_fingerprint_idx + ON mechanic_model_revisions(source_id,source_fingerprint); +CREATE TABLE IF NOT EXISTS mechanic_model_revision_pools ( + id TEXT PRIMARY KEY, + revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE, + pool_key TEXT NOT NULL CHECK(length(trim(pool_key))>0), + label TEXT NOT NULL CHECK(length(trim(label))>0), + state_kind TEXT NOT NULL CHECK(state_kind IN ('flag','count','stack','duration','scalar','target_mark','resource')), + state_scope TEXT NOT NULL CHECK(state_scope IN ('agent','target')), + initial_value REAL NOT NULL, + minimum_value REAL, + maximum_value REAL, + best_case_value REAL, + duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0), + duration_variants_json TEXT CHECK(duration_variants_json IS NULL OR json_valid(duration_variants_json)), + sort_order INTEGER NOT NULL CHECK(sort_order>=0), + UNIQUE(revision_id,id), + UNIQUE(revision_id,pool_key), + UNIQUE(revision_id,sort_order), + CHECK(minimum_value IS NULL OR maximum_value IS NULL OR maximum_value>=minimum_value), + CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value), + CHECK(best_case_value IS NULL OR maximum_value IS NULL OR best_case_value<=maximum_value), + CHECK(NOT (duration_seconds IS NOT NULL AND duration_variants_json IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS mechanic_model_revision_pools_revision_order_idx + ON mechanic_model_revision_pools(revision_id,sort_order); +CREATE TABLE IF NOT EXISTS mechanic_model_revision_clauses ( + id TEXT PRIMARY KEY, + revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE, + clause_key TEXT NOT NULL CHECK(length(trim(clause_key))>0), + label TEXT, + event_kind TEXT NOT NULL CHECK(event_kind IN ( + 'shot_fired','weapon_hit','skill_deployed','signature_ability_deployed', + 'skill_hit','skill_multishot_triggered','agent_hit','health_damage_taken', + 'damage_dealt','enemy_killed','assist','ally_downed','agent_downed', + 'ally_rescued','grenade_thrown','medical_kit_used','healing_applied', + 'extra_health_granted','extra_health_changed','movement_snapshot', + 'proximity_snapshot','firing_started','firing_stopped','reload_started', + 'reload_completed','weapon_swapped','tick' + )), + recipe_key TEXT, + conditions_json TEXT CHECK(conditions_json IS NULL OR json_valid(conditions_json)), + reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}' CHECK(json_valid(reducer_json)), + weight_expression_json TEXT CHECK(weight_expression_json IS NULL OR json_valid(weight_expression_json)), + mutations_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(mutations_json)), + cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), + proc_chance_percent REAL CHECK(proc_chance_percent IS NULL OR (proc_chance_percent>=0 AND proc_chance_percent<=100)), + requires_assigned_ability_active INTEGER CHECK(requires_assigned_ability_active IS NULL OR requires_assigned_ability_active IN (0,1)), + sort_order INTEGER NOT NULL CHECK(sort_order>=0), + UNIQUE(revision_id,id), + UNIQUE(revision_id,clause_key), + UNIQUE(revision_id,sort_order) +); +CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_revision_order_idx + ON mechanic_model_revision_clauses(revision_id,sort_order); +CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_event_idx + ON mechanic_model_revision_clauses(event_kind,revision_id,sort_order); +CREATE TABLE IF NOT EXISTS mechanic_model_revision_outcomes ( + id TEXT PRIMARY KEY, + revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE, + clause_id TEXT NOT NULL, + state_pool_id TEXT, + effect_key TEXT NOT NULL CHECK(length(trim(effect_key))>0), + variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0), + label TEXT NOT NULL CHECK(length(trim(label))>0), + outcome_kind TEXT NOT NULL CHECK(outcome_kind IN ('attribute_modifier','direct_damage','restoration','resource_cost','weapon_reload','marker')), + effect_scope TEXT NOT NULL CHECK(effect_scope IN ('self','equipped_weapon','inactive_weapon','target','allies','self_and_allies','assigned_ability','all_skills')), + value REAL NOT NULL, + value_expression_json TEXT CHECK(value_expression_json IS NULL OR json_valid(value_expression_json)), + value_basis TEXT NOT NULL CHECK(value_basis IN ('flat','max_health_percent','current_health_percent','weapon_base_percent')), + resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo','signature_ability_charge')), + delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')), + delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), + application_phase TEXT NOT NULL CHECK(application_phase IN ('on_event','after_event','on_enter','on_exit','while_active')), + combat_timing TEXT CHECK(combat_timing IS NULL OR combat_timing IN ('pre_event','current')), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + per_stack INTEGER CHECK(per_stack IS NULL OR per_stack IN (0,1)), + min_state_value REAL CHECK(min_state_value IS NULL OR min_state_value>=0), + max_state_value REAL CHECK(max_state_value IS NULL OR max_state_value>=0), + targeting_json TEXT CHECK(targeting_json IS NULL OR json_valid(targeting_json)), + probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)), + sort_order INTEGER NOT NULL CHECK(sort_order>=0), + UNIQUE(revision_id,id), + UNIQUE(revision_id,clause_id,variant_key,effect_key), + UNIQUE(revision_id,clause_id,variant_key,sort_order), + CHECK(max_state_value IS NULL OR min_state_value IS NULL OR max_state_value>=min_state_value), + CHECK( + (delivery_mode='instant' AND delivery_duration_seconds IS NULL) + OR (delivery_mode='continuous' AND outcome_kind='restoration' AND delivery_duration_seconds IS NOT NULL) + ), + CHECK( + (outcome_kind='attribute_modifier' AND stat_attribute_id IS NOT NULL AND resource_kind IS NULL) + OR (outcome_kind IN ('restoration','resource_cost') AND stat_attribute_id IS NULL AND resource_kind IS NOT NULL) + OR (outcome_kind IN ('direct_damage','weapon_reload','marker') AND stat_attribute_id IS NULL AND resource_kind IS NULL) + ), + FOREIGN KEY(revision_id,clause_id) + REFERENCES mechanic_model_revision_clauses(revision_id,id) ON DELETE CASCADE, + FOREIGN KEY(revision_id,state_pool_id) + REFERENCES mechanic_model_revision_pools(revision_id,id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_clause_order_idx + ON mechanic_model_revision_outcomes(revision_id,clause_id,variant_key,sort_order); +CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_pool_idx + ON mechanic_model_revision_outcomes(revision_id,state_pool_id) + WHERE state_pool_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_attribute_idx + ON mechanic_model_revision_outcomes(stat_attribute_id) + WHERE stat_attribute_id IS NOT NULL; +CREATE TABLE IF NOT EXISTS mechanic_model_selections ( + source_id TEXT PRIMARY KEY REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE, + revision_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(source_id,revision_id) + REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS mechanic_model_selections_revision_idx + ON mechanic_model_selections(revision_id,source_id); +CREATE TABLE IF NOT EXISTS mechanic_public_variants ( + source_id TEXT NOT NULL, + variant_key TEXT NOT NULL, + selected_revision_id TEXT, + current_source_fingerprint TEXT NOT NULL CHECK( + length(current_source_fingerprint)=64 + AND lower(current_source_fingerprint)=current_source_fingerprint + AND current_source_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0), + mechanic_contract_version INTEGER CHECK(mechanic_contract_version IS NULL OR mechanic_contract_version>0), + status TEXT NOT NULL CHECK(status IN ('modeled','not_modeled')), + behavior_text TEXT NOT NULL CHECK(length(behavior_text)>0), + display_source TEXT NOT NULL CHECK(display_source IN ('model','authored')), + updated_at TEXT NOT NULL, + PRIMARY KEY(source_id,variant_key), + CHECK( + (display_source='model' AND status='modeled' AND selected_revision_id IS NOT NULL) + OR (display_source='authored' AND status='not_modeled') + ), + FOREIGN KEY(source_id,variant_key) + REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE, + FOREIGN KEY(source_id,selected_revision_id) + REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS mechanic_public_variants_status_idx + ON mechanic_public_variants(status,source_id,variant_key); +CREATE INDEX IF NOT EXISTS mechanic_public_variants_revision_idx + ON mechanic_public_variants(selected_revision_id) + WHERE selected_revision_id IS NOT NULL; CREATE TABLE IF NOT EXISTS talent_tier_effects ( id TEXT PRIMARY KEY, talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE, diff --git a/database/schema.test.ts b/database/schema.test.ts new file mode 100644 index 0000000..f98ed97 --- /dev/null +++ b/database/schema.test.ts @@ -0,0 +1,265 @@ +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +let database: Database; + +const applySchema = async (target: Database) => { + const schema = await readFile(new URL('./schema.sql', import.meta.url), 'utf8'); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +const insertSource = async ( + target: Database, + id: string, + ownerId: string, + fingerprint: string, +) => { + await ( + await target.prepare( + `INSERT INTO mechanic_authored_sources( + id,owner_type,owner_id,template,authored_contract_version,source_fingerprint, + talent_scope,talent_has_tier_ii,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?)`, + ) + ).run( + id, + 'talent', + ownerId, + 'Gain {1}', + 1, + fingerprint, + 'standard', + 1, + '2026-08-27T00:00:00.000Z', + '2026-08-27T00:00:00.000Z', + ); +}; + +describe('mechanic persistence schema', () => { + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + }); + + afterEach(async () => { + await database.close(); + }); + + it('installs the complete schema with clean foreign keys', async () => { + const rows = await ( + await database.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'mechanic_%' ORDER BY name", + ) + ).all(); + + expect(rows.map((row) => row.name)).toEqual([ + 'mechanic_authored_slots', + 'mechanic_authored_sources', + 'mechanic_authored_variants', + 'mechanic_model_revision_clauses', + 'mechanic_model_revision_outcomes', + 'mechanic_model_revision_pools', + 'mechanic_model_revisions', + 'mechanic_model_selections', + 'mechanic_public_variants', + ]); + expect(await (await database.prepare('PRAGMA foreign_key_check')).all()).toEqual([]); + }); + + it('enforces owner metadata, ordered authored children, and source cascades', async () => { + const fingerprint = 'a'.repeat(64); + await insertSource(database, 'source-1', 'talent-1', fingerprint); + + await expect( + insertSource(database, 'source-2', 'talent-1', fingerprint), + ).rejects.toThrow(); + await expect( + database.exec( + `INSERT INTO mechanic_authored_sources( + id,owner_type,owner_id,template,authored_contract_version,source_fingerprint, + specialization_rank_description,specialization_effect_id,specialization_effect_template, + created_at,updated_at + ) VALUES('source-3','talent','talent-3','Gain',1,'${fingerprint}', + 'rank','effect','template','now','now')`, + ), + ).rejects.toThrow(); + + await ( + await database.prepare( + `INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order) + VALUES('source-1','I','Gain 1',0),('source-1','II','Gain 2',1)`, + ) + ).run(); + await ( + await database.prepare( + `INSERT INTO mechanic_authored_slots( + source_id,variant_key,slot_key,display_value,sort_order + ) VALUES('source-1','I','1','1',0)`, + ) + ).run(); + + await expect( + database.exec( + `INSERT INTO mechanic_authored_slots( + source_id,variant_key,slot_key,display_value,sort_order + ) VALUES('source-1','missing','1','1',0)`, + ), + ).rejects.toThrow(); + await expect( + database.exec( + `INSERT INTO mechanic_authored_slots( + source_id,variant_key,slot_key,display_value,sort_order + ) VALUES('source-1','II','1','10% Weapon Damage',0)`, + ), + ).rejects.toThrow(); + + await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'"); + for (const table of [ + 'mechanic_authored_variants', + 'mechanic_authored_slots', + ]) { + expect( + await ( + await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`) + ).get(), + ).toMatchObject({ count: 0 }); + } + }); + + it('allows deliberate duplicate graphs while proving selection ownership', async () => { + const sourceFingerprint = 'a'.repeat(64); + const programHash = 'b'.repeat(64); + await insertSource(database, 'source-1', 'talent-1', sourceFingerprint); + await ( + await database.prepare( + `INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order) + VALUES('source-1','I','Gain 1',0)`, + ) + ).run(); + await ( + await database.prepare( + `INSERT INTO mechanic_model_revisions( + id,source_id,revision_number,source_fingerprint,mechanic_contract_version, + program_hash,simulation_readiness,scenario_inputs_json,source_variants_json, + program_name,origin,created_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + ) + ).run( + 'revision-1', + 'source-1', + 1, + sourceFingerprint, + 1, + programHash, + 'ready', + '[]', + '["I"]', + 'Model', + 'manual', + 'now', + ); + await ( + await database.prepare( + `INSERT INTO mechanic_model_revisions( + id,source_id,revision_number,source_fingerprint,mechanic_contract_version, + program_hash,simulation_readiness,scenario_inputs_json,source_variants_json, + program_name,origin,created_at + ) SELECT 'revision-2',source_id,2,source_fingerprint,mechanic_contract_version, + program_hash,simulation_readiness,scenario_inputs_json,source_variants_json, + program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`, + ) + ).run(); + + await expect( + database.exec( + `INSERT INTO mechanic_model_revisions( + id,source_id,revision_number,source_fingerprint,mechanic_contract_version, + program_hash,simulation_readiness,scenario_inputs_json,source_variants_json, + program_name,origin,created_at + ) SELECT 'revision-3',source_id,1,source_fingerprint,mechanic_contract_version, + program_hash,simulation_readiness,scenario_inputs_json,source_variants_json, + program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`, + ), + ).rejects.toThrow(); + + await ( + await database.prepare( + `INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at) + VALUES('source-1','revision-2','now')`, + ) + ).run(); + await expect( + database.exec( + `INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at) + VALUES('source-1','not-this-source','now')`, + ), + ).rejects.toThrow(); + + await ( + await database.prepare( + `INSERT INTO mechanic_model_revision_pools( + id,revision_id,pool_key,label,state_kind,state_scope,initial_value, + minimum_value,maximum_value,best_case_value,duration_seconds, + duration_variants_json,sort_order + ) VALUES('pool-1','revision-1','stacks','Stacks','stack','agent',0,0,5,5,NULL,NULL,0)`, + ) + ).run(); + await ( + await database.prepare( + `INSERT INTO mechanic_model_revision_clauses( + id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json, + reducer_json,weight_expression_json,mutations_json,cooldown_seconds, + proc_chance_percent,requires_assigned_ability_active,sort_order + ) VALUES('clause-1','revision-1','on-hit','On hit','weapon_hit',NULL,NULL, + '{"kind":"none"}',NULL,'[]',NULL,25,NULL,0)`, + ) + ).run(); + await ( + await database.prepare( + `INSERT INTO mechanic_model_revision_outcomes( + id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label, + outcome_kind,effect_scope,value,value_expression_json,value_basis, + resource_kind,delivery_mode,delivery_duration_seconds,application_phase, + combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value, + targeting_json,probability_percent,sort_order + ) VALUES('outcome-1','revision-1','clause-1','pool-1','mark','I','Mark', + 'marker','self',1,NULL,'flat',NULL,'instant',NULL,'while_active','current', + NULL,0,NULL,NULL,NULL,NULL,0)`, + ) + ).run(); + await ( + await database.prepare( + `INSERT INTO mechanic_public_variants( + source_id,variant_key,selected_revision_id,current_source_fingerprint, + authored_contract_version,mechanic_contract_version,status,behavior_text, + display_source,updated_at + ) VALUES('source-1','I','revision-1',?,1,1,'modeled','Modeled behavior','model','now')`, + ) + ).run(sourceFingerprint); + + await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'"); + for (const table of [ + 'mechanic_authored_variants', + 'mechanic_authored_slots', + 'mechanic_model_revisions', + 'mechanic_model_revision_pools', + 'mechanic_model_revision_clauses', + 'mechanic_model_revision_outcomes', + 'mechanic_model_selections', + 'mechanic_public_variants', + ]) { + expect( + await ( + await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`) + ).get(), + ).toMatchObject({ count: 0 }); + } + }); +}); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 4a6c62e..10321d6 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -896,6 +896,20 @@ error, or danger indicators. - Tiered records with shared structure use one shared selector and side-by-side value columns rather than duplicating the entire editor. Template-driven copy must show live previews for every tier and document its placeholder syntax. +- Talent, OS Protocol Talent, Skill Chip Talent, and Specialization rank-effect + forms use the shared authored-description editor. Place the exact source text + first because it remains authoritative when no model revision is selected. + Put model controls in a separate bordered section with explicit Preserve, + Save, and Clear choices. Keep fixed units and Attribute wording in the + authored template; every token value is numeric and contains only the number + that changes between variants, while the combat model owns Attribute + semantics. The token matrix + keeps one row per varying authored value and one column per variant so + operators can compare substitutions without duplicating the program editor. + On narrow screens, keep the matrix + horizontally scrollable instead of collapsing labels into ambiguous cards. + Show modeled previews, readiness, assumptions, and validation as text; color + may reinforce these states but must not replace their labels. ### Forms diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index de3284f..d39aa39 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -2216,6 +2216,17 @@ if (legacyProtocolEffects) { await database.exec('DROP TABLE os_protocol_attribute_effects'); } +const assertProposalContributorNoteColumn = async () => { + const columns = await ( + await database.prepare("PRAGMA table_info('data_change_proposals')") + ).all(); + if (!columns.some((column) => column.name === 'contributor_note')) { + throw new Error( + 'Schema migration lost the contributor_note column from data_change_proposals.', + ); + } +}; + const proposalDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", @@ -2235,6 +2246,7 @@ if ( draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, + contributor_note TEXT, 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), @@ -2244,14 +2256,20 @@ if ( reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_expanded - SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,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 + await database.exec(`INSERT INTO data_change_proposals_expanded( + id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + ) + SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id, + draft_entity_id,applied_entity_id,summary,contributor_note,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_expanded RENAME TO data_change_proposals', ); await database.exec('PRAGMA foreign_keys=ON'); + await assertProposalContributorNoteColumn(); } await database.exec( 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', @@ -2282,6 +2300,7 @@ if ( draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, + contributor_note TEXT, 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), @@ -2291,14 +2310,19 @@ if ( reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_weaponmods - 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 + await database.exec(`INSERT INTO data_change_proposals_weaponmods( + id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + ) + SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + 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_weaponmods RENAME TO data_change_proposals', ); await database.exec('PRAGMA foreign_keys=ON'); + await assertProposalContributorNoteColumn(); await database.exec( 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', ); @@ -2329,6 +2353,7 @@ if ( draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, + contributor_note TEXT, 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), @@ -2338,14 +2363,19 @@ if ( 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 + await database.exec(`INSERT INTO data_change_proposals_specializations( + id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + ) + SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + 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 assertProposalContributorNoteColumn(); await database.exec( 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', ); @@ -2376,6 +2406,7 @@ if ( draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, + contributor_note TEXT, 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), @@ -2385,14 +2416,19 @@ if ( reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_calculations - 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 + await database.exec(`INSERT INTO data_change_proposals_calculations( + id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + ) + SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + 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_calculations RENAME TO data_change_proposals', ); await database.exec('PRAGMA foreign_keys=ON'); + await assertProposalContributorNoteColumn(); await database.exec( 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', ); @@ -2427,6 +2463,7 @@ if ( draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, + contributor_note TEXT, 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), @@ -2436,11 +2473,14 @@ if ( reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_source_owned + await database.exec(`INSERT INTO data_change_proposals_source_owned( + id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note, + payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + ) SELECT id, CASE WHEN module='triggers' THEN 'talents' ELSE module END, CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action END, - entity_id,draft_entity_id,applied_entity_id,summary,payload_json, + entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,payload_json, CASE WHEN module='triggers' AND status='pending' THEN 'failed' ELSE status END, proposed_by,reviewed_by, CASE @@ -2466,6 +2506,7 @@ if ( } finally { await database.exec('PRAGMA foreign_keys=ON'); } + await assertProposalContributorNoteColumn(); await database.exec( 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', ); diff --git a/src/app.css b/src/app.css index 0210a06..998f9fc 100644 --- a/src/app.css +++ b/src/app.css @@ -2818,6 +2818,24 @@ a.secondary:active { font-size: var(--shd-type-telemetry); line-height: 1.35; } +.talent-icon-identity .game-data-render-upload .image-drop-input { + grid-template-columns: 1fr; + gap: 6px; + text-align: center; +} +.talent-icon-identity .game-data-render-upload .image-upload-preview { + width: 100%; +} +.talent-icon-identity .game-data-render-upload .image-upload-actions { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; +} +.talent-icon-identity .game-data-render-upload .image-upload-actions button { + min-width: 0; + padding: 7px 3px; + font-size: var(--shd-type-telemetry); +} .dashboard-row > .registry-render-identity { display: flex; align-items: center; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 623b6a8..86716b1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,6 +8,7 @@ import { } from '$lib/server/site-access'; import { DATA_MODULES, + ProposalPayloadValidationError, proposalActionResult, queueDataProposal, shouldModerateDataMutation, @@ -82,12 +83,30 @@ export const handle: Handle = async ({ event, resolve }) => { ); } const submittedForm = await event.request.formData(); - const proposal = await queueDataProposal( - user, - module, - action, - submittedForm, - ); + let proposal: Awaited>; + try { + proposal = await queueDataProposal( + user, + module, + action, + submittedForm, + ); + } catch (error) { + if (!(error instanceof ProposalPayloadValidationError)) throw error; + if ( + event.request.headers.get('accept')?.includes('application/json') + ) { + return json( + { + type: 'failure', + status: 413, + data: { proposalError: error.message }, + }, + { status: 413 }, + ); + } + return new Response(error.message, { status: 413 }); + } if (event.request.headers.get('accept')?.includes('application/json')) { const result = proposalActionResult( module, diff --git a/src/lib/calculations/mechanic-authored-description.test.ts b/src/lib/calculations/mechanic-authored-description.test.ts index 6fde376..8de3655 100644 --- a/src/lib/calculations/mechanic-authored-description.test.ts +++ b/src/lib/calculations/mechanic-authored-description.test.ts @@ -9,14 +9,14 @@ import { const talentSource = (): MechanicAuthoredSource => ({ version: 1, owner: { type: 'talent', id: 'talent-1', scope: 'standard' }, - template: 'Gain {1} for {duration}. Gain {1} again, up to {max_stacks}.', + template: 'Gain {1}% for {duration} seconds. Gain {1}% again, up to {max_stacks}.', variants: ['I', 'II'].map((key, index) => ({ key, materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`, slots: [ { key: 'max_stacks', value: '3', order: 2 }, - { key: '1', value: `${10 + index}%`, order: 0, unit: 'percent' }, - { key: 'duration', value: '5 seconds', order: 1, unit: 'seconds' }, + { key: '1', value: `${10 + index}`, order: 0, unit: 'percent' }, + { key: 'duration', value: '5', order: 1, unit: 'seconds' }, ], })), }); @@ -59,11 +59,11 @@ describe('mechanic authored descriptions', () => { const source: MechanicAuthoredSource = { version: 1, owner, - template: 'Gain {1}.', + template: 'Gain {1}%.', variants: keys.map((key) => ({ key, materialized: 'Gain 10%.', - slots: [{ key: '1', value: '10%', order: 0 }], + slots: [{ key: '1', value: '10', order: 0 }], })), }; expect(materializeMechanicAuthoredDescription(source).ok).toBe(true); @@ -73,12 +73,12 @@ describe('mechanic authored descriptions', () => { const result = materializeMechanicAuthoredDescription({ version: 1, owner: { type: 'specialization_tree_rank', id: 'rank-1' }, - template: 'Gain {1}.', + template: 'Gain {1}%.', variants: [ { key: 'base', materialized: 'Gain 10%.', - slots: [{ key: '1', value: '10%', order: 0 }], + slots: [{ key: '1', value: '10', order: 0 }], }, ], }); @@ -106,7 +106,7 @@ describe('mechanic authored descriptions', () => { injected.variants[0].slots.find((slot) => slot.key === '1')!.value = '{raw}'; injected.variants[0].materialized = - 'Gain {raw} for 5 seconds. Gain {raw} again, up to 3.'; + 'Gain {raw}% for 5 seconds. Gain {raw}% again, up to 3.'; expect(materializeMechanicAuthoredDescription(injected)).toMatchObject({ ok: false, errors: expect.arrayContaining([ @@ -116,10 +116,21 @@ describe('mechanic authored descriptions', () => { }); }); + it('rejects units embedded in authored slot values', () => { + const source = talentSource(); + source.variants[0].slots.find((slot) => slot.key === '1')!.value = '10%'; + expect(materializeMechanicAuthoredDescription(source)).toMatchObject({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_slot' }), + ]), + }); + }); + it('rejects unknown, unresolved, unused, and mismatched authored values', () => { const source = talentSource(); - source.template = 'Gain {1} for {cooldown}.'; - source.variants[0].slots.push({ key: '2', value: 'unused', order: 4 }); + source.template = 'Gain {1}% for {cooldown} seconds.'; + source.variants[0].slots.push({ key: '2', value: '4', order: 4 }); const result = materializeMechanicAuthoredDescription(source); expect(result.ok).toBe(false); if (result.ok) return; @@ -149,11 +160,11 @@ describe('mechanic authored descriptions', () => { it('preserves exact authored whitespace and materialized prose', () => { const source = talentSource(); - source.template = ' Gain {1}.\nNext line. '; + source.template = ' Gain {1}%.\nNext line. '; for (const variant of source.variants) { const value = variant.slots.find((slot) => slot.key === '1')?.value; variant.slots = variant.slots.filter((slot) => slot.key === '1'); - variant.materialized = ` Gain ${value}.\nNext line. `; + variant.materialized = ` Gain ${value}%.\nNext line. `; } const result = materializeMechanicAuthoredDescription(source); expect(result.ok && result.variants[0].text).toBe( diff --git a/src/lib/calculations/mechanic-authored-description.ts b/src/lib/calculations/mechanic-authored-description.ts index 9b4b035..7b688d1 100644 --- a/src/lib/calculations/mechanic-authored-description.ts +++ b/src/lib/calculations/mechanic-authored-description.ts @@ -84,6 +84,7 @@ export type MechanicAuthoredFingerprintPayload = { }; const TOKEN_PATTERN = /\{([^{}]+)\}/g; +const NUMERIC_SLOT_VALUE_PATTERN = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/; const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']); const objectValue = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -209,15 +210,14 @@ function materializeKnownMechanicAuthoredDescription( !isAllowedToken(slot.key) || !Number.isInteger(slot.order) || slot.order < 0 || - !slot.value.trim() || - /[{}]/.test(slot.value) || + !NUMERIC_SLOT_VALUE_PATTERN.test(slot.value) || slots.has(slot.key) || slotOrders.has(slot.order) ) { errors.push({ code: 'invalid_slot', path: `${path}.slots[${slotIndex}]`, - message: `Authored slot ${slot.key || '(empty)'} is invalid or duplicated.`, + message: `Authored slot ${slot.key || '(empty)'} must contain a plain numeric value with a unique key and order.`, }); continue; } diff --git a/src/lib/calculations/mechanic-lifecycle.test.ts b/src/lib/calculations/mechanic-lifecycle.test.ts index b0bb146..d714c05 100644 --- a/src/lib/calculations/mechanic-lifecycle.test.ts +++ b/src/lib/calculations/mechanic-lifecycle.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { canonicalMechanicProgramJson, classifyMechanicLifecycle, + classifyMechanicVariantLifecycle, hashMechanicAuthoredSource, hashMechanicProgram, + hashMechanicProgramVariants, mechanicProgramSemanticPayload, type MechanicLifecycleInput, type MechanicModelRevision, @@ -396,16 +398,90 @@ describe('Mechanic Program lifecycle and semantic hashing', () => { ); }); + it('hashes ordered program variants as one revision graph', async () => { + const tierOne = program(); + const tierTwo = program(); + tierTwo.clauses[0].outcomes![0].value = 10; + + const forward = await hashMechanicProgramVariants({ + simulationReadiness: 'ready', + variants: [ + { key: 'I', label: 'Tier I', program: tierOne }, + { key: 'II', label: 'Tier II', program: tierTwo }, + ], + }); + const sameSemantics = await hashMechanicProgramVariants({ + simulationReadiness: 'ready', + variants: [ + { key: 'I', label: 'Renamed display label', program: tierOne }, + { key: 'II', label: 'Tier II', program: tierTwo }, + ], + }); + const reversed = await hashMechanicProgramVariants({ + simulationReadiness: 'ready', + variants: [ + { key: 'II', label: 'Tier II', program: tierTwo }, + { key: 'I', label: 'Tier I', program: tierOne }, + ], + }); + + expect(forward).toBe(sameSemantics); + expect(reversed).not.toBe(forward); + }); + + it('classifies the complete selected variant graph and fails closed when one variant is invalid', async () => { + const variants = [ + { key: 'I', label: 'Tier I', program: program() }, + { key: 'II', label: 'Tier II', program: program() }, + ]; + variants[1].program.clauses[0].outcomes![0].value = 10; + const programHash = await hashMechanicProgramVariants({ + simulationReadiness: 'ready', + variants, + }); + + await expect( + classifyMechanicVariantLifecycle({ + currentSourceFingerprint: 'a'.repeat(64), + resolveAttributeLabel: (attributeId) => attributeId, + selectedRevision: { + variants, + sourceFingerprint: 'a'.repeat(64), + programHash, + mechanicContractVersion: 1, + simulationReadiness: 'ready', + }, + }), + ).resolves.toMatchObject({ status: 'current', programHash }); + + variants[1].program.pools = [ + { key: 'bad', label: 'Bad', scope: 'party' as 'agent', initial: 0 }, + ]; + await expect( + classifyMechanicVariantLifecycle({ + currentSourceFingerprint: 'a'.repeat(64), + resolveAttributeLabel: (attributeId) => attributeId, + selectedRevision: { + variants, + sourceFingerprint: 'a'.repeat(64), + programHash, + mechanicContractVersion: 1, + simulationReadiness: 'ready', + }, + }), + ).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' }); + }); + it('hashes a valid authored source and rejects unresolved source values', async () => { const source = { version: 1 as const, owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' }, - template: 'Gain {1}.', + template: 'Gain {1}%.', variants: [ { key: 'base', materialized: 'Gain 10%.', - slots: [{ key: '1', value: '10%', order: 0 }], + slots: [{ key: '1', value: '10', order: 0 }], }, ], }; diff --git a/src/lib/calculations/mechanic-lifecycle.ts b/src/lib/calculations/mechanic-lifecycle.ts index 2765d22..2219262 100644 --- a/src/lib/calculations/mechanic-lifecycle.ts +++ b/src/lib/calculations/mechanic-lifecycle.ts @@ -8,6 +8,7 @@ import { import { renderPublicMechanicPrograms, type MechanicAttributeLabelResolver, + type MechanicPublicProgramVariant, } from './mechanic-public-renderer.js'; import { validateMechanicProgram, @@ -29,6 +30,8 @@ import { /** The version of the canonical Mechanic Program hash contract. */ export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const; export const MECHANIC_PROGRAM_HASH_KIND = 'mechanic_program' as const; +export const MECHANIC_PROGRAM_VARIANTS_HASH_KIND = + 'mechanic_program_variants' as const; export type MechanicLifecycle = 'absent' | 'stale' | 'current'; export type MechanicAuthoredFingerprintResult = @@ -72,6 +75,11 @@ export type MechanicProgramHashSource = MechanicProgram & { export type MechanicProgramHashInput = MechanicProgramHashSource; +export type MechanicProgramVariantsHashInput = { + simulationReadiness: MechanicSimulationReadiness; + variants: MechanicPublicProgramVariant[]; +}; + export type MechanicModelRevision = { program: MechanicProgram | null; sourceFingerprint: string; @@ -88,6 +96,20 @@ export type MechanicLifecycleInput = { supportedMechanicContractVersion?: number; }; +export type MechanicModelVariantRevision = Omit< + MechanicModelRevision, + 'program' | 'sourceVariants' +> & { + variants: MechanicPublicProgramVariant[]; +}; + +export type MechanicVariantLifecycleInput = Omit< + MechanicLifecycleInput, + 'selectedRevision' +> & { + selectedRevision: MechanicModelVariantRevision | null; +}; + export type MechanicLifecycleReason = | 'no_selected_revision' | 'empty_program' @@ -584,6 +606,29 @@ export async function hashMechanicProgram( return sha256Utf8(canonicalMechanicProgramJson(input)); } +/** + * Hashes one owner revision across its explicit ordered program variants. + * Variant labels are presentation metadata; keys, order, and program semantics + * are revision data. + */ +export async function hashMechanicProgramVariants( + input: MechanicProgramVariantsHashInput, +): Promise { + return sha256Utf8( + stableJson( + canonicalizeValue({ + kind: MECHANIC_PROGRAM_VARIANTS_HASH_KIND, + version: MECHANIC_PROGRAM_HASH_VERSION, + simulationReadiness: input.simulationReadiness, + variants: input.variants.map((variant) => ({ + key: variant.key, + program: mechanicProgramSemanticPayload(variant.program), + })), + }), + ), + ); +} + const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; function lifecycleAbsent( @@ -692,3 +737,109 @@ export async function classifyMechanicLifecycle( simulationReadiness: revision.simulationReadiness, }; } + +/** + * Classifies a selected owner revision as one unit across every required + * program variant. One malformed or unrenderable variant fails the complete + * revision closed. + */ +export async function classifyMechanicVariantLifecycle( + input: MechanicVariantLifecycleInput, +): Promise { + const revision = input.selectedRevision; + if (!revision || revision.variants.length === 0) { + return lifecycleAbsent('no_selected_revision'); + } + + for (const variant of revision.variants) { + let validation; + try { + validation = validateMechanicProgram(variant.program); + } catch { + return lifecycleAbsent('invalid_program', [ + `Mechanic variant ${variant.key} could not be validated safely.`, + ]); + } + if (!validation.ok) { + return lifecycleAbsent( + 'invalid_program', + validation.errors.map((error) => `${variant.key}: ${error}`), + ); + } + } + + if (revision.variants.every((variant) => variant.program.clauses.length === 0)) { + return lifecycleAbsent( + revision.simulationReadiness === 'captured_only' + ? 'empty_captured_program' + : 'empty_program', + ); + } + if (revision.variants.some((variant) => variant.program.clauses.length === 0)) { + return lifecycleAbsent('invalid_program', [ + 'Every mechanic variant must contain the selected revision graph.', + ]); + } + + const rendering = renderPublicMechanicPrograms(revision.variants, { + resolveAttributeLabel: input.resolveAttributeLabel, + }); + if (!rendering.ok) { + return lifecycleAbsent( + 'unrenderable_program', + rendering.errors.map((error) => error.message), + ); + } + + if ( + !['ready', 'requires_assumptions', 'captured_only'].includes( + revision.simulationReadiness, + ) + ) { + return { status: 'stale', reason: 'unsupported_contract' }; + } + const supportedContractVersion = + input.supportedMechanicContractVersion ?? MECHANIC_PROGRAM_HASH_VERSION; + if ( + revision.mechanicContractVersion !== supportedContractVersion || + !Number.isInteger(revision.mechanicContractVersion) || + revision.mechanicContractVersion <= 0 + ) { + return { status: 'stale', reason: 'unsupported_contract' }; + } + + if ( + !SHA256_HEX_PATTERN.test(revision.sourceFingerprint) || + !SHA256_HEX_PATTERN.test(input.currentSourceFingerprint) + ) { + return lifecycleAbsent('invalid_program', [ + 'The selected revision and authored source require lowercase SHA-256 fingerprints.', + ]); + } + if (revision.sourceFingerprint !== input.currentSourceFingerprint) { + return { status: 'stale', reason: 'fingerprint_mismatch' }; + } + if (!SHA256_HEX_PATTERN.test(revision.programHash)) { + return lifecycleAbsent('invalid_program_hash'); + } + + let programHash: string; + try { + programHash = await hashMechanicProgramVariants({ + simulationReadiness: revision.simulationReadiness, + variants: revision.variants, + }); + } catch { + return lifecycleAbsent('hash_unavailable'); + } + if (programHash !== revision.programHash) { + return lifecycleAbsent('invalid_program_hash'); + } + + return { + status: 'current', + fingerprint: input.currentSourceFingerprint, + programHash, + simulationReadiness: revision.simulationReadiness, + }; +} diff --git a/src/lib/components/ProposalChangePreview.helpers.test.ts b/src/lib/components/ProposalChangePreview.helpers.test.ts new file mode 100644 index 0000000..bd2effa --- /dev/null +++ b/src/lib/components/ProposalChangePreview.helpers.test.ts @@ -0,0 +1,1020 @@ +import { describe, expect, it } from 'vitest'; +import { + formatMechanicValue, + normalizeMechanicPreview, + normalizePhase2OwnerPreview, + parseProposalPreviewPayload, + safePreviewValue, + isPreviewFieldHidden, +} from './ProposalChangePreview.helpers.js'; + +const payload = (fields: Record) => + JSON.stringify( + Object.entries(fields).map(([name, value]) => ({ + name, + kind: 'text', + value, + })), + ); + +it('normalizes duplicate scalar fields with first-value-wins semantics', () => { + const result = parseProposalPreviewPayload( + JSON.stringify([ + { name: 'name', kind: 'text', value: 'first value' }, + { name: 'name', kind: 'text', value: 'last value' }, + { name: 'id', kind: 'text', value: 'canonical-id' }, + { name: 'id', kind: 'text', value: 'spoofed-id' }, + ]), + ); + + expect(result.fields).toEqual({ + name: 'first value', + id: 'canonical-id', + }); + expect(result.entries).toHaveLength(4); +}); + +const source = (owner: string, template = 'Gain {1}% damage.') => ({ + id: `${owner}-source`, + owner: { type: owner, id: `${owner}-1` }, + version: 1, + template, + variants: [ + { + key: 'I', + materialized: template.replace('{1}', '10'), + slots: [ + { + key: '1', + value: '10', + order: 0, + unit: '%', + attributeId: 'weapon-damage', + }, + ], + }, + ], +}); + +const revision = (fingerprint = 'source-fingerprint') => ({ + id: 'revision-1', + revisionNumber: 3, + sourceFingerprint: fingerprint, + simulationReadiness: 'ready', + scenarioInputs: [ + { key: 'active', label: 'Active', kind: 'toggle', readiness: 'ready' }, + ], + pools: [ + { key: 'stacks', label: 'Stacks', scope: 'self', initial: 0, max: 3 }, + ], + clauses: [ + { + key: 'primary', + label: 'Primary', + event: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], + outcomes: [ + { + key: 'damage', + label: 'Damage', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + basis: 'flat', + attributeId: 'weapon-damage', + variants: { I: { value: 10 } }, + }, + ], + }, + ], +}); + +const projection = ( + fingerprint = 'source-fingerprint', + selectedRevisionId = 'revision-1', +) => ({ + selectedRevisionId, + variants: [ + { + key: 'I', + status: 'modeled', + displaySource: 'model', + behaviorText: 'Gain 10% damage.', + sourceFingerprint: fingerprint, + }, + ], +}); + +const mechanicSnapshot = ( + owner: string, + fingerprint = 'source-fingerprint', +) => ({ + authoredSource: { ...source(owner), sourceFingerprint: fingerprint }, + selectedRevision: revision(fingerprint), + publicProjection: projection(fingerprint), +}); + +it('normalizes authored source order and a complete current model graph', () => { + const result = normalizeMechanicPreview({ + authoredSource: mechanicSnapshot('talent').authoredSource, + selectedRevision: revision(), + publicProjection: projection(), + }); + + expect(result.authoredSource?.variants[0]).toMatchObject({ + key: 'I', + materialized: 'Gain 10% damage.', + }); + expect(result.authoredSource?.variants[0].slots[0]).toMatchObject({ + key: '1', + value: '10', + order: 0, + unit: '%', + attributeId: 'weapon-damage', + }); + expect(result.selectedRevision).toMatchObject({ + revisionNumber: 3, + readiness: 'ready', + scenarioInputs: [{ key: 'active' }], + pools: [{ key: 'stacks' }], + clauses: [{ key: 'primary', outcomes: [{ key: 'damage' }] }], + }); + expect(result.selectedRevisionStatus).toBe('current'); + expect(result.projection).toMatchObject({ + status: 'current', + selectedRevisionId: 'revision-1', + }); +}); + +it('marks stale, absent, and current public projections without exposing fingerprints', () => { + const stale = normalizeMechanicPreview({ + authoredSource: mechanicSnapshot('talent', 'new-fingerprint') + .authoredSource, + selectedRevision: revision('old-fingerprint'), + publicProjection: projection('old-fingerprint'), + }); + expect(stale.selectedRevisionStatus).toBe('stale'); + expect(stale.projection.status).toBe('stale'); + expect(JSON.stringify(stale)).not.toContain('fingerprint'); + + const staleProjection = normalizeMechanicPreview({ + authoredSource: mechanicSnapshot('talent', 'same-fingerprint') + .authoredSource, + selectedRevision: revision('same-fingerprint'), + publicProjection: projection('old-fingerprint'), + }); + expect(staleProjection.selectedRevisionStatus).toBe('current'); + expect(staleProjection.projection.status).toBe('stale'); + + const explicitStale = normalizeMechanicPreview({ + authoredSource: source('talent'), + selectedRevision: revision(), + publicProjection: { + status: 'stale', + selectedRevisionId: 'revision-1', + variants: [ + { + key: 'I', + status: 'not_modeled', + displaySource: 'authored', + behaviorText: 'Authored text.', + }, + ], + }, + }); + expect(explicitStale.projection.status).toBe('stale'); + + const explicitAbsent = normalizeMechanicPreview({ + authoredSource: source('talent'), + selectedRevision: revision(), + publicProjection: { + status: 'absent', + selectedRevisionId: 'revision-1', + variants: [ + { + key: 'I', + status: 'modeled', + displaySource: 'model', + behaviorText: 'Should not render.', + }, + ], + }, + }); + expect(explicitAbsent.projection.status).toBe('absent'); + + const nestedVariantFingerprint = normalizeMechanicPreview({ + authoredSource: { + ...source('talent'), + sourceFingerprint: 'new-fingerprint', + }, + selectedRevision: revision('new-fingerprint'), + publicProjection: { + selectedRevisionId: 'revision-1', + variants: [ + { + key: 'I', + status: 'modeled', + displaySource: 'model', + behaviorText: 'Text.', + payload: { currentSourceFingerprint: 'old-fingerprint' }, + }, + ], + }, + }); + expect(nestedVariantFingerprint.projection.status).toBe('stale'); + + const nested = normalizeMechanicPreview({ + authoredSource: { + source: source('talent'), + sourceFingerprint: 'same-fingerprint', + }, + selectedRevision: { + ...revision('same-fingerprint'), + source: { sourceFingerprint: 'same-fingerprint' }, + }, + publicProjection: { + source: { sourceFingerprint: 'same-fingerprint' }, + selectedRevisionId: 'revision-1', + variants: [ + { + key: 'I', + status: 'modeled', + displaySource: 'model', + behaviorText: 'Text.', + }, + ], + }, + }); + expect(nested.selectedRevisionStatus).toBe('current'); + expect(nested.projection.status).toBe('current'); + + const absent = normalizeMechanicPreview({ + authoredSource: source('talent'), + selectedRevision: null, + publicProjection: null, + }); + expect(absent.selectedRevisionStatus).toBe('absent'); + expect(absent.projection.status).toBe('absent'); + + const currentFallback = normalizeMechanicPreview({ + authoredSource: source('talent'), + selectedRevision: null, + publicProjection: { + selectedRevisionId: null, + variants: [ + { + key: 'I', + status: 'not_modeled', + displaySource: 'authored', + behaviorText: 'Authored text.', + }, + ], + }, + }); + expect(currentFallback.projection).toMatchObject({ + status: 'current', + variants: [{ displaySource: 'authored' }], + }); + expect( + safePreviewValue('expectedSourceFingerprint', 'private-fingerprint'), + ).toContain('OMITTED'); + expect( + safePreviewValue( + 'details', + JSON.stringify({ + programHash: 'private-hash', + nested: JSON.stringify({ privateKey: 'private-key', visible: 'kept' }), + }), + ), + ).not.toContain('private-key'); + expect(safePreviewValue('apiKey', 'private-api-key')).toContain('OMITTED'); + expect( + formatMechanicValue( + JSON.stringify({ encryptionKey: 'private-key', visible: 'kept' }), + ), + ).not.toContain('private-key'); +}); + +describe('phase 2 owner families', () => { + it.each([ + [ + 'talents', + 'saveTalent', + { + id: 'talent-1', + name: 'Talent', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source('talent')), + }, + ], + [ + 'protocols', + 'save', + { + id: 'protocol-1', + name: 'Protocol', + quality: 'Standard', + attributeType: 'Offensive', + hasTalent: '1', + talentName: 'Protocol Talent', + authoredSource: JSON.stringify(source('os_protocol_talent')), + }, + ], + [ + 'skillchips', + 'save', + { + id: 'chip-1', + name: 'Chip', + abilityId: 'ability-1', + twoPieceStatId: 'weapon-damage', + twoPieceMin: '1', + twoPieceMax: '2', + hasTalent: '1', + talentName: 'Chip Talent', + authoredSource: JSON.stringify(source('skill_chip_talent')), + }, + ], + ])( + 'normalizes the %s owner with authored and model sections', + (module, action, fields) => { + const currentSnapshot = + module === 'talents' + ? mechanicSnapshot('talent') + : { + record: { id: fields.id, name: fields.name }, + mechanic: + module === 'protocols' + ? JSON.stringify(mechanicSnapshot('os_protocol_talent')) + : mechanicSnapshot('skill_chip_talent'), + }; + const result = normalizePhase2OwnerPreview( + module, + action, + payload(fields), + currentSnapshot, + ); + expect(result?.family).toBe( + module === 'talents' + ? 'talent' + : module === 'protocols' + ? 'protocol-talent' + : 'skill-chip-talent', + ); + expect(result?.currentMechanic.authoredSource?.template).toBe( + 'Gain {1}% damage.', + ); + expect( + result?.proposedMechanic.authoredSource?.variants[0].slots[0].value, + ).toBe('10'); + }, + ); +}); + +it('uses persisted Phase 2 target IDs for edits instead of submitted identities', () => { + const cases: Array<{ + module: string; + action: string; + targetId: string; + currentSnapshot: Record; + fields: Record; + }> = [ + { + module: 'talents', + action: 'saveTalent', + targetId: 'talent-live', + currentSnapshot: { + record: { id: 'talent-live' }, + ...mechanicSnapshot('talent'), + }, + fields: { + id: 'payload-target', + _proposedEntityId: 'payload-draft', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify({ + ...source('talent'), + owner: { type: 'talent', id: 'payload-owner' }, + }), + }, + }, + { + module: 'protocols', + action: 'save', + targetId: 'protocol-live', + currentSnapshot: { + record: { id: 'protocol-live' }, + mechanic: mechanicSnapshot('os_protocol_talent'), + }, + fields: { + id: 'payload-target', + authoredSource: JSON.stringify({ + ...source('os_protocol_talent'), + owner: { type: 'os_protocol_talent', id: 'payload-owner' }, + }), + hasTalent: '1', + }, + }, + { + module: 'skillchips', + action: 'save', + targetId: 'skill-chip-live', + currentSnapshot: { + record: { id: 'skill-chip-live' }, + mechanic: mechanicSnapshot('skill_chip_talent'), + }, + fields: { + id: 'payload-target', + authoredSource: JSON.stringify({ + ...source('skill_chip_talent'), + owner: { type: 'skill_chip_talent', id: 'payload-owner' }, + }), + hasTalent: '1', + }, + }, + { + module: 'specializations', + action: 'saveNode', + targetId: 'node-live', + currentSnapshot: { record: { id: 'node-live' } }, + fields: { id: 'payload-target', ranksJson: '[]' }, + }, + { + module: 'specializations', + action: 'saveCombatEffect', + targetId: 'effect-live', + currentSnapshot: { record: { id: 'effect-live' }, attachments: [] }, + fields: { id: 'payload-target', descriptionTemplate: 'Apply {1} armor.' }, + }, + ]; + + for (const item of cases) { + const result = normalizePhase2OwnerPreview( + item.module, + item.action, + payload(item.fields), + item.currentSnapshot, + ); + expect(result?.canonicalTargetId).toBe(item.targetId); + expect(JSON.stringify(result)).not.toContain('payload-target'); + expect(JSON.stringify(result)).not.toContain('payload-draft'); + expect(JSON.stringify(result)).not.toContain('payload-owner'); + } + + const talent = normalizePhase2OwnerPreview( + 'talents', + 'saveTalent', + payload(cases[0].fields), + cases[0].currentSnapshot, + ); + expect(talent?.proposedMechanic.authoredSource?.ownerId).toBe('talent-live'); +}); + +it('leaves new Phase 2 targets unassigned rather than displaying submitted IDs', () => { + const cases: Array<{ + module: string; + action: string; + fields: Record; + }> = [ + { + module: 'talents', + action: 'saveTalent', + fields: { + id: 'payload-target', + _proposedEntityId: 'payload-draft', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify({ + ...source('talent'), + owner: { type: 'talent', id: 'payload-owner' }, + }), + }, + }, + { + module: 'protocols', + action: 'save', + fields: { + id: 'payload-target', + _proposedEntityId: 'payload-draft', + hasTalent: '1', + authoredSource: JSON.stringify({ + ...source('os_protocol_talent'), + owner: { type: 'os_protocol_talent', id: 'payload-owner' }, + }), + }, + }, + { + module: 'skillchips', + action: 'save', + fields: { + id: 'payload-target', + _proposedEntityId: 'payload-draft', + hasTalent: '1', + authoredSource: JSON.stringify({ + ...source('skill_chip_talent'), + owner: { type: 'skill_chip_talent', id: 'payload-owner' }, + }), + }, + }, + { + module: 'specializations', + action: 'saveNode', + fields: { + id: 'payload-target', + _proposedEntityId: 'payload-draft', + ranksJson: JSON.stringify([ + { + rank: 1, + combatEffects: [ + { + id: 'payload-attachment', + nodeId: 'payload-node', + effectId: 'payload-effect', + authoredSource: { + ...source('specialization_tree_rank'), + owner: { + type: 'specialization_tree_rank', + id: 'payload-owner', + }, + }, + }, + ], + }, + ]), + }, + }, + { + module: 'specializations', + action: 'saveCombatEffect', + fields: { + id: 'payload-target', + _proposedEntityId: 'payload-draft', + descriptionTemplate: 'Apply {1} armor.', + }, + }, + ]; + + for (const item of cases) { + const result = normalizePhase2OwnerPreview( + item.module, + item.action, + payload(item.fields), + null, + ); + expect(result?.canonicalTargetId).toBeNull(); + expect(JSON.stringify(result)).not.toContain('payload-target'); + expect(JSON.stringify(result)).not.toContain('payload-draft'); + expect(JSON.stringify(result)).not.toContain('payload-owner'); + } + + const node = normalizePhase2OwnerPreview( + 'specializations', + 'saveNode', + payload(cases[3].fields), + null, + ); + expect(node?.proposedAttachments[0]).toMatchObject({ + attachmentId: 'SERVER-ASSIGNED ON APPROVAL · ATTACHMENT RANK 1 · POSITION 1', + nodeId: 'ASSIGNED ON APPROVAL', + }); +}); + +it('normalizes serialized talent graph fallback metadata', () => { + const graphRevision = revision('old-fingerprint'); + const result = normalizePhase2OwnerPreview( + 'talents', + 'saveTalent', + payload({ + id: 'talent-1', + name: 'Talent', + authoredSource: JSON.stringify(source('talent')), + }), + { + authoredSource: JSON.stringify({ + ...source('talent'), + sourceFingerprint: 'new-fingerprint', + }), + selectedRevision: null, + graph: JSON.stringify({ + revisionNumber: graphRevision.revisionNumber, + sourceFingerprint: graphRevision.sourceFingerprint, + simulationReadiness: graphRevision.simulationReadiness, + scenarioInputs: JSON.stringify(graphRevision.scenarioInputs), + pools: JSON.stringify(graphRevision.pools), + clauses: JSON.stringify(graphRevision.clauses), + }), + publicProjection: JSON.stringify(projection('old-fingerprint')), + }, + ); + expect(result?.currentMechanic.selectedRevision).toMatchObject({ + id: 'revision-1', + revisionNumber: 3, + readiness: 'ready', + }); + expect(result?.currentMechanic.selectedRevisionStatus).toBe('stale'); +}); + +it('normalizes specialization attachment IDs and reusable effect fan-out', () => { + const attachmentSource = source( + 'specialization_tree_rank', + 'Apply {1} armor.', + ); + const currentAttachmentSnapshot = mechanicSnapshot( + 'specialization_tree_rank', + ); + currentAttachmentSnapshot.authoredSource = { + ...currentAttachmentSnapshot.authoredSource, + template: 'Apply {1} armor and {2} health.', + variants: [ + { + ...currentAttachmentSnapshot.authoredSource.variants[0], + materialized: 'Apply 10 armor and 20 health.', + slots: [ + { + ...currentAttachmentSnapshot.authoredSource.variants[0].slots[0], + key: '1', + value: '10', + order: 0, + }, + { + ...currentAttachmentSnapshot.authoredSource.variants[0].slots[0], + key: '2', + value: '20', + order: 1, + }, + ], + }, + ], + }; + const nodeFields = { + id: 'node-1', + name: 'Node', + focusId: 'focus-1', + rowNumber: '2', + columnNumber: '3', + maxRank: '1', + ranksJson: JSON.stringify([ + { + rank: 1, + description: 'Rank one.', + combatEffects: [ + { + attachment_id: 'attachment-1', + effectId: 'effect-1', + authoredSource: attachmentSource, + selectionIntent: 'clear', + }, + ], + }, + ]), + }; + const node = normalizePhase2OwnerPreview( + 'specializations', + 'saveNode', + payload(nodeFields), + { + record: { id: 'node-1' }, + combatEffects: JSON.stringify([ + { + attachment_id: 'attachment-1', + node_id: 'node-1', + rank: 1, + effect_order: 0, + combat_effect_id: 'effect-1', + }, + ]), + authoredMechanics: JSON.stringify([ + JSON.stringify({ + attachment_id: 'attachment-1', + ...currentAttachmentSnapshot, + }), + ]), + }, + ); + expect(node?.family).toBe('specialization-node'); + expect(node?.currentAttachments).toMatchObject([ + { attachmentId: 'attachment-1', effectId: 'effect-1' }, + ]); + expect(node?.proposedAttachments).toMatchObject([ + { + attachmentId: 'attachment-1', + nodeId: 'node-1', + rank: 1, + effectId: 'effect-1', + }, + ]); + + const fanOut = normalizePhase2OwnerPreview( + 'specializations', + 'saveCombatEffect', + payload({ + id: 'effect-1', + name: 'Shared Effect', + specializationId: 'spec-1', + descriptionTemplate: 'Apply {1} armor and {2} health.', + }), + { + record: { + id: 'effect-1', + name: 'Shared Effect', + specialization_id: 'spec-1', + description_template: 'Apply {1} armor.', + }, + attachments: [ + { + attachment_id: 'attachment-1', + node_id: 'node-1', + rank: 1, + effect_order: 0, + combat_effect_id: 'effect-1', + }, + { + attachment_id: 'attachment-2', + node_id: 'node-2', + rank: 2, + effect_order: 1, + combat_effect_id: 'effect-1', + }, + ], + authoredMechanics: [ + { + attachment_id: 'attachment-2', + authoredSource: null, + selectedRevision: null, + publicProjection: null, + }, + { + attachment_id: 'attachment-1', + ...currentAttachmentSnapshot, + }, + ], + }, + ); + expect(fanOut?.family).toBe('combat-effect'); + expect(fanOut?.proposedAttachments).toHaveLength(2); + expect(fanOut?.proposedAttachments[1]).toMatchObject({ + attachmentId: 'attachment-2', + nodeId: 'node-2', + }); + expect(fanOut?.proposedAttachments[0]).toMatchObject({ + attachmentId: 'attachment-1', + mechanic: { + authoredSource: { + template: 'Apply {1} armor and {2} health.', + variants: [ + { + slots: [ + { key: '1', value: '10' }, + { key: '2', value: '20' }, + ], + }, + ], + }, + }, + }); +}); + +it('uses canonical attachment IDs for edits and server-assigned placeholders for creates', () => { + const result = normalizePhase2OwnerPreview( + 'specializations', + 'saveNode', + payload({ + id: 'node-1', + ranksJson: JSON.stringify([ + { + rank: 1, + description: 'Rank one.', + combatEffects: [ + { + id: 'attachment-1', + effectId: 'effect-1', + authoredSource: source('specialization_tree_rank'), + selectionIntent: 'clear', + }, + { + // A contributor can submit a client-side ID for a new row, but + // it is not an identity the approval save will preserve. + id: 'client-created-attachment', + effectId: 'effect-1', + authoredSource: source('specialization_tree_rank'), + selectionIntent: 'clear', + }, + ], + }, + ]), + }), + { + record: { id: 'node-1' }, + combatEffects: [ + { + id: 'attachment-1', + node_id: 'node-1', + rank: 1, + effect_order: 0, + combat_effect_id: 'effect-1', + }, + ], + authoredMechanics: [ + { + attachment_id: 'attachment-1', + authoredSource: source('specialization_tree_rank'), + selectedRevision: null, + publicProjection: null, + }, + ], + }, + ); + + expect(result?.proposedAttachments.map(({ attachmentId }) => attachmentId)).toEqual([ + 'attachment-1', + 'SERVER-ASSIGNED ON APPROVAL · ATTACHMENT RANK 1 · POSITION 2', + ]); + expect(result?.proposedAttachments.map(({ attachmentId }) => attachmentId)).not.toContain( + 'client-created-attachment', + ); + expect(result?.proposedAttachments[1].mechanic.authoredSource).toMatchObject({ + ownerId: null, + }); +}); + +it('keeps unchanged Talent owner identity current and marks scope or Tier-II changes stale', () => { + const currentSource = { + ...source('talent'), + owner: { type: 'talent', id: 'talent-1', scope: 'standard', hasTierII: true }, + }; + const currentSnapshot = { + ...mechanicSnapshot('talent'), + authoredSource: currentSource, + }; + const unchanged = normalizePhase2OwnerPreview( + 'talents', + 'saveTalent', + payload({ + id: 'talent-1', + category: 'Weapon Talent 1', + tierIIDocumented: '1', + authoredSource: JSON.stringify(source('talent')), + mechanicSelectionIntent: 'preserve', + }), + currentSnapshot, + ); + expect(unchanged?.proposedMechanic.authoredSource).toMatchObject({ + ownerType: 'talent', + ownerId: 'talent-1', + ownerScope: 'standard', + ownerHasTierII: true, + }); + expect(unchanged?.proposedMechanic.selectedRevisionStatus).toBe('current'); + expect(unchanged?.proposedMechanic.projection.status).toBe('current'); + + const changed = normalizePhase2OwnerPreview( + 'talents', + 'saveTalent', + payload({ + id: 'talent-1', + category: 'Exotic Weapon', + tierIIDocumented: '0', + authoredSource: JSON.stringify(source('talent')), + mechanicSelectionIntent: 'preserve', + }), + currentSnapshot, + ); + expect(changed?.proposedMechanic.authoredSource).toMatchObject({ + ownerScope: 'exotic', + ownerHasTierII: false, + }); + expect(changed?.proposedMechanic.selectedRevisionStatus).toBe('stale'); + expect(changed?.proposedMechanic.projection.status).toBe('stale'); +}); + +it('preserves and compares specialization authored identity on attachment mechanics', () => { + const currentSource = { + ...source('specialization_tree_rank', 'Apply {1} armor.'), + owner: { type: 'specialization_tree_rank', id: 'attachment-1' }, + specialization: { + rankDescription: 'Rank one.', + effectId: 'effect-1', + effectTemplate: 'Apply {1} armor.', + }, + }; + const currentSnapshot = { + record: { id: 'node-1' }, + ranks: [{ rank: 1, description: 'Rank one.' }], + combatEffects: [ + { + attachment_id: 'attachment-1', + node_id: 'node-1', + rank: 1, + effect_order: 0, + combat_effect_id: 'effect-1', + }, + ], + authoredMechanics: [ + { + attachmentId: 'attachment-1', + authoredSource: currentSource, + selectedRevision: revision(), + publicProjection: projection(), + }, + ], + }; + const attachmentPayload = (description: string) => + payload({ + id: 'node-1', + focusId: 'focus-1', + ranksJson: JSON.stringify([ + { + rank: 1, + description, + combatEffects: [ + { + id: 'attachment-1', + effectId: 'effect-1', + authoredSource: source( + 'specialization_tree_rank', + 'Apply {1} armor.', + ), + selectionIntent: 'preserve', + }, + ], + }, + ]), + }); + + const unchanged = normalizePhase2OwnerPreview( + 'specializations', + 'saveNode', + attachmentPayload('Rank one.'), + currentSnapshot, + ); + expect(unchanged?.proposedAttachments[0].mechanic.authoredSource).toMatchObject({ + ownerId: 'attachment-1', + specialization: { + rankDescription: 'Rank one.', + effectId: 'effect-1', + effectTemplate: 'Apply {1} armor.', + }, + }); + expect(unchanged?.proposedAttachments[0].mechanic.selectedRevisionStatus).toBe('current'); + + const changed = normalizePhase2OwnerPreview( + 'specializations', + 'saveNode', + attachmentPayload('Rank one changed.'), + currentSnapshot, + ); + expect(changed?.proposedAttachments[0].mechanic.selectedRevisionStatus).toBe('stale'); + expect(changed?.proposedAttachments[0].mechanic.projection.status).toBe('stale'); +}); + +it('redacts normalized nested secret keys without hiding mechanic token labels', () => { + const value = safePreviewValue( + 'details', + JSON.stringify({ + contentHash: 'hash-secret', + nestedSecrets: { + sessionId: 'session-secret', + oauthState: 'oauth-secret', + publicKeyBytes: 'key-secret', + accessToken: 'access-secret', + }, + token: 'damage', + mechanicToken: 'stacks', + visible: 'kept', + }), + ); + expect(value).toContain('kept'); + expect(value).toContain('damage'); + expect(value).toContain('stacks'); + expect(value).not.toContain('secret'); + expect(isPreviewFieldHidden('content-hash')).toBe(true); + expect(isPreviewFieldHidden('sessionId')).toBe(true); + expect(isPreviewFieldHidden('expectedTalentId')).toBe(true); + expect(safePreviewValue('expectedTalentId', 'talent-id')).toContain('OMITTED'); + expect(isPreviewFieldHidden('expectedOwnerUpdatedAt')).toBe(true); + expect( + safePreviewValue('expectedOwnerUpdatedAt', '2026-08-02T12:00:00.000Z'), + ).toContain('OMITTED'); + expect(isPreviewFieldHidden('token')).toBe(false); + expect(isPreviewFieldHidden('mechanic-token')).toBe(false); + expect(isPreviewFieldHidden('hasHealth')).toBe(false); + expect(safePreviewValue('rank', '3')).toBe('3'); + expect(formatMechanicValue({ token: 'damage', accessToken: 'secret' })).toContain( + 'token=damage', + ); + expect(formatMechanicValue({ token: 'damage', accessToken: 'secret' })).not.toContain( + 'secret', + ); +}); + +it('omits malformed structured values instead of returning their raw text', () => { + expect(safePreviewValue('details', ' {"unterminated": ')).toContain( + 'OMITTED', + ); + const nested = safePreviewValue( + 'details', + JSON.stringify({ visible: 'kept', malformed: '[unterminated' }), + ); + expect(nested).toContain('kept'); + expect(nested).not.toContain('unterminated'); + expect(formatMechanicValue('{"unterminated": ')).toBe('NOT SET'); +}); diff --git a/src/lib/components/ProposalChangePreview.helpers.ts b/src/lib/components/ProposalChangePreview.helpers.ts new file mode 100644 index 0000000..4b4e069 --- /dev/null +++ b/src/lib/components/ProposalChangePreview.helpers.ts @@ -0,0 +1,1403 @@ +export type ProposalPreviewEntry = { + name: string; + kind: 'text' | 'file'; + value?: string; + fileName?: string; + type?: string; +}; + +type RecordValue = Record; + +export type AuthoredSlotPreview = { + key: string; + value: string; + order: number; + unit: string | null; + attributeId: string | null; +}; + +export type AuthoredVariantPreview = { + key: string; + materialized: string; + slots: AuthoredSlotPreview[]; +}; + +export type AuthoredSpecializationPreview = { + rankDescription: string | null; + effectId: string | null; + effectTemplate: string | null; +}; + +export type AuthoredSourcePreview = { + id: string | null; + ownerType: string | null; + ownerId: string | null; + ownerScope: string | null; + ownerHasTierII: boolean | null; + version: number | null; + template: string; + variants: AuthoredVariantPreview[]; + specialization: AuthoredSpecializationPreview | null; +}; + +export type ScenarioInputPreview = { + key: string; + label: string; + kind: string; + readiness: string; + description: string | null; + defaultValue: number | boolean | null; + bestCaseValue: number | boolean | null; + min: number | null; + max: number | null; +}; + +export type PoolPreview = { + key: string; + label: string; + stateKind: string; + scope: string; + initial: number | null; + min: number | null; + max: number | null; + bestCaseValue: number | null; + expiresAfterSeconds: number | null; +}; + +export type OutcomeVariantPreview = { + key: string; + value: unknown; + expression: unknown; +}; + +export type OutcomePreview = { + key: string; + label: string; + phase: string; + kind: string; + scope: string; + attributeId: string | null; + statePool: string | null; + basis: string; + resource: string | null; + perStack: boolean | null; + variants: OutcomeVariantPreview[]; +}; + +export type ClausePreview = { + key: string; + label: string; + event: string; + recipeKey: string | null; + reducer: unknown; + condition: unknown; + cooldownSeconds: number | null; + mutations: unknown[]; + outcomes: OutcomePreview[]; +}; + +export type RevisionPreview = { + id: string | null; + revisionNumber: number | null; + readiness: string | null; + scenarioInputs: ScenarioInputPreview[]; + pools: PoolPreview[]; + clauses: ClausePreview[]; +}; + +export type ProjectionVariantPreview = { + key: string; + status: string; + displaySource: string; + text: string; +}; + +export type ProjectionPreview = { + status: 'current' | 'stale' | 'absent'; + selectedRevisionId: string | null; + variants: ProjectionVariantPreview[]; +}; + +export type MechanicPreviewState = { + authoredSource: AuthoredSourcePreview | null; + selectedRevision: RevisionPreview | null; + selectedRevisionStatus: 'current' | 'stale' | 'absent'; + modelDraft: RevisionPreview | null; + projection: ProjectionPreview; +}; + +export type AttachmentPreview = { + attachmentId: string; + nodeId: string | null; + rank: number | null; + effectOrder: number | null; + effectId: string | null; + mechanic: MechanicPreviewState; +}; + +export type Phase2OwnerFamily = + | 'talent' + | 'protocol-talent' + | 'skill-chip-talent' + | 'specialization-node' + | 'combat-effect'; + +export type Phase2OwnerPreview = { + family: Phase2OwnerFamily; + /** Persisted owner target ID, or null while a new target is awaiting approval. */ + canonicalTargetId: string | null; + currentMechanic: MechanicPreviewState; + proposedMechanic: MechanicPreviewState; + currentAttachments: AttachmentPreview[]; + proposedAttachments: AttachmentPreview[]; + selectionIntent: 'preserve' | 'save' | 'clear' | null; +}; + +const record = (value: unknown): RecordValue | null => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as RecordValue) + : null; + +const array = (value: unknown): unknown[] => + Array.isArray(value) ? value : []; + +const stringValue = (value: unknown, fallback = '') => + value === null || value === undefined ? fallback : String(value); + +const nullableString = (value: unknown): string | null => { + const result = stringValue(value).trim(); + return result ? result : null; +}; + +const numberValue = (value: unknown): number | null => { + if (value === null || value === undefined || value === '') return null; + const result = Number(value); + return Number.isFinite(result) ? result : null; +}; + +const booleanOrNull = (value: unknown): boolean | null => + typeof value === 'boolean' ? value : null; + +const booleanValue = (value: unknown): boolean | null => { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value === 1 ? true : value === 0 ? false : null; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') return true; + if (normalized === 'false' || normalized === '0') return false; + } + return null; +}; + +const json = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return null; + } +}; + +const jsonRecord = (value: unknown): RecordValue | null => record(json(value)); + +export function parseProposalPreviewPayload(payload: string): { + entries: ProposalPreviewEntry[]; + fields: Record; +} { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return { entries: [], fields: {} }; + } + const entries = array(parsed).flatMap((value) => { + const item = record(value); + if (!item || (item.kind !== 'text' && item.kind !== 'file')) return []; + return [ + { + name: stringValue(item.name), + kind: item.kind, + ...(item.value === undefined ? {} : { value: String(item.value) }), + ...(item.fileName === undefined + ? {} + : { fileName: String(item.fileName) }), + ...(item.type === undefined ? {} : { type: String(item.type) }), + } as ProposalPreviewEntry, + ]; + }); + const fieldEntries: Array<[string, string]> = []; + const seenFieldNames = new Set(); + for (const entry of entries) { + if (entry.kind !== 'text' || seenFieldNames.has(entry.name)) continue; + seenFieldNames.add(entry.name); + fieldEntries.push([entry.name, entry.value ?? '']); + } + return { entries, fields: Object.fromEntries(fieldEntries) }; +} + +function sourceValue(value: unknown): RecordValue | null { + const candidate = jsonRecord(value); + if (!candidate) return null; + const nestedSource = jsonRecord(candidate.source); + if (nestedSource) return { ...candidate, ...nestedSource }; + const nestedPayload = jsonRecord(candidate.payload); + return nestedPayload ? { ...candidate, ...nestedPayload } : candidate; +} + +type AuthoredSourceIdentity = { + ownerType?: string | null; + ownerId?: string | null; + ownerScope?: string | null; + ownerHasTierII?: boolean | null; + specialization?: Partial | null; +}; + +function authoredSourceIdentity( + source: RecordValue, + fallback: AuthoredSourceIdentity | undefined, +) { + const owner = jsonRecord(source.owner); + const ownerType = nullableString( + fallback?.ownerType !== undefined + ? fallback.ownerType + : owner?.type ?? source.ownerType ?? source.owner_type, + ); + const ownerId = nullableString( + fallback?.ownerId !== undefined + ? fallback.ownerId + : owner?.id ?? source.ownerId ?? source.owner_id, + ); + const ownerScope = nullableString( + fallback?.ownerScope !== undefined + ? fallback.ownerScope + : owner?.scope ?? + owner?.talentScope ?? + owner?.talent_scope ?? + source.ownerScope ?? + source.owner_scope ?? + source.talentScope ?? + source.talent_scope, + ); + const ownerHasTierII = + fallback?.ownerHasTierII !== undefined + ? fallback.ownerHasTierII + : booleanValue( + owner?.hasTierII ?? + owner?.has_tier_ii ?? + source.ownerHasTierII ?? + source.owner_has_tier_ii ?? + source.talentHasTierII ?? + source.talent_has_tier_ii, + ) ?? + (ownerType === 'talent' && ownerScope !== null + ? ownerScope === 'standard' + : null); + const specialization = + jsonRecord(source.specialization) ?? + jsonRecord(source.specializationIdentity) ?? + jsonRecord(source.specialization_identity); + const currentSpecialization = fallback?.specialization; + const specializationValue = + specialization || currentSpecialization + ? { + rankDescription: nullableString( + currentSpecialization?.rankDescription ?? + specialization?.rankDescription ?? + specialization?.rank_description ?? + source.rankDescription ?? + source.rank_description ?? + source.specializationRankDescription ?? + source.specialization_rank_description, + ), + effectId: nullableString( + currentSpecialization?.effectId ?? + specialization?.effectId ?? + specialization?.effect_id ?? + source.effectId ?? + source.effect_id ?? + source.specializationEffectId ?? + source.specialization_effect_id, + ), + effectTemplate: nullableString( + currentSpecialization?.effectTemplate ?? + specialization?.effectTemplate ?? + specialization?.effect_template ?? + source.effectTemplate ?? + source.effect_template ?? + source.specializationEffectTemplate ?? + source.specialization_effect_template, + ), + } + : null; + return { + ownerType, + ownerId, + ownerScope, + ownerHasTierII, + specialization: specializationValue, + }; +} + +export function normalizeAuthoredSource( + value: unknown, + fallback?: AuthoredSourceIdentity, +): AuthoredSourcePreview | null { + const source = sourceValue(value); + if (!source || typeof source.template !== 'string') return null; + const identity = authoredSourceIdentity(source, fallback); + const variants = array(json(source.variants)).flatMap((value) => { + const variant = jsonRecord(value); + if (!variant || typeof variant.key !== 'string') return []; + const slots = array(json(variant.slots)) + .flatMap((value, index) => { + const slot = jsonRecord(value); + if (!slot || typeof slot.key !== 'string') return []; + return [ + { + key: slot.key, + value: stringValue( + slot.value ?? slot.displayValue ?? slot.display_value, + ), + order: + numberValue(slot.order ?? slot.sortOrder ?? slot.sort_order) ?? + index, + unit: nullableString(slot.unit), + attributeId: nullableString( + slot.attributeId ?? + slot.statAttributeId ?? + slot.stat_attribute_id, + ), + }, + ]; + }) + .sort((left, right) => left.order - right.order); + return [ + { + key: variant.key, + materialized: stringValue(variant.materialized), + slots, + }, + ]; + }); + return { + id: nullableString(source.id), + ...identity, + version: numberValue(source.version ?? source.authoredContractVersion), + template: source.template, + variants, + }; +} + +function normalizeScenarioInput(value: unknown): ScenarioInputPreview | null { + const input = jsonRecord(value); + if (!input || typeof input.key !== 'string') return null; + return { + key: input.key, + label: stringValue(input.label, input.key), + kind: stringValue(input.kind, 'unknown'), + readiness: stringValue(input.readiness, 'ready'), + description: nullableString(input.description), + defaultValue: + typeof input.defaultValue === 'number' || + typeof input.defaultValue === 'boolean' + ? input.defaultValue + : null, + bestCaseValue: + typeof input.bestCaseValue === 'number' || + typeof input.bestCaseValue === 'boolean' + ? input.bestCaseValue + : null, + min: numberValue(input.min), + max: numberValue(input.max), + }; +} + +function normalizePool(value: unknown): PoolPreview | null { + const pool = jsonRecord(value); + if (!pool || typeof (pool.key ?? pool.state_key) !== 'string') return null; + return { + key: stringValue(pool.key ?? pool.state_key), + label: stringValue(pool.label, stringValue(pool.key ?? pool.state_key)), + stateKind: stringValue(pool.stateKind ?? pool.state_kind, 'unknown'), + scope: stringValue(pool.scope ?? pool.state_scope, 'unknown'), + initial: numberValue(pool.initial), + min: numberValue(pool.min), + max: numberValue(pool.max ?? pool.maximum_value), + bestCaseValue: numberValue(pool.bestCaseValue), + expiresAfterSeconds: numberValue( + pool.expiresAfterSeconds ?? pool.duration_seconds, + ), + }; +} + +function normalizeOutcome(value: unknown): OutcomePreview | null { + const outcome = jsonRecord(value); + if (!outcome || typeof (outcome.key ?? outcome.id) !== 'string') return null; + const variants = jsonRecord(outcome.variants); + return { + key: stringValue(outcome.key ?? outcome.id), + label: stringValue(outcome.label, stringValue(outcome.key ?? outcome.id)), + phase: stringValue(outcome.phase, 'unknown'), + kind: stringValue(outcome.kind, 'unknown'), + scope: stringValue(outcome.scope, 'unknown'), + attributeId: nullableString(outcome.attributeId ?? outcome.attribute_id), + statePool: nullableString(outcome.statePool ?? outcome.state_pool), + basis: stringValue(outcome.basis, 'unknown'), + resource: nullableString(outcome.resource), + perStack: booleanOrNull(outcome.perStack), + variants: variants + ? Object.entries(variants).map(([key, configured]) => { + const item = jsonRecord(configured); + return { + key, + value: item?.value ?? configured, + expression: item?.expression ?? null, + }; + }) + : [], + }; +} + +function normalizeClause(value: unknown): ClausePreview | null { + const clause = jsonRecord(value); + if (!clause || typeof (clause.key ?? clause.id) !== 'string') return null; + return { + key: stringValue(clause.key ?? clause.id), + label: stringValue(clause.label, stringValue(clause.key ?? clause.id)), + event: stringValue( + clause.event ?? clause.event_kind ?? clause.when, + 'unknown', + ), + recipeKey: nullableString(clause.recipeKey ?? clause.recipe_key), + reducer: clause.reducer ?? null, + condition: clause.if ?? clause.condition ?? null, + cooldownSeconds: numberValue( + clause.cooldownSeconds ?? clause.cooldown_seconds, + ), + mutations: array(json(clause.mutations)), + outcomes: array(json(clause.outcomes)).flatMap((item) => { + const outcome = normalizeOutcome(item); + return outcome ? [outcome] : []; + }), + }; +} + +export function normalizeRevision(value: unknown): RevisionPreview | null { + const revision = jsonRecord(value); + if (!revision) return null; + const draft = jsonRecord(revision.draft); + const source = draft ?? revision; + const hasModelData = + source.simulationReadiness !== undefined || + Array.isArray(json(source.scenarioInputs)) || + Array.isArray(json(source.pools)) || + Array.isArray(json(source.clauses)); + if (!hasModelData) return null; + return { + id: nullableString(revision.id), + revisionNumber: numberValue( + revision.revisionNumber ?? revision.revision_number, + ), + readiness: nullableString( + source.simulationReadiness ?? source.simulation_readiness, + ), + scenarioInputs: array( + json(source.scenarioInputs ?? source.scenario_inputs), + ).flatMap((item) => { + const input = normalizeScenarioInput(item); + return input ? [input] : []; + }), + pools: array(json(source.pools)).flatMap((item) => { + const pool = normalizePool(item); + return pool ? [pool] : []; + }), + clauses: array(json(source.clauses)).flatMap((item) => { + const clause = normalizeClause(item); + return clause ? [clause] : []; + }), + }; +} + +function metadataValue(value: unknown, keys: string[]): unknown { + const candidate = jsonRecord(value); + if (!candidate) return undefined; + for (const key of keys) { + if (candidate[key] !== undefined) return candidate[key]; + } + for (const nested of [candidate.source, candidate.payload]) { + const nestedRecord = jsonRecord(nested); + if (!nestedRecord) continue; + for (const key of keys) { + if (nestedRecord[key] !== undefined) return nestedRecord[key]; + } + } + return undefined; +} + +function sourceFingerprint(value: unknown): string | null { + return nullableString( + metadataValue(value, [ + 'sourceFingerprint', + 'source_fingerprint', + 'currentSourceFingerprint', + 'current_source_fingerprint', + ]), + ); +} + +function revisionSourceFingerprint(value: unknown): string | null { + return nullableString( + metadataValue(value, [ + 'sourceFingerprint', + 'source_fingerprint', + 'currentSourceFingerprint', + 'current_source_fingerprint', + ]), + ); +} + +function sourceSignature(value: AuthoredSourcePreview | null): string { + if (!value) return ''; + return JSON.stringify({ + ownerType: value.ownerType, + ownerId: value.ownerId, + ownerScope: value.ownerScope, + ownerHasTierII: value.ownerHasTierII, + specialization: value.specialization, + template: value.template, + variants: value.variants.map((variant) => ({ + key: variant.key, + materialized: variant.materialized, + slots: variant.slots.map((slot) => ({ + key: slot.key, + value: slot.value, + order: slot.order, + unit: slot.unit, + attributeId: slot.attributeId, + })), + })), + }); +} + +function projectionSourceFingerprint(value: unknown): string | null { + return nullableString( + metadataValue(value, [ + 'sourceFingerprint', + 'source_fingerprint', + 'currentSourceFingerprint', + 'current_source_fingerprint', + ]), + ); +} + +export function normalizeProjection( + value: unknown, + authoredSource: AuthoredSourcePreview | null, + selectedRevision: RevisionPreview | null, + rawRevision: unknown = null, + authoredFingerprint: string | null = null, +): ProjectionPreview { + const parsedProjection = json(value); + const projection = Array.isArray(parsedProjection) + ? { variants: parsedProjection } + : record(parsedProjection); + if (!projection) + return { status: 'absent', selectedRevisionId: null, variants: [] }; + const variants = array(json(projection.variants)).flatMap((value) => { + const variant = jsonRecord(value); + if (!variant || typeof (variant.key ?? variant.variantKey) !== 'string') + return []; + return [ + { + key: stringValue(variant.key ?? variant.variantKey), + status: stringValue(variant.status, 'unknown'), + displaySource: stringValue( + variant.displaySource ?? variant.display_source, + 'unknown', + ), + text: stringValue(variant.behaviorText ?? variant.behavior_text), + }, + ]; + }); + const selectedRevisionId = nullableString( + projection.selectedRevisionId ?? projection.selected_revision_id, + ); + let status: ProjectionPreview['status'] = variants.length + ? 'current' + : 'absent'; + const explicitStatus = stringValue(projection.status).toLowerCase(); + const explicitStaleVariant = variants.some( + (variant) => variant.status.toLowerCase() === 'stale', + ); + if (explicitStatus === 'absent') status = 'absent'; + else if (explicitStatus === 'stale' || explicitStaleVariant) status = 'stale'; + const currentSourceFingerprint = authoredFingerprint; + const projectionRevisionFingerprint = projectionSourceFingerprint(value); + const variantFingerprints = array(json(projection.variants)) + .map((item) => + nullableString( + metadataValue(item, [ + 'sourceFingerprint', + 'source_fingerprint', + 'currentSourceFingerprint', + 'current_source_fingerprint', + ]), + ), + ) + .filter((fingerprint): fingerprint is string => Boolean(fingerprint)); + const projectionHasStaleVariant = currentSourceFingerprint + ? variantFingerprints.some( + (fingerprint) => fingerprint !== currentSourceFingerprint, + ) + : false; + const selectedFingerprint = revisionSourceFingerprint(rawRevision); + if ( + status !== 'absent' && + ((selectedRevision && selectedRevisionId !== selectedRevision.id) || + (!selectedRevision && Boolean(selectedRevisionId)) || + (currentSourceFingerprint && + projectionRevisionFingerprint && + currentSourceFingerprint !== projectionRevisionFingerprint) || + (currentSourceFingerprint && + selectedFingerprint && + currentSourceFingerprint !== selectedFingerprint) || + projectionHasStaleVariant) + ) { + status = 'stale'; + } + return { status, selectedRevisionId, variants }; +} + +export function normalizeMechanicPreview(input: { + authoredSource?: unknown; + selectedRevision?: unknown; + publicProjection?: unknown; + modelDraft?: unknown; +}): MechanicPreviewState { + const authoredSource = normalizeAuthoredSource(input.authoredSource); + const selectedRevision = normalizeRevision(input.selectedRevision); + const modelDraft = normalizeRevision(input.modelDraft); + const rawRevision = jsonRecord(input.selectedRevision); + let selectedRevisionStatus: MechanicPreviewState['selectedRevisionStatus'] = + selectedRevision ? 'current' : 'absent'; + const sourceFingerprintValue = sourceFingerprint(input.authoredSource); + const revisionFingerprintValue = revisionSourceFingerprint( + input.selectedRevision, + ); + if ( + selectedRevision && + sourceFingerprintValue && + revisionFingerprintValue && + sourceFingerprintValue !== revisionFingerprintValue + ) { + selectedRevisionStatus = 'stale'; + } + return { + authoredSource, + selectedRevision, + selectedRevisionStatus, + modelDraft, + projection: normalizeProjection( + input.publicProjection, + authoredSource, + selectedRevision, + rawRevision, + sourceFingerprint(input.authoredSource), + ), + }; +} + +const ownerFamily = ( + module: string, + action: string, +): Phase2OwnerFamily | null => { + if (module === 'talents' && action === 'saveTalent') return 'talent'; + if (module === 'protocols' && action === 'save') return 'protocol-talent'; + if (module === 'skillchips' && action === 'save') return 'skill-chip-talent'; + if (module === 'specializations' && action === 'saveNode') + return 'specialization-node'; + if (module === 'specializations' && action === 'saveCombatEffect') + return 'combat-effect'; + return null; +}; + +const selection = (value: unknown): Phase2OwnerPreview['selectionIntent'] => + value === 'preserve' || value === 'save' || value === 'clear' ? value : null; + +function attachment( + value: unknown, + mechanic: MechanicPreviewState, + fallbackAttachmentId: string | null = null, +): AttachmentPreview | null { + const item = jsonRecord(value); + if (!item) return null; + const attachmentId = + nullableString(item.attachmentId ?? item.attachment_id ?? item.id) ?? + fallbackAttachmentId; + if (!attachmentId) return null; + return { + attachmentId, + nodeId: nullableString(item.nodeId ?? item.node_id), + rank: numberValue(item.rank), + effectOrder: numberValue( + item.effectOrder ?? item.effect_order ?? item.order, + ), + effectId: nullableString( + item.effectId ?? item.combatEffectId ?? item.combat_effect_id, + ), + mechanic, + }; +} + +function currentAttachmentMechanic(value: unknown): MechanicPreviewState { + const item = jsonRecord(value); + const state = jsonRecord(item?.mechanic) ?? jsonRecord(item?.state) ?? item; + return normalizeMechanicPreview({ + authoredSource: state?.authoredSource ?? state?.authored ?? state?.source, + selectedRevision: state?.selectedRevision ?? state?.revision, + publicProjection: state?.publicProjection ?? state?.projection, + modelDraft: null, + }); +} + +function projectedFallback(source: unknown): MechanicPreviewState { + const normalized = normalizeAuthoredSource(source); + return normalizeMechanicPreview({ + authoredSource: source, + selectedRevision: null, + publicProjection: normalized + ? { + variants: normalized.variants.map((variant) => ({ + key: variant.key, + status: 'not_modeled', + displaySource: 'authored', + behaviorText: variant.materialized, + })), + } + : null, + }); +} + +function proposedMechanic( + source: unknown, + fields: Record, + currentMechanic: MechanicPreviewState, + identity?: AuthoredSourceIdentity, +): MechanicPreviewState { + const intent = selection(fields.mechanicSelectionIntent); + const draft = intent === 'save' ? json(fields.conditionalRule) : null; + // Proposal forms intentionally omit server-owned source identity. Reattach the + // canonical identity before comparing a preserved model with its source. + const normalizedSource = normalizeAuthoredSource(source, identity); + const previewSource = normalizedSource ?? source; + if (intent === 'preserve') { + if (!currentMechanic.selectedRevision) { + const proposed = normalizeMechanicPreview({ + authoredSource: previewSource, + modelDraft: draft, + publicProjection: projectedFallback(previewSource).projection, + }); + return { ...proposed, modelDraft: normalizeRevision(draft) }; + } + const proposed = normalizeMechanicPreview({ + authoredSource: previewSource, + selectedRevision: currentMechanic.selectedRevision, + publicProjection: currentMechanic.projection, + }); + // A changed source invalidates the old selected revision and projection even + // though the proposal asked to preserve the model selection. + if ( + sourceSignature(proposed.authoredSource) !== + sourceSignature(currentMechanic.authoredSource) + ) { + return { + ...proposed, + selectedRevisionStatus: proposed.selectedRevision ? 'stale' : 'absent', + projection: { + ...projectedFallback(previewSource).projection, + status: 'stale', + }, + }; + } + return { + ...proposed, + selectedRevisionStatus: currentMechanic.selectedRevisionStatus, + projection: currentMechanic.projection, + }; + } + if (intent === 'save') { + return { + ...normalizeMechanicPreview({ + authoredSource: previewSource, + modelDraft: draft, + }), + modelDraft: normalizeRevision(draft), + }; + } + return projectedFallback(previewSource); +} + +function identityFromMechanic( + mechanic: MechanicPreviewState, + ownerType: string, +): AuthoredSourceIdentity { + const source = mechanic.authoredSource; + return { + // The proposal source is contributor input. Always provide the expected + // owner type and the persisted owner ID (if one exists) so submitted + // source.owner values cannot become preview identity. + ownerType, + ownerId: source?.ownerId ?? null, + ownerScope: source?.ownerScope ?? null, + ownerHasTierII: source?.ownerHasTierII ?? null, + specialization: source?.specialization, + }; +} + +function talentSourceIdentity( + fields: Record, + currentMechanic: MechanicPreviewState, + canonicalTargetId: string | null, +): AuthoredSourceIdentity { + const current = currentMechanic.authoredSource; + const category = nullableString(fields.category); + const isExotic = category === 'Exotic Weapon'; + return { + ownerType: 'talent', + // Talent IDs are proposal metadata, not authored source identity. Edits + // use the persisted snapshot target; creates remain unassigned. + ownerId: canonicalTargetId ?? current?.ownerId ?? null, + ownerScope: category + ? isExotic + ? 'exotic' + : 'standard' + : current?.ownerScope ?? null, + ownerHasTierII: category + ? !isExotic || fields.tierIIDocumented === '1' + : current?.ownerHasTierII ?? null, + }; +} + +function specializationSourceIdentity( + source: unknown, + currentMechanic: MechanicPreviewState, + rankDescription: unknown, + effectId: unknown, + canonicalAttachmentId: string | null = currentMechanic.authoredSource?.ownerId ?? null, +): AuthoredSourceIdentity { + const current = currentMechanic.authoredSource; + const rawSource = sourceValue(source); + const explicit = + jsonRecord(rawSource?.specialization) ?? + jsonRecord(rawSource?.specializationIdentity) ?? + jsonRecord(rawSource?.specialization_identity); + const explicitRankDescription = + explicit?.rankDescription ?? + explicit?.rank_description ?? + rawSource?.rankDescription ?? + rawSource?.rank_description ?? + rawSource?.specializationRankDescription ?? + rawSource?.specialization_rank_description; + const explicitEffectId = + explicit?.effectId ?? + explicit?.effect_id ?? + rawSource?.effectId ?? + rawSource?.effect_id ?? + rawSource?.specializationEffectId ?? + rawSource?.specialization_effect_id; + const explicitEffectTemplate = + explicit?.effectTemplate ?? + explicit?.effect_template ?? + rawSource?.effectTemplate ?? + rawSource?.effect_template ?? + rawSource?.specializationEffectTemplate ?? + rawSource?.specialization_effect_template; + const currentIdentity = current?.specialization; + const chooseSpecializationIdentity = ( + explicitValue: unknown, + derivedValue: unknown, + currentValue: string | null | undefined, + ) => { + const normalizedExplicit = nullableString(explicitValue); + return explicitValue !== undefined && + normalizedExplicit !== (currentValue ?? null) + ? normalizedExplicit + : nullableString(derivedValue); + }; + return { + ownerType: 'specialization_tree_rank', + ownerId: canonicalAttachmentId, + specialization: { + rankDescription: chooseSpecializationIdentity( + explicitRankDescription, + rankDescription, + currentIdentity?.rankDescription, + ), + effectId: chooseSpecializationIdentity( + explicitEffectId, + effectId, + currentIdentity?.effectId, + ), + effectTemplate: chooseSpecializationIdentity( + explicitEffectTemplate, + rawSource?.template ?? currentIdentity?.effectTemplate, + currentIdentity?.effectTemplate, + ), + }, + }; +} + +function phase2CanonicalTargetId( + family: Phase2OwnerFamily, + current: RecordValue, +): string | null { + const persistedRecordId = nullableString(record(current.record)?.id); + if (persistedRecordId) return persistedRecordId; + // Talent snapshots historically expose the persisted target through the + // authored source even when the row snapshot is unavailable. Other Phase 2 + // families expose nested authored sources for child attachments, so they must + // not use those IDs as the owner target. + if (family === 'talent') { + const source = sourceValue(current.authoredSource); + const owner = jsonRecord(source?.owner); + return nullableString(owner?.id ?? source?.ownerId ?? source?.owner_id); + } + return null; +} + +export function normalizePhase2OwnerPreview( + module: string, + action: string, + payload: string, + currentSnapshot: Record | null | undefined, +): Phase2OwnerPreview | null { + const family = ownerFamily(module, action); + if (!family) return null; + const { fields } = parseProposalPreviewPayload(payload); + const current = record(currentSnapshot) ?? {}; + const canonicalTargetId = phase2CanonicalTargetId(family, current); + let currentMechanic = normalizeMechanicPreview({}); + if (family === 'talent') { + const talentGraph = jsonRecord(current.graph); + const talentProjection = jsonRecord(current.publicProjection); + const selectedRevisionValue = json(current.selectedRevision); + const graphRevision = jsonRecord( + talentGraph?.selectedRevision ?? talentGraph?.revision, + ); + const projectionRevision = jsonRecord( + talentProjection?.selectedRevision ?? talentProjection?.revision, + ); + const fallbackRevision = graphRevision ?? projectionRevision ?? talentGraph; + const selectedRevision = + selectedRevisionValue ?? + (talentGraph + ? { + id: + talentProjection?.selectedRevisionId ?? + talentProjection?.selected_revision_id ?? + fallbackRevision?.id ?? + null, + revisionNumber: numberValue( + fallbackRevision?.revisionNumber ?? + fallbackRevision?.revision_number, + ), + sourceFingerprint: metadataValue(fallbackRevision, [ + 'sourceFingerprint', + 'source_fingerprint', + ]), + simulationReadiness: + current.readiness ?? + fallbackRevision?.simulationReadiness ?? + fallbackRevision?.simulation_readiness, + scenarioInputs: json( + talentGraph.scenarioInputs ?? + talentGraph.scenario_inputs ?? + fallbackRevision?.scenarioInputs ?? + fallbackRevision?.scenario_inputs ?? + current.scenarioInputs, + ), + pools: json(talentGraph.pools ?? fallbackRevision?.pools), + clauses: json(talentGraph.clauses ?? fallbackRevision?.clauses), + } + : null); + currentMechanic = normalizeMechanicPreview({ + authoredSource: current.authoredSource, + selectedRevision, + publicProjection: current.publicProjection, + }); + } else if (family === 'protocol-talent' || family === 'skill-chip-talent') { + const mechanic = jsonRecord(current.mechanic); + currentMechanic = normalizeMechanicPreview({ + authoredSource: mechanic?.authoredSource, + selectedRevision: mechanic?.selectedRevision, + publicProjection: mechanic?.publicProjection, + }); + } + const proposedSource = + family === 'talent' + ? fields.authoredSource + : fields.hasTalent === '1' + ? fields.authoredSource + : null; + const sourceValue = proposedSource ? json(proposedSource) : null; + const proposedMechanicState = proposedMechanic( + sourceValue, + fields, + currentMechanic, + family === 'talent' + ? talentSourceIdentity(fields, currentMechanic, canonicalTargetId) + : identityFromMechanic( + currentMechanic, + family === 'protocol-talent' + ? 'os_protocol_talent' + : 'skill_chip_talent', + ), + ); + + const currentAttachments: AttachmentPreview[] = []; + const proposedAttachments: AttachmentPreview[] = []; + if (family === 'specialization-node') { + const authored = array(json(current.authoredMechanics)); + const authoredByAttachment = new Map( + authored.flatMap((value) => { + const item = jsonRecord(value); + const id = nullableString( + item?.attachmentId ?? item?.attachment_id ?? item?.id, + ); + return id ? [[id, value] as const] : []; + }), + ); + const rows = array(json(current.combatEffects)); + rows.forEach((row, index) => { + const rowId = nullableString( + jsonRecord(row)?.attachmentId ?? + jsonRecord(row)?.attachment_id ?? + jsonRecord(row)?.id, + ); + const normalized = attachment( + row, + currentAttachmentMechanic( + authoredByAttachment.get(rowId ?? '') ?? authored[index], + ), + ); + if (normalized) currentAttachments.push(normalized); + }); + const ranks = array(json(fields.ranksJson)); + ranks.forEach((rankValue) => { + const rank = jsonRecord(rankValue); + if (!rank) return; + array(json(rank.combatEffects)).forEach((effectValue, index) => { + const effect = jsonRecord(effectValue); + const source = + effect?.authoredSource ?? + jsonRecord(effect?.mechanic)?.authoredSource ?? + jsonRecord(effect?.state)?.authoredSource ?? + null; + const submittedAttachmentId = nullableString( + effect?.id ?? effect?.attachmentId ?? effect?.attachment_id, + ); + const effectId = nullableString( + effect?.effectId ?? + effect?.combatEffectId ?? + effect?.combat_effect_id, + ); + const existingAttachment = currentAttachments.find( + (attachment) => attachment.attachmentId === submittedAttachmentId, + ); + const fallbackMechanic = + existingAttachment?.mechanic ?? projectedFallback(source); + const normalized = attachment( + { + // Only a row from the current snapshot can establish an existing + // attachment identity. Payload IDs for new rows are client data, + // not persisted identities. + attachmentId: existingAttachment?.attachmentId, + // Existing rows inherit their persisted node relationship. New + // rows have no target identity until approval assigns one. + nodeId: + existingAttachment?.nodeId ?? + canonicalTargetId ?? + 'ASSIGNED ON APPROVAL', + rank: rank.rank, + effectOrder: + effect?.effectOrder ?? + effect?.effect_order ?? + effect?.order ?? + index, + effectId: existingAttachment?.effectId ?? effectId, + }, + proposedMechanic( + source, + { + mechanicSelectionIntent: stringValue( + effect?.selectionIntent ?? 'clear', + ), + conditionalRule: + typeof effect?.modelDraft === 'string' + ? effect.modelDraft + : JSON.stringify(effect?.modelDraft ?? null), + }, + fallbackMechanic, + specializationSourceIdentity( + source, + fallbackMechanic, + rank.description, + existingAttachment?.effectId ?? effectId, + existingAttachment?.attachmentId ?? null, + ), + ), + `SERVER-ASSIGNED ON APPROVAL · ATTACHMENT RANK ${rank.rank} · POSITION ${index + 1}`, + ); + if (normalized) proposedAttachments.push(normalized); + }); + }); + } + if (family === 'combat-effect') { + const authored = array(json(current.authoredMechanics)); + const authoredByAttachment = new Map( + authored.flatMap((value) => { + const item = jsonRecord(value); + const id = nullableString( + item?.attachmentId ?? item?.attachment_id ?? item?.id, + ); + return id ? [[id, value] as const] : []; + }), + ); + array(json(current.attachments)).forEach((row, index) => { + const rowId = nullableString( + jsonRecord(row)?.attachmentId ?? + jsonRecord(row)?.attachment_id ?? + jsonRecord(row)?.id, + ); + const normalized = attachment( + row, + currentAttachmentMechanic( + authoredByAttachment.get(rowId ?? '') ?? authored[index], + ), + ); + if (normalized) currentAttachments.push(normalized); + }); + // A reusable template update fans out to each existing attachment. Keep the + // attachment identity and values, but materialize the new shared template. + const template = fields.descriptionTemplate; + currentAttachments.forEach((item) => { + const source = item.mechanic.authoredSource; + if (!source || !template) { + proposedAttachments.push({ ...item }); + return; + } + const tokens = [ + ...new Set( + [...template.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]), + ), + ]; + const variants = source.variants.map((variant) => { + const existingSlots = new Map( + variant.slots.map((slot) => [slot.key, slot]), + ); + const slots = tokens.map((key, order) => ({ + ...(existingSlots.get(key) ?? { + key, + value: '', + order, + unit: null, + attributeId: null, + }), + key, + order, + })); + const values = new Map(slots.map((slot) => [slot.key, slot.value])); + const materialized = template.replace( + /\{([^{}]+)\}/g, + (placeholder, key: string) => values.get(key) || placeholder, + ); + return { ...variant, slots, materialized }; + }); + const proposedSource = { + ...source, + template, + variants, + specialization: source.specialization + ? { ...source.specialization, effectTemplate: template } + : null, + }; + const changed = + sourceSignature(proposedSource) !== sourceSignature(source); + proposedAttachments.push({ + ...item, + mechanic: { + ...item.mechanic, + authoredSource: proposedSource, + selectedRevisionStatus: + changed && item.mechanic.selectedRevision + ? 'stale' + : item.mechanic.selectedRevisionStatus, + projection: changed + ? projectedFallback(proposedSource).projection + : item.mechanic.projection, + }, + }); + }); + } + + return { + family, + canonicalTargetId, + currentMechanic, + proposedMechanic: proposedMechanicState, + currentAttachments, + proposedAttachments, + selectionIntent: selection(fields.mechanicSelectionIntent), + }; +} + +export function formatMechanicToken(value: unknown): string { + const text = stringValue(value, 'NOT SET').replaceAll('_', ' '); + return text.toUpperCase(); +} + +const hiddenMechanicKeys = new Set([ + 'sourcefingerprint', + 'programhash', + 'raw', + 'bytes', + 'chat', + 'notes', + 'contributor', + 'reviewer', + 'secret', +]); + +const previewKey = (key: string) => + key.replace(/[^a-z0-9]/gi, '').toLowerCase(); + +const hiddenPreviewKey = (key: string) => { + const normalized = previewKey(key); + // "token" is a valid mechanic label. Only token fields that identify a + // credential are secret, such as accessToken or session_token. + const credentialToken = + normalized.includes('token') && + normalized !== 'token' && + /(access|refresh|session|auth|csrf|bearer|oauth|credential|api|secret)/.test( + normalized, + ); + const keyMaterial = + normalized.includes('passkey') || + normalized.includes('publickey') || + normalized.includes('privatekey') || + normalized.includes('apikey') || + normalized.includes('encryptionkey') || + normalized.includes('keymaterial'); + const structuredBytes = + normalized === 'raw' || + normalized.startsWith('raw') || + normalized.includes('base64') || + normalized.includes('encoded') || + normalized.includes('blob') || + normalized === 'bytes' || + normalized.endsWith('bytes') || + normalized === 'data' || + normalized.startsWith('datauri') || + normalized.startsWith('dataurl') || + normalized.startsWith('filedata') || + normalized.startsWith('filecontent') || + normalized === 'file'; + return ( + normalized === 'expectedtalentid' || + normalized === 'expectedownerupdatedat' || + normalized.includes('fingerprint') || + normalized === 'hash' || + normalized.endsWith('hash') || + normalized === 'sessionid' || + normalized === 'oauthstate' || + normalized.includes('password') || + normalized.includes('salt') || + normalized.includes('signature') || + normalized.includes('cookie') || + normalized.includes('credential') || + normalized.includes('chat') || + normalized.includes('note') || + normalized.includes('contributor') || + normalized.includes('reviewer') || + normalized.includes('secret') || + credentialToken || + keyMaterial || + structuredBytes + ); +}; + +const OMITTED_PREVIEW_VALUE = Symbol('omitted-preview-value'); +type RedactedPreviewValue = unknown | typeof OMITTED_PREVIEW_VALUE; + +const redactPreviewValue = (value: unknown): RedactedPreviewValue => { + if (Array.isArray(value)) { + return value.flatMap((item) => { + const redacted = redactPreviewValue(item); + return redacted === OMITTED_PREVIEW_VALUE ? [] : [redacted]; + }); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return redactPreviewValue(JSON.parse(trimmed)); + } catch { + // A value that looks structured must not fall through as raw text. + return OMITTED_PREVIEW_VALUE; + } + } + return value; + } + const objectValue = record(value); + if (objectValue) { + return Object.fromEntries( + Object.entries(objectValue).flatMap(([key, item]) => { + if (hiddenPreviewKey(key)) return []; + const redacted = redactPreviewValue(item); + return redacted === OMITTED_PREVIEW_VALUE ? [] : [[key, redacted]]; + }), + ); + } + return value; +}; + +export function isPreviewFieldHidden(name: string): boolean { + return hiddenPreviewKey(name); +} + +export function safePreviewValue(name: string, value: unknown): string { + if (hiddenPreviewKey(name)) return '[OMITTED FROM HUMAN PREVIEW]'; + const redacted = redactPreviewValue(value); + if (redacted === OMITTED_PREVIEW_VALUE) { + return '[OMITTED FROM HUMAN PREVIEW]'; + } + if (typeof redacted === 'string') return redacted; + return stringValue(JSON.stringify(redacted)); +} + +export function formatMechanicValue(value: unknown): string { + if (value === null || value === undefined || value === '') return 'NOT SET'; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + const redacted = redactPreviewValue(value); + return redacted === OMITTED_PREVIEW_VALUE + ? 'NOT SET' + : formatMechanicValue(redacted); + } + return value; + } + if (typeof value === 'object') { + if (Array.isArray(value)) return value.map(formatMechanicValue).join(', '); + const visible = Object.entries(value as RecordValue).filter( + ([key]) => + !hiddenMechanicKeys.has(previewKey(key)) && !hiddenPreviewKey(key), + ); + return visible.length + ? visible + .map(([key, item]) => `${key}=${formatMechanicValue(item)}`) + .join(', ') + : 'NOT SET'; + } + return String(value); +} diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index 21a8793..482d040 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -1,5 +1,16 @@ +{#snippet authoredSourceBlock(source: AuthoredSourcePreview | null)} + {#if source} +
+
AUTHORED SOURCE · VERSION {shown(source.version)}{source.variants.length} ORDERED VARIANTS
+
TEMPLATE

{source.template}

+ {#if source.specialization} +
+
RANK DESCRIPTION

{shown(source.specialization.rankDescription)}

+
REUSABLE EFFECT ID

{shown(source.specialization.effectId)}

+
REUSABLE EFFECT TEMPLATE

{shown(source.specialization.effectTemplate)}

+
+ {/if} +
+ {#each source.variants as variant, variantIndex} +
+
VARIANT {variantIndex + 1} · {variant.key}{variant.slots.length} SLOTS
+

{variant.materialized || 'NOT MATERIALIZED'}

+ {#each variant.slots as slot, slotIndex} + SLOT {slotIndex + 1} · ORDER {slot.order} · {slot.key} = {shown(slot.value)}{slot.unit ? ` · UNIT ${slot.unit}` : ''}{slot.attributeId ? ` · ATTRIBUTE ${slot.attributeId}` : ''} + {:else}NO TEMPLATE SLOTS{/each} +
+ {:else}

NO AUTHORED VARIANTS

{/each} +
+
+ {/if} +{/snippet} + +{#snippet mechanicStateBlock(state: MechanicPreviewState, heading: string)} +
+
{heading}{state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : state.modelDraft ? 'MODEL SUBMISSION' : 'NO MODEL SELECTED'}
+
+
SELECTION STATE{state.selectedRevision ? formatMechanicToken(state.selectedRevisionStatus) : state.modelDraft ? 'PENDING REVISION' : 'ABSENT'}
+
READINESS{formatMechanicToken((state.selectedRevision ?? state.modelDraft)?.readiness)}
+
SCENARIO INPUTS{(state.selectedRevision ?? state.modelDraft)?.scenarioInputs.length ?? 0}
+
STATE POOLS{(state.selectedRevision ?? state.modelDraft)?.pools.length ?? 0}
+
CLAUSES{(state.selectedRevision ?? state.modelDraft)?.clauses.length ?? 0}
+
+ {#if state.selectedRevision ?? state.modelDraft} + {@const model = (state.selectedRevision ?? state.modelDraft)!} +
+
SCENARIO INPUTS{model.scenarioInputs.length}
+ {#each model.scenarioInputs as input}

INPUT · {input.label} · {formatMechanicToken(input.kind)} · {formatMechanicToken(input.readiness)}{input.min !== null || input.max !== null ? ` · RANGE ${shown(input.min)}–${shown(input.max)}` : ''}{input.defaultValue !== null ? ` · DEFAULT ${shown(input.defaultValue)}` : ''}

{:else}

NO SCENARIO INPUTS

{/each} +
+
STATE POOLS{model.pools.length}
+ {#each model.pools as pool}

POOL · {pool.key} · {pool.label} · KIND {formatMechanicToken(pool.stateKind)} · {formatMechanicToken(pool.scope)} · INITIAL {shown(pool.initial)} · RANGE {shown(pool.min)}–{shown(pool.max)}{pool.bestCaseValue !== null ? ` · BEST ${shown(pool.bestCaseValue)}` : ''}{pool.expiresAfterSeconds !== null ? ` · EXPIRES ${shown(pool.expiresAfterSeconds)}s` : ''}

{:else}

NO STATE POOLS

{/each} +
+
CLAUSE GRAPH{model.clauses.length}
+ {#each model.clauses as clause, clauseIndex} +
+

CLAUSE {clauseIndex + 1} · {clause.label} · EVENT {formatMechanicToken(clause.event)}{clause.recipeKey ? ` · RECIPE ${formatMechanicToken(clause.recipeKey)}` : ''} · REDUCER {formatMechanicToken((clause.reducer as any)?.kind)}{clause.cooldownSeconds !== null ? ` · COOLDOWN ${shown(clause.cooldownSeconds)}s` : ''}

+ CONDITION · {formatMechanicValue(clause.condition)} · MUTATIONS {clause.mutations.length} + {#each clause.mutations as mutation, mutationIndex}MUTATION {mutationIndex + 1} · {formatMechanicValue(mutation)}{/each} + {#each clause.outcomes as outcome, outcomeIndex} +
OUTCOME {outcomeIndex + 1} · {outcome.label} · {formatMechanicToken(outcome.kind)} · {formatMechanicToken(outcome.scope)} · {formatMechanicToken(outcome.phase)} · BASIS {formatMechanicToken(outcome.basis)}{outcome.attributeId ? ` · ATTRIBUTE ${outcome.attributeId}` : ''}{outcome.statePool ? ` · POOL ${outcome.statePool}` : ''}{outcome.resource ? ` · RESOURCE ${formatMechanicToken(outcome.resource)}` : ''}{outcome.perStack !== null ? ` · PER STACK ${outcome.perStack ? 'YES' : 'NO'}` : ''}{#each outcome.variants as variant}VARIANT {variant.key} · VALUE {formatMechanicValue(variant.value)}{variant.expression !== null ? ` · EXPRESSION ${formatMechanicValue(variant.expression)}` : ''}{/each}
+ {/each} +
+ {:else}

NO CLAUSES

{/each} +
+
+ {:else}

NO IMMUTABLE MODEL REVISION IS SELECTED.

{/if} +
+{/snippet} + +{#snippet projectionBlock(state: MechanicPreviewState)} +
+
PUBLIC PROJECTION{formatMechanicToken(state.projection.status)}
+ {#if state.projection.variants.length} +

PROJECTION SOURCE · {state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : 'AUTHORED FALLBACK'}

+ {#each state.projection.variants as variant}

{variant.key}STATUS {formatMechanicToken(variant.status)} · DISPLAY SOURCE {formatMechanicToken(variant.displaySource)}{variant.text || 'NO PUBLIC TEXT'}

{/each} + {:else}

NO PUBLIC PROJECTION AVAILABLE.

{/if} +
+{/snippet} + +{#snippet mechanicBlocks(state: MechanicPreviewState, heading: string)} + {@render authoredSourceBlock(state.authoredSource)} + {@render mechanicStateBlock(state, heading)} + {@render projectionBlock(state)} +{/snippet} + +{#snippet attachmentBlock(attachments: AttachmentPreview[], heading: string)} +
+
{heading}{attachments.length} ATTACHMENTS
+ {#each attachments as attachment, attachmentIndex} +
+
ATTACHMENT {attachmentIndex + 1} · {attachment.attachmentId}RANK {shown(attachment.rank)} · ORDER {shown(attachment.effectOrder)}
+

NODE ID · {shown(attachment.nodeId)} · COMBAT EFFECT ID · {shown(attachment.effectId)}

+ {@render mechanicBlocks(attachment.mechanic, 'ATTACHMENT MECHANIC')} +
+ {:else}

NO ATTACHMENTS

{/each} +
+{/snippet} +
CHANGE PREVIEWLIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION
@@ -281,6 +353,42 @@
+ {:else if phase2OwnerPreview} +
+
+
CURRENT LIVE · {phase2OwnerPreview.family.toUpperCase()}{phase2HasCurrentTarget ? 'BEFORE' : 'NO LIVE RECORD'}
+ {#if phase2HasCurrentTarget} +
{#each ownerIdentityFields('current', phase2OwnerPreview.family) as field}
{field.label}{shown(field.value)}
{/each}
+
RELATIONSHIPSLIVE
{#each ownerRelationshipRows('current', phase2OwnerPreview.family) as line}

{line}

{/each}
+ {#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)} + {@render mechanicBlocks(phase2OwnerPreview.currentMechanic, 'MECHANIC MODEL')} + {:else} + {#if phase2OwnerPreview.family === 'specialization-node'} +
+ {#each currentNodeRanks as rank}
RANK {rank.rank}{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} STATIC EFFECTS

{shown(rank.description)}

{#each currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)) as effect}{effectSummary(effect)}{/each}{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}
{:else}

NO RANK DEFINITIONS

{/each} +
+ {/if} + {@render attachmentBlock(phase2OwnerPreview.currentAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT' : 'NODE ATTACHMENTS')} + {/if} + {:else}
NO LIVE {phase2OwnerPreview.family.toUpperCase()} RECORDThe proposed owner state will be created on approval.
{/if} +
+
+
PROPOSED · {phase2OwnerPreview.family.toUpperCase()}AFTER
+
{#each ownerIdentityFields('proposed', phase2OwnerPreview.family) as field}
{field.label}{shown(field.value)}
{/each}
+
RELATIONSHIPSPROPOSED
{#each ownerRelationshipRows('proposed', phase2OwnerPreview.family) as line}

{line}

{/each}
+ {#if phase2OwnerPreview.selectionIntent}

MODEL REQUEST · {formatMechanicToken(phase2OwnerPreview.selectionIntent)}

{/if} + {#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)} + {@render mechanicBlocks(phase2OwnerPreview.proposedMechanic, 'MECHANIC MODEL AFTER APPROVAL')} + {:else} + {#if phase2OwnerPreview.family === 'specialization-node'} +
+ {#each proposedRanks as rank}
RANK {rank.rank}{rank.effects?.length ?? 0} STATIC EFFECTS

{shown(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'}
{:else}

NO RANK DEFINITIONS

{/each} +
+ {/if} + {@render attachmentBlock(phase2OwnerPreview.proposedAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT AFTER APPROVAL' : 'NODE ATTACHMENTS AFTER APPROVAL')} + {/if} +
+
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
@@ -292,35 +400,6 @@
{#each proposedSharedSkillChipProfiles as profile}

{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}

{/each}
- {:else if module === 'skillchips' && action === 'save'} -
-
-
CURRENT LIVE · SKILL CHIP{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}
- {#if skillChipRecord} -
- {#each skillChipFields as field}
{field.label}{shown(field.current)}
{/each} -
3-PIECE TALENT{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}
-
-
-
3-PIECE CONDITIONAL MECHANICTYPED RULE
- {#each conditionalRuleLines(currentSnapshot?.conditional) as line}

{line}

{:else}

NO TYPED RULE CONFIGURED

{/each} -
- {:else} -
NEW SKILL CHIPNo live registry record will be replaced.
- {/if} -
-
-
PROPOSED · SKILL CHIPAFTER
-
- {#each skillChipFields as field}
{field.label}{shown(field.proposed)}
{/each} -
3-PIECE TALENT{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}
-
-
-
3-PIECE CONDITIONAL MECHANICTYPED RULE
- {#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}

{line}

{:else}

NO TYPED RULE CONFIGURED

{/each} -
-
-
{:else if module === 'calculations'}
@@ -333,7 +412,7 @@ {#if action === 'saveRevision'}
{#each proposedCalculationStages as stage}
{stage.label}{stage.operator?.replaceAll('_',' ').toUpperCase()}

ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}

{/each}
{/if}
- {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)} + {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities'].includes(action)}
CURRENT LIVE · {specializationKind}{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}
@@ -352,12 +431,6 @@
INTRINSIC ATTACK PROFILE{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}
{#each attackProfileLines(currentAbilityAttack) as line}

{line}

{:else}

NO INTRINSIC ATTACK PROFILE

{/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} @@ -385,18 +458,6 @@
INTRINSIC ATTACK PROFILE{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}
{#each attackProfileLines(proposedAbilityAttack) as line}

{line}

{:else}

NO INTRINSIC ATTACK PROFILE

{/each}
- {:else if action === 'saveNode'} -
- {#each proposedRanks as rank} -
-
RANK {rank.rank}{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL
-

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

- {#each rank.effects ?? [] as effect}{effectSummary(effect)}{/each} - {#each rank.combatEffects ?? [] as effect}{conditionalRuleSummary(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} @@ -411,19 +472,94 @@
PROPOSED {label(module)} RECORDAFTER
{#each entries as entry} + {@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''} + {#if !isPreviewFieldHidden(entry.name)}
{label(entry.name)} {#if entry.kind === 'file'} {entry.fileName || 'UPLOADED FILE'}{entry.type || 'FILE EVIDENCE'} - {:else if jsonValue(entry.value)} - {Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'} - {entry.value} + {:else if jsonValue(safeValue)} + {Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'} + {safeValue} {:else} - {shown(entry.value)} + {shown(safeValue)} {/if}
+ {/if} {/each}
{/if}
+ + diff --git a/src/lib/components/isac/AuthoredDescriptionEditor.svelte b/src/lib/components/isac/AuthoredDescriptionEditor.svelte new file mode 100644 index 0000000..8492924 --- /dev/null +++ b/src/lib/components/isac/AuthoredDescriptionEditor.svelte @@ -0,0 +1,355 @@ + + +
+
+
+

PUBLIC FALLBACK SOURCE

+

AUTHORED DESCRIPTION

+

Define exact public prose independently from combat modeling.

+
+ + {variantLabels.length} {variantLabels.length === 1 ? 'VARIANT' : 'VARIANTS'} + +
+ + + + {#if invalidTokens.length} + + {/if} + + {#if tokens.length} +
+ + + + + {#each variantLabels as variant} + + {/each} + + + + {#each tokens as token} + + + {#each variantLabels as variant} + + {/each} + + {/each} + +
AUTHORED VALUE{variant.label}
{`{${token}}`} + + + updateSlot(variant.key, token, event.currentTarget.value)} + {disabled} + required + /> +
+
+ {:else} +

+ Add a supported token to the shared template to define variant values. +

+ {/if} + +
+ {#each variantLabels as variant} +
+ {variant.label} EXACT PREVIEW +

{preview(variant.key) || 'Description preview'}

+
+ {/each} +
+
+ + diff --git a/src/lib/server/data-moderation-phase2.test.ts b/src/lib/server/data-moderation-phase2.test.ts new file mode 100644 index 0000000..8b5031d --- /dev/null +++ b/src/lib/server/data-moderation-phase2.test.ts @@ -0,0 +1,1570 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { connect, type Database } from '@tursodatabase/database'; +import sharp from 'sharp'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(), +})); + +vi.mock('$lib/server/oversight-notifications', () => ({ + notifyOversightReview: vi.fn(async () => undefined), +})); + +import { getDatabase } from '$lib/server/database'; +import { + loadMechanicAuthoredSource, + saveMechanicAuthoredSourceInTransaction, +} from './mechanic-authored-sources'; +import { + approvePhase2Proposal, + failPhase2Proposal, + MAX_PROPOSAL_ENTRIES, + MAX_PROPOSAL_FILE_BYTES, + MAX_PROPOSAL_FILE_BYTES_TOTAL, + MAX_PROPOSAL_FILE_NAME_CHARS, + MAX_PROPOSAL_FILE_TYPE_CHARS, + MAX_PROPOSAL_FIELD_NAME_CHARS, + MAX_PROPOSAL_TEXT_BYTES, + MAX_PROPOSAL_TEXT_ENTRY_CHARS, + ProposalCanonicalValidationError, + ProposalDependenciesBlockedError, + ProposalDependencyCycleError, + ProposalNotPendingError, + deserializeProposal, + ProposalPayloadValidationError, + getPendingEntities, + queueDataProposal, + refreshProposalDependencies, + serializeProposal, + validateSerializedProposalEntries, +} from './data-moderation'; + +let database: Database; + +const applySchema = async (target: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +const source = (value = '10') => ({ + version: 1, + template: 'Gain {1}% weapon damage.', + variants: [ + { + key: 'I', + materialized: `Gain ${value}% weapon damage.`, + slots: [{ key: '1', value, order: 0 }], + }, + { + key: 'II', + materialized: `Gain ${value}% weapon damage.`, + slots: [{ key: '1', value, order: 0 }], + }, + ], +}); + +const protocolSource = (value = '10') => ({ + version: 1, + template: 'Gain {1}% weapon damage.', + variants: [ + { + key: 'base', + materialized: `Gain ${value}% weapon damage.`, + slots: [{ key: '1', value, order: 0 }], + }, + ], +}); + +const skillChipSource = (value = '10') => ({ + version: 1, + template: 'Gain {1}% skill damage.', + variants: [ + { + key: 'minimum', + materialized: `Gain ${value}% skill damage.`, + slots: [{ key: '1', value, order: 0 }], + }, + { + key: 'maximum', + materialized: `Gain ${value}% skill damage.`, + slots: [{ key: '1', value, order: 0 }], + }, + ], +}); + +const modelDraft = () => ({ + version: 1 as const, + simulationReadiness: 'captured_only' as const, + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'primary', + label: 'Primary', + event: 'weapon_hit' as const, + if: { kind: 'true' as const }, + reducer: { kind: 'none' as const }, + mutations: [], + outcomes: [ + { + key: 'marker', + label: 'Marker', + phase: 'on_event' as const, + kind: 'marker' as const, + scope: 'self' as const, + basis: 'flat' as const, + perStack: false, + variants: { minimum: { value: 1 }, maximum: { value: 1 } }, + }, + ], + }, + ], +}); + +const payload = (fields: Record) => + JSON.stringify( + Object.entries(fields).map(([name, value]) => ({ + name, + kind: 'text', + value, + })), + ); + +const specializationSource = (id: string, value: string) => ({ + version: 1 as const, + owner: { type: 'specialization_tree_rank' as const, id }, + template: 'Gain {1}% Weapon Damage.', + variants: [ + { + key: 'base', + materialized: `Gain ${value}% Weapon Damage.`, + slots: [{ key: '1', value, order: 0, unit: '%' }], + }, + ], + specialization: { + rankDescription: 'Rank description', + effectId: 'combat-1', + effectTemplate: 'Gain {1}% Weapon Damage.', + }, +}); + +const insertProposal = async ( + id: string, + fields: Record, + options: { + entityId?: string; + draftEntityId?: string; + module?: string; + action?: string; + } = {}, +) => { + await ( + await database.prepare(`INSERT INTO data_change_proposals + (id,module,action,entity_id,draft_entity_id,summary,payload_json,proposed_by,created_at) + VALUES(?,?,?,?,?,?,?,'contributor','now')`) + ).run( + id, + options.module ?? 'talents', + options.action ?? 'saveTalent', + options.entityId ?? null, + options.draftEntityId ?? null, + `SAVE TALENT · ${fields.name}`, + payload(fields), + ); +}; + +beforeEach(async () => { + database = await connect(':memory:'); + vi.mocked(getDatabase).mockResolvedValue(database); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + await database.exec(` + INSERT INTO users(id,username,role,created_at) + VALUES('reviewer','Reviewer','admin','now'),('contributor','Contributor','admin','now'); + INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,created_at,updated_at) + VALUES + ('weapon-damage','Weapon Damage','Offensive','Weapon','Additive','%','now','now'), + ('firepower','Firepower','Offensive','Character','Additive','','now','now'), + ('weapon-damage-profile','Weapon Damage','Offensive','Weapon','Additive','','now','now'), + ('health','Health','Defensive','Character','Additive','','now','now'), + ('skill-damage','Skill Damage','Offensive','Skill','Additive','%','now','now'); + `); +}); + +afterEach(async () => { + await database.close(); +}); + +const insertProtocolProfile = async () => { + await database.exec(` + INSERT INTO os_protocol_secondary_profiles( + id,quality,primary_attribute_type,created_at,updated_at + ) VALUES('profile-standard-firepower','Standard','Firepower','now','now'); + INSERT INTO os_protocol_secondary_effects( + id,profile_id,effect_role,stat_attribute_id,value + ) VALUES + ('profile-core','profile-standard-firepower','Core','firepower',1), + ('profile-damage','profile-standard-firepower','Damage','weapon-damage-profile',2), + ('profile-health','profile-standard-firepower','Health','health',3); + `); +}; + +const insertSpecializationFixture = async () => { + await database.exec(` + INSERT INTO specializations(id,name,created_at,updated_at) + VALUES('specialization-phase2','Phase 2 Specialization','now','now'); + INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,created_at,updated_at + ) VALUES('focus-phase2','specialization-phase2','Phase 2 Focus','Firepower','now','now'); + INSERT INTO specialization_abilities( + id,specialization_id,name,description,created_at,updated_at + ) VALUES('ability-phase2','specialization-phase2','Phase 2 Ability','Ability description.','now','now'); + `); +}; + +describe('atomic Phase 2 Data Oversight replay', () => { + it('commits owner state, proposal decision, and both audits together', async () => { + await insertProposal( + 'proposal-1', + { + id: 'client-selected-id', + _proposedEntityId: 'client-draft-id', + name: 'Replay Talent', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'clear', + }, + { draftEntityId: 'draft-talent-1' }, + ); + + const result = await approvePhase2Proposal( + database, + 'reviewer', + 'proposal-1', + 'approval-note-sentinel', + ); + + expect(result.entityId).toBe('draft-talent-1'); + expect( + await ( + await database.prepare( + 'SELECT status,applied_entity_id FROM data_change_proposals WHERE id=?', + ) + ).get('proposal-1'), + ).toEqual({ status: 'approved', applied_entity_id: 'draft-talent-1' }); + expect( + await ( + await database.prepare('SELECT id FROM talents WHERE id=?') + ).get('draft-talent-1'), + ).toEqual({ id: 'draft-talent-1' }); + expect( + await ( + await database.prepare('SELECT id FROM talents WHERE id IN (?,?)') + ).all('client-selected-id', 'client-draft-id'), + ).toEqual([]); + const audits = await ( + await database.prepare( + 'SELECT action,before_json,after_json FROM audit_log WHERE entity_id IN (?,?) ORDER BY action', + ) + ).all('draft-talent-1', 'proposal-1'); + expect(JSON.stringify(audits)).not.toContain('approval-note-sentinel'); + expect(audits.map((audit) => audit.action)).toEqual([ + 'proposal.approved', + 'talent.created', + ]); + const ownerAudit = await ( + await database.prepare( + "SELECT after_json FROM audit_log WHERE action='talent.created'", + ) + ).get(); + expect(JSON.parse(String(ownerAudit?.after_json))).toMatchObject({ + authoredSource: { owner: { type: 'talent', id: 'draft-talent-1' } }, + selectedRevision: null, + publicProjection: { variants: [{ key: 'I' }, { key: 'II' }] }, + relationships: { + weaponScope: { scope: 'Standard' }, + weaponSlot: { slot: 1 }, + gearTalents: [], + weaponTalentOptions: [], + }, + mutationContext: { origin: 'proposal', proposalId: 'proposal-1' }, + }); + }); + + it('atomically approves a Protocol with authored source and no model', async () => { + await insertProtocolProfile(); + await insertProposal( + 'protocol-proposal', + { + id: 'client-protocol-id', + _proposedEntityId: 'client-protocol-draft', + name: 'Phase 2 Protocol', + quality: 'Standard', + attributeType: 'Firepower', + primaryStatId: 'firepower', + primaryBaseValue: '12', + expectedTalentId: '', + hasTalent: '1', + talentName: 'Phase 2 Protocol Talent', + authoredSource: JSON.stringify(protocolSource()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'clear', + }, + { + draftEntityId: 'protocol-draft', + module: 'protocols', + action: 'save', + }, + ); + + const result = await approvePhase2Proposal( + database, + 'reviewer', + 'protocol-proposal', + null, + ); + + expect(result.entityId).toBe('protocol-draft'); + expect( + await ( + await database.prepare( + 'SELECT id,name,primary_attribute_type FROM os_protocols WHERE id=?', + ) + ).get('protocol-draft'), + ).toEqual({ + id: 'protocol-draft', + name: 'Phase 2 Protocol', + primary_attribute_type: 'Firepower', + }); + expect( + await ( + await database.prepare( + 'SELECT protocol_id,stat_attribute_id,base_value FROM os_protocol_primary_effects WHERE protocol_id=?', + ) + ).get('protocol-draft'), + ).toEqual({ + protocol_id: 'protocol-draft', + stat_attribute_id: 'firepower', + base_value: 12, + }); + const talent = await ( + await database.prepare( + 'SELECT id,protocol_id FROM os_protocol_talents WHERE protocol_id=?', + ) + ).get('protocol-draft'); + expect(talent).toMatchObject({ protocol_id: 'protocol-draft' }); + expect( + await ( + await database.prepare( + 'SELECT owner_type,owner_id,template FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?', + ) + ).get('os_protocol_talent', String(talent?.id)), + ).toMatchObject({ + owner_type: 'os_protocol_talent', + owner_id: talent?.id, + template: 'Gain {1}% weapon damage.', + }); + expect( + await ( + await database.prepare( + 'SELECT status,applied_entity_id FROM data_change_proposals WHERE id=?', + ) + ).get('protocol-proposal'), + ).toEqual({ status: 'approved', applied_entity_id: 'protocol-draft' }); + expect( + await ( + await database.prepare( + "SELECT action FROM audit_log WHERE entity_id IN (?,?) ORDER BY action", + ) + ).all('protocol-draft', 'protocol-proposal'), + ).toEqual([ + { action: 'os_protocol.created' }, + { action: 'proposal.approved' }, + ]); + const ownerAudit = await ( + await database.prepare( + "SELECT after_json FROM audit_log WHERE action='os_protocol.created'", + ) + ).get(); + expect(JSON.parse(String(ownerAudit?.after_json))).toMatchObject({ + mechanic: { + authoredSource: { owner: { type: 'os_protocol_talent' } }, + selectedRevision: null, + publicProjection: { variants: [{ status: 'not_modeled' }] }, + }, + mutationContext: { origin: 'proposal', proposalId: 'protocol-proposal' }, + }); + }); + + it('forces an edit replay to the proposal owner identity', async () => { + await insertProtocolProfile(); + await database.exec(` + INSERT INTO os_protocols( + id,name,quality,primary_attribute_type,created_at,updated_at + ) VALUES('protocol-existing','Original Protocol','Standard','Firepower','now','now'); + INSERT INTO os_protocol_primary_effects( + id,protocol_id,stat_attribute_id,base_value + ) VALUES('primary-existing','protocol-existing','firepower',4); + `); + await insertProposal( + 'protocol-edit-proposal', + { + id: 'client-target', + _proposedEntityId: 'client-draft', + name: 'Updated Protocol', + quality: 'Standard', + attributeType: 'Firepower', + primaryStatId: 'firepower', + primaryBaseValue: '8', + expectedOwnerUpdatedAt: 'now', + expectedTalentId: '', + hasTalent: '0', + }, + { + entityId: 'protocol-existing', + module: 'protocols', + action: 'save', + }, + ); + + const result = await approvePhase2Proposal( + database, + 'reviewer', + 'protocol-edit-proposal', + null, + ); + + expect(result.entityId).toBe('protocol-existing'); + expect( + await ( + await database.prepare('SELECT id,name FROM os_protocols ORDER BY id') + ).all(), + ).toEqual([{ id: 'protocol-existing', name: 'Updated Protocol' }]); + expect( + await ( + await database.prepare( + 'SELECT id FROM os_protocols WHERE id IN (?,?,?)', + ) + ).all('client-target', 'client-draft', 'protocol-edit-proposal'), + ).toEqual([]); + expect( + await ( + await database.prepare( + 'SELECT status,applied_entity_id FROM data_change_proposals WHERE id=?', + ) + ).get('protocol-edit-proposal'), + ).toEqual({ status: 'approved', applied_entity_id: 'protocol-existing' }); + }); + + it('keeps an edit proposal pending when its owner aggregate changes after submission', async () => { + await insertProtocolProfile(); + await database.exec(` + INSERT INTO os_protocols( + id,name,quality,primary_attribute_type,created_at,updated_at + ) VALUES('protocol-stale-owner','Original Protocol','Standard','Firepower','now','owner-version-1'); + INSERT INTO os_protocol_primary_effects( + id,protocol_id,stat_attribute_id,base_value + ) VALUES('primary-stale-owner','protocol-stale-owner','firepower',4); + `); + await insertProposal( + 'protocol-stale-owner-proposal', + { + id: 'protocol-stale-owner', + name: 'Proposed Protocol', + quality: 'Standard', + attributeType: 'Firepower', + primaryStatId: 'firepower', + primaryBaseValue: '8', + expectedOwnerUpdatedAt: 'owner-version-1', + expectedTalentId: '', + hasTalent: '0', + }, + { + entityId: 'protocol-stale-owner', + module: 'protocols', + action: 'save', + }, + ); + await database.exec(`UPDATE os_protocols + SET name='Concurrent Protocol',updated_at='owner-version-2' + WHERE id='protocol-stale-owner'`); + + let staleError: unknown; + try { + await approvePhase2Proposal( + database, + 'reviewer', + 'protocol-stale-owner-proposal', + null, + ); + } catch (error) { + staleError = error; + } + + expect(staleError).toBeInstanceOf(ProposalCanonicalValidationError); + expect(staleError).toMatchObject({ status: 409 }); + expect( + await ( + await database.prepare( + 'SELECT name,updated_at FROM os_protocols WHERE id=?', + ) + ).get('protocol-stale-owner'), + ).toEqual({ name: 'Concurrent Protocol', updated_at: 'owner-version-2' }); + expect( + await ( + await database.prepare( + 'SELECT status FROM data_change_proposals WHERE id=?', + ) + ).get('protocol-stale-owner-proposal'), + ).toEqual({ status: 'pending' }); + expect( + await ( + await database.prepare( + 'SELECT COUNT(*) AS count FROM audit_log WHERE entity_id IN (?,?)', + ) + ).get('protocol-stale-owner', 'protocol-stale-owner-proposal'), + ).toEqual({ count: 0 }); + }); + + it('atomically approves a Skill Chip with a model revision and projection', async () => { + await insertSpecializationFixture(); + await insertProposal( + 'skill-chip-proposal', + { + id: 'client-skill-chip-id', + name: 'Phase 2 Skill Chip', + expectedTalentId: '', + abilityId: 'ability-phase2', + twoPieceStatId: 'weapon-damage', + twoPieceMin: '1', + twoPieceMax: '3', + hasTalent: '1', + talentName: 'Phase 2 Skill Chip Talent', + authoredSource: JSON.stringify(skillChipSource()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'save', + conditionalRule: JSON.stringify(modelDraft()), + }, + { + draftEntityId: 'skill-chip-draft', + module: 'skillchips', + action: 'save', + }, + ); + + const result = await approvePhase2Proposal( + database, + 'reviewer', + 'skill-chip-proposal', + null, + ); + + expect(result.entityId).toBe('skill-chip-draft'); + const talent = await ( + await database.prepare( + 'SELECT id,skill_chip_set_id FROM skill_chip_talents WHERE skill_chip_set_id=?', + ) + ).get('skill-chip-draft'); + expect(talent).toMatchObject({ skill_chip_set_id: 'skill-chip-draft' }); + const sourceRecord = await ( + await database.prepare( + 'SELECT id,owner_type,owner_id FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?', + ) + ).get('skill_chip_talent', String(talent?.id)); + expect(sourceRecord).toMatchObject({ + owner_type: 'skill_chip_talent', + owner_id: talent?.id, + }); + expect( + await ( + await database.prepare( + 'SELECT revision_number,origin,actor_id FROM mechanic_model_revisions WHERE source_id=?', + ) + ).get(sourceRecord?.id), + ).toEqual({ revision_number: 1, origin: 'proposal', actor_id: 'reviewer' }); + expect( + await ( + await database.prepare( + 'SELECT status,display_source,selected_revision_id FROM mechanic_public_variants WHERE source_id=? ORDER BY variant_key', + ) + ).all(sourceRecord?.id), + ).toHaveLength(2); + expect( + await ( + await database.prepare( + 'SELECT status,display_source FROM mechanic_public_variants WHERE source_id=? LIMIT 1', + ) + ).get(sourceRecord?.id), + ).toEqual({ status: 'modeled', display_source: 'model' }); + expect( + await ( + await database.prepare( + 'SELECT status,applied_entity_id FROM data_change_proposals WHERE id=?', + ) + ).get('skill-chip-proposal'), + ).toEqual({ status: 'approved', applied_entity_id: 'skill-chip-draft' }); + const ownerAudit = await ( + await database.prepare( + "SELECT after_json FROM audit_log WHERE action='skill_chip.created'", + ) + ).get(); + const ownerAuditAfter = JSON.parse(String(ownerAudit?.after_json)); + expect(ownerAuditAfter).toMatchObject({ + mechanic: { + authoredSource: { owner: { type: 'skill_chip_talent' } }, + selectedRevision: { revisionNumber: 1 }, + publicProjection: { variants: expect.any(Array) }, + }, + mutationContext: { + origin: 'proposal', + proposalId: 'skill-chip-proposal', + }, + }); + expect(ownerAuditAfter.mechanic.publicProjection.variants).toEqual( + expect.arrayContaining([expect.objectContaining({ status: 'modeled' })]), + ); + expect( + await ( + await database.prepare( + "SELECT action FROM audit_log WHERE entity_id='skill-chip-proposal'", + ) + ).get(), + ).toEqual({ action: 'proposal.approved' }); + }); + + it('atomically approves a specialization tree node and reusable combat effect', async () => { + await insertSpecializationFixture(); + await insertProposal( + 'node-proposal', + { + id: 'client-node-id', + name: 'Phase 2 Node', + focusId: 'focus-phase2', + descriptionTemplate: 'Increase weapon damage.', + rowNumber: '2', + columnNumber: '3', + maxRank: '1', + ranksJson: JSON.stringify([ + { + rank: 1, + description: 'Increase weapon damage.', + effects: [ + { + type: 'Attribute', + statId: 'weapon-damage', + label: 'Weapon Damage', + value: 5, + unit: '%', + }, + ], + combatEffects: [], + costs: {}, + }, + ]), + expectedAttachmentFingerprints: '{}', + }, + { + draftEntityId: 'node-draft', + module: 'specializations', + action: 'saveNode', + }, + ); + await insertProposal( + 'combat-effect-proposal', + { + id: 'client-effect-id', + name: 'Phase 2 Combat Effect', + specializationId: 'specialization-phase2', + descriptionTemplate: 'Gain {1}% Weapon Damage.', + expectedAttachmentFingerprints: '{}', + }, + { + draftEntityId: 'combat-effect-draft', + module: 'specializations', + action: 'saveCombatEffect', + }, + ); + + const nodeResult = await approvePhase2Proposal( + database, + 'reviewer', + 'node-proposal', + null, + ); + const effectResult = await approvePhase2Proposal( + database, + 'reviewer', + 'combat-effect-proposal', + null, + ); + + expect(nodeResult.entityId).toBe('node-draft'); + expect(effectResult.entityId).toBe('combat-effect-draft'); + expect( + await ( + await database.prepare( + 'SELECT id,focus_id,name FROM specialization_tree_nodes WHERE id=?', + ) + ).get('node-draft'), + ).toEqual({ id: 'node-draft', focus_id: 'focus-phase2', name: 'Phase 2 Node' }); + expect( + await ( + await database.prepare( + 'SELECT node_id,rank,description FROM specialization_tree_node_ranks WHERE node_id=?', + ) + ).get('node-draft'), + ).toEqual({ + node_id: 'node-draft', + rank: 1, + description: 'Increase weapon damage.', + }); + expect( + await ( + await database.prepare( + 'SELECT id,specialization_id,name FROM specialization_combat_effects WHERE id=?', + ) + ).get('combat-effect-draft'), + ).toEqual({ + id: 'combat-effect-draft', + specialization_id: 'specialization-phase2', + name: 'Phase 2 Combat Effect', + }); + expect( + await ( + await database.prepare( + 'SELECT status,applied_entity_id FROM data_change_proposals WHERE id IN (?,?) ORDER BY id', + ) + ).all('combat-effect-proposal', 'node-proposal'), + ).toEqual([ + { status: 'approved', applied_entity_id: 'combat-effect-draft' }, + { status: 'approved', applied_entity_id: 'node-draft' }, + ]); + const ownerAudits = await ( + await database.prepare( + "SELECT action,entity_id,after_json FROM audit_log WHERE entity_id IN (?,?) ORDER BY action,entity_id", + ) + ).all('node-draft', 'combat-effect-draft'); + expect(ownerAudits.map((audit) => audit.action)).toEqual([ + 'specialization_combat_effect.created', + 'specialization_node.created', + ]); + expect(JSON.parse(String(ownerAudits[0].after_json))).toMatchObject({ + mutationContext: { + origin: 'proposal', + proposalId: 'combat-effect-proposal', + }, + }); + expect(JSON.parse(String(ownerAudits[1].after_json))).toMatchObject({ + mutationContext: { origin: 'proposal', proposalId: 'node-proposal' }, + }); + expect( + await ( + await database.prepare( + "SELECT COUNT(*) AS count FROM audit_log WHERE action='proposal.approved' AND entity_id IN (?,?)", + ) + ).get('node-proposal', 'combat-effect-proposal'), + ).toEqual({ count: 2 }); + }); + + it('blocks a proposal while a dependency remains unresolved and rejects duplicate approval', async () => { + await insertProposal( + 'dependency', + { + id: '', + name: 'Dependency Talent', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'clear', + }, + { draftEntityId: 'dependency-talent' }, + ); + await insertProposal( + 'dependent', + { + id: '', + name: 'Dependent Talent', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'clear', + }, + { draftEntityId: 'dependent-talent' }, + ); + await ( + await database.prepare( + 'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)', + ) + ).run('dependent', 'dependency'); + + await expect( + approvePhase2Proposal(database, 'reviewer', 'dependent', null), + ).rejects.toBeInstanceOf(ProposalDependenciesBlockedError); + expect( + await ( + await database.prepare( + 'SELECT status FROM data_change_proposals WHERE id=?', + ) + ).get('dependent'), + ).toEqual({ status: 'pending' }); + expect( + await ( + await database.prepare('SELECT COUNT(*) AS count FROM talents') + ).get(), + ).toEqual({ count: 0 }); + + await approvePhase2Proposal(database, 'reviewer', 'dependency', null); + await approvePhase2Proposal(database, 'reviewer', 'dependent', null); + await expect( + approvePhase2Proposal(database, 'reviewer', 'dependent', null), + ).rejects.toBeInstanceOf(ProposalNotPendingError); + }); + + it('rolls back owner media and proposal decision when the proposal audit cannot commit', async () => { + const storageDirectory = await mkdtemp( + path.join(os.tmpdir(), 'shd-media-oversight-test-'), + ); + const previousStorageDirectory = process.env.TDR_GAME_ICON_STORAGE_PATH; + process.env.TDR_GAME_ICON_STORAGE_PATH = storageDirectory; + try { + const pixels = Buffer.alloc(3 * 3 * 4, 255); + for (let index = 1; index < 9; index += 1) pixels[index * 4 + 3] = 0; + const icon = await sharp(pixels, { + raw: { width: 3, height: 3, channels: 4 }, + }) + .png() + .toBuffer(); + const entries = [ + ...Object.entries({ + id: '', + name: 'Audit Failure Talent', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'clear', + }).map(([name, value]) => ({ name, kind: 'text' as const, value })), + { + name: 'icon', + kind: 'file' as const, + value: icon.toString('base64'), + fileName: 'icon.png', + type: 'image/png', + }, + ]; + await ( + await database.prepare(`INSERT INTO data_change_proposals + (id,module,action,entity_id,draft_entity_id,summary,payload_json,proposed_by,created_at) + VALUES(?,?,?,?,?,?,?,'contributor','now')`) + ).run( + 'audit-failure', + 'talents', + 'saveTalent', + null, + '44444444-4444-4444-8444-444444444444', + 'SAVE TALENT · Audit Failure Talent', + JSON.stringify(entries), + ); + await database.exec(`CREATE TRIGGER reject_proposal_audit + BEFORE INSERT ON audit_log WHEN NEW.action='proposal.approved' + BEGIN SELECT RAISE(ABORT, 'proposal audit failed'); END`); + + await expect( + approvePhase2Proposal(database, 'reviewer', 'audit-failure', null), + ).rejects.toThrow('proposal audit failed'); + expect( + await ( + await database.prepare( + 'SELECT status FROM data_change_proposals WHERE id=?', + ) + ).get('audit-failure'), + ).toEqual({ status: 'pending' }); + expect( + await ( + await database.prepare('SELECT COUNT(*) AS count FROM talents') + ).get(), + ).toEqual({ count: 0 }); + expect( + await ( + await database.prepare('SELECT COUNT(*) AS count FROM audit_log') + ).get(), + ).toEqual({ count: 0 }); + await expect( + readFile( + path.join( + storageDirectory, + 'talent-44444444-4444-4444-8444-444444444444.webp', + ), + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + if (previousStorageDirectory === undefined) + delete process.env.TDR_GAME_ICON_STORAGE_PATH; + else process.env.TDR_GAME_ICON_STORAGE_PATH = previousStorageDirectory; + await rm(storageDirectory, { recursive: true, force: true }); + } + }); + + it('keeps a reusable combat-effect proposal pending when an attachment changes after submission', async () => { + await database.exec(` + INSERT INTO specializations(id,name,created_at,updated_at) + VALUES('specialization-1','Specialization','now','now'); + INSERT INTO specialization_focuses(id,specialization_id,name,primary_attribute,created_at,updated_at) + VALUES('focus-1','specialization-1','Focus','Firepower','now','now'); + INSERT INTO specialization_tree_nodes(id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at) + VALUES('node-1','focus-1','Node','Node description.',1,3,1,'now','now'); + INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-1',1,'Rank description'); + INSERT INTO specialization_combat_effects(id,specialization_id,name,description_template,created_at,updated_at) + VALUES('combat-1','specialization-1','Combat effect','Gain {1}% Weapon Damage.','now','now'); + INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks,stacks_per_trigger, + counter_policy,refresh_policy,expiry_policy + ) VALUES + ('attachment-1','node-1',1,'combat-1',1,'enemy_hits_within_window',1,1,1,1,1,'reset_on_proc','refresh_on_stack_gain','clear_all'); + `); + const originalSource = specializationSource('attachment-1', '10'); + const current = await saveMechanicAuthoredSourceInTransaction( + database, + originalSource, + { expectedSourceFingerprint: null }, + ); + await insertProposal( + 'specialization-stale', + { + id: 'combat-1', + specializationId: 'specialization-1', + name: 'Changed effect', + descriptionTemplate: 'Grant {1} Weapon Damage.', + expectedOwnerUpdatedAt: 'now', + expectedAttachmentFingerprints: JSON.stringify({ + 'attachment-1': current.fingerprint, + }), + }, + { + entityId: 'combat-1', + module: 'specializations', + action: 'saveCombatEffect', + }, + ); + await saveMechanicAuthoredSourceInTransaction( + database, + specializationSource('attachment-1', '20'), + { expectedSourceFingerprint: current.fingerprint }, + ); + + let staleError: unknown; + try { + await approvePhase2Proposal( + database, + 'reviewer', + 'specialization-stale', + null, + ); + } catch (error) { + staleError = error; + } + expect(staleError).toBeInstanceOf(ProposalCanonicalValidationError); + expect(staleError).toMatchObject({ status: 409 }); + expect( + await ( + await database.prepare( + 'SELECT status FROM data_change_proposals WHERE id=?', + ) + ).get('specialization-stale'), + ).toEqual({ status: 'pending' }); + expect( + await ( + await database.prepare( + 'SELECT name,description_template FROM specialization_combat_effects WHERE id=?', + ) + ).get('combat-1'), + ).toEqual({ + name: 'Combat effect', + description_template: 'Gain {1}% Weapon Damage.', + }); + expect( + await ( + await database.prepare( + 'SELECT action FROM audit_log WHERE entity_id IN (?,?) ORDER BY action', + ) + ).all('combat-1', 'specialization-stale'), + ).toEqual([]); + }); + + it('rolls back owner writes on a stale fingerprint before marking the proposal failed', async () => { + await insertProposal( + 'initial', + { + id: '', + name: 'Existing Talent', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source()), + expectedSourceFingerprint: '', + mechanicSelectionIntent: 'clear', + }, + { draftEntityId: 'talent-existing' }, + ); + await approvePhase2Proposal(database, 'reviewer', 'initial', null); + await insertProposal( + 'stale', + { + id: 'talent-existing', + name: 'Must Not Apply', + category: 'Weapon Talent 1', + authoredSource: JSON.stringify(source('20')), + expectedSourceFingerprint: 'f'.repeat(64), + mechanicSelectionIntent: 'clear', + }, + { entityId: 'talent-existing' }, + ); + + await expect( + approvePhase2Proposal(database, 'reviewer', 'stale', null), + ).rejects.toBeInstanceOf(ProposalCanonicalValidationError); + const failed = await failPhase2Proposal( + database, + 'reviewer', + 'stale', + 'The authored mechanic changed after this editor was loaded.', + ); + expect(failed.id).toBe('stale'); + expect( + await ( + await database.prepare('SELECT name FROM talents WHERE id=?') + ).get('talent-existing'), + ).toEqual({ name: 'Existing Talent' }); + expect( + await ( + await database.prepare( + 'SELECT status FROM data_change_proposals WHERE id=?', + ) + ).get('stale'), + ).toEqual({ status: 'failed' }); + }); +}); + +describe('stored proposal payload recovery', () => { + it('keeps malformed pending registry entries visible without throwing', async () => { + await insertProposal('malformed-pending', { name: 'Malformed' }, { + draftEntityId: 'malformed-draft', + }); + await ( + await database.prepare( + 'UPDATE data_change_proposals SET payload_json=? WHERE id=?', + ) + ).run('not-json', 'malformed-pending'); + + const pending = await getPendingEntities('contributor', [ + { module: 'talents', action: 'saveTalent', kind: 'talent' }, + ]); + + expect(pending).toEqual([ + expect.objectContaining({ + id: 'malformed-draft', + proposalId: 'malformed-pending', + name: 'UNUSABLE PROPOSAL', + proposerName: 'Contributor', + unusable: true, + }), + ]); + }); + + it('does not rewrite dependency links when refreshing a malformed payload', async () => { + await insertProposal('dependency', { name: 'Dependency' }, { + draftEntityId: 'dependency-draft', + }); + await insertProposal('malformed-refresh', { name: 'Malformed' }, { + draftEntityId: 'malformed-refresh-draft', + }); + await ( + await database.prepare( + 'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)', + ) + ).run('malformed-refresh', 'dependency'); + + await expect( + refreshProposalDependencies('malformed-refresh', 'not-json', database), + ).resolves.toEqual([]); + expect( + await ( + await database.prepare( + 'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=?', + ) + ).all('malformed-refresh'), + ).toEqual([{ depends_on_proposal_id: 'dependency' }]); + }); + + it('refreshes dependencies only from pending proposals with draft identities', async () => { + await insertProposal('old-dependency', { name: 'Old' }, { + draftEntityId: 'old-refresh-draft', + }); + await insertProposal('failed-dependency', { name: 'Failed' }, { + draftEntityId: 'failed-refresh-draft', + }); + await insertProposal('rejected-dependency', { name: 'Rejected' }, { + draftEntityId: 'rejected-refresh-draft', + }); + await insertProposal('pending-dependency', { name: 'Pending' }, { + draftEntityId: 'pending-refresh-draft', + }); + await insertProposal('refresh', { name: 'Refresh' }, { + draftEntityId: 'refresh-draft', + }); + await database.exec(` + UPDATE data_change_proposals SET status='failed' WHERE id='failed-dependency'; + UPDATE data_change_proposals SET status='rejected' WHERE id='rejected-dependency'; + `); + await ( + await database.prepare( + 'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)', + ) + ).run('refresh', 'old-dependency'); + + await expect( + refreshProposalDependencies( + 'refresh', + payload({ + name: 'failed-refresh-draft rejected-refresh-draft pending-refresh-draft', + }), + database, + ), + ).resolves.toEqual(['pending-dependency']); + expect( + await ( + await database.prepare( + 'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=?', + ) + ).all('refresh'), + ).toEqual([{ depends_on_proposal_id: 'pending-dependency' }]); + }); + + it('rejects a direct dependency cycle without replacing existing links', async () => { + await insertProposal('cycle-a', { name: 'A' }, { + draftEntityId: 'cycle-a-draft', + }); + await insertProposal('cycle-b', { name: 'B' }, { + draftEntityId: 'cycle-b-draft', + }); + await insertProposal('cycle-old', { name: 'Old' }, { + draftEntityId: 'cycle-old-draft', + }); + await database.exec(` + INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) + VALUES('cycle-b','cycle-a'),('cycle-a','cycle-old'); + `); + + await expect( + refreshProposalDependencies( + 'cycle-a', + payload({ name: 'cycle-b-draft' }), + database, + ), + ).rejects.toMatchObject({ + constructor: ProposalDependencyCycleError, + proposalId: 'cycle-a', + dependencyId: 'cycle-b', + }); + expect( + await ( + await database.prepare( + 'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id', + ) + ).all('cycle-a'), + ).toEqual([{ depends_on_proposal_id: 'cycle-old' }]); + }); + + it('rejects a transitive dependency cycle without replacing existing links', async () => { + await insertProposal('chain-a', { name: 'A' }, { + draftEntityId: 'chain-a-draft', + }); + await insertProposal('chain-b', { name: 'B' }, { + draftEntityId: 'chain-b-draft', + }); + await insertProposal('chain-c', { name: 'C' }, { + draftEntityId: 'chain-c-draft', + }); + await insertProposal('chain-old', { name: 'Old' }, { + draftEntityId: 'chain-old-draft', + }); + await database.exec(` + INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) + VALUES('chain-a','chain-b'),('chain-b','chain-c'),('chain-c','chain-old'); + `); + + await expect( + refreshProposalDependencies( + 'chain-c', + payload({ name: 'chain-a-draft' }), + database, + ), + ).rejects.toMatchObject({ + constructor: ProposalDependencyCycleError, + proposalId: 'chain-c', + dependencyId: 'chain-a', + }); + expect( + await ( + await database.prepare( + 'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id', + ) + ).all('chain-c'), + ).toEqual([{ depends_on_proposal_id: 'chain-old' }]); + }); +}); + +describe('contributor proposal payload bounds', () => { + const serialize = (form: FormData) => serializeProposal(form); + + it('accepts exact text and entry boundaries', async () => { + const text = new FormData(); + text.set('value', 'x'.repeat(MAX_PROPOSAL_TEXT_ENTRY_CHARS)); + await expect(serialize(text)).resolves.toHaveLength(1); + + const entries = new FormData(); + for (let index = 0; index < MAX_PROPOSAL_ENTRIES; index += 1) + entries.append(`field-${index}`, 'x'); + await expect(serialize(entries)).resolves.toHaveLength( + MAX_PROPOSAL_ENTRIES, + ); + }); + + it('rejects oversized text, aggregate text, fields, and names', async () => { + const oversizedEntry = new FormData(); + oversizedEntry.set('value', 'x'.repeat(MAX_PROPOSAL_TEXT_ENTRY_CHARS + 1)); + await expect(serialize(oversizedEntry)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const exactAggregate = new FormData(); + const aggregateEntry = 'x'.repeat(100_000); + for (let index = 0; index < 20; index += 1) + exactAggregate.append(`field-${index}`, aggregateEntry); + exactAggregate.append( + 'last', + 'x'.repeat(MAX_PROPOSAL_TEXT_BYTES - 2_000_000), + ); + await expect(serialize(exactAggregate)).resolves.toHaveLength(21); + + const aggregate = new FormData(); + for (let index = 0; index < 20; index += 1) + aggregate.append(`field-${index}`, aggregateEntry); + aggregate.append( + 'over', + 'x'.repeat(MAX_PROPOSAL_TEXT_BYTES - 2_000_000 + 1), + ); + await expect(serialize(aggregate)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const tooMany = new FormData(); + for (let index = 0; index <= MAX_PROPOSAL_ENTRIES; index += 1) + tooMany.append(`field-${index}`, 'x'); + await expect(serialize(tooMany)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const controlName = new FormData(); + controlName.append('bad\u0000name', 'x'); + await expect(serialize(controlName)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const longName = new FormData(); + longName.append('x'.repeat(MAX_PROPOSAL_FIELD_NAME_CHARS + 1), 'x'); + await expect(serialize(longName)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + }); + + it('bounds files before base64 while preserving valid icon and render files', async () => { + const valid = new FormData(); + valid.append( + 'icon', + new Blob([Buffer.from('icon')], { type: 'image/png' }), + 'icon.png', + ); + valid.append( + 'render', + new Blob([Buffer.from('render')], { type: 'image/png' }), + 'render.png', + ); + const serialized = await serialize(valid); + expect(serialized).toHaveLength(2); + expect(serialized.every((entry) => entry.kind === 'file')).toBe(true); + + const exactFile = new FormData(); + exactFile.append( + 'file', + new Blob([Buffer.alloc(MAX_PROPOSAL_FILE_BYTES)]), + 'exact.bin', + ); + await expect(serialize(exactFile)).resolves.toHaveLength(1); + + const exactMetadata = new FormData(); + exactMetadata.append( + 'file', + new Blob([Buffer.from('metadata')], { + type: 'x'.repeat(MAX_PROPOSAL_FILE_TYPE_CHARS), + }), + 'x'.repeat(MAX_PROPOSAL_FILE_NAME_CHARS), + ); + await expect(serialize(exactMetadata)).resolves.toHaveLength(1); + + const oversizedName = new FormData(); + oversizedName.append( + 'file', + new Blob([Buffer.from('name')]), + 'x'.repeat(MAX_PROPOSAL_FILE_NAME_CHARS + 1), + ); + await expect(serialize(oversizedName)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const oversizedType = new FormData(); + oversizedType.append( + 'file', + new Blob([Buffer.from('type')], { + type: 'x'.repeat(MAX_PROPOSAL_FILE_TYPE_CHARS + 1), + }), + 'type.bin', + ); + await expect(serialize(oversizedType)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const controlMetadata = new FormData(); + controlMetadata.append( + 'file', + new Blob([Buffer.from('control')]), + 'bad\u0000name', + ); + await expect(serialize(controlMetadata)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + expect(() => + validateSerializedProposalEntries([ + { + name: 'file', + kind: 'file', + value: 'Y29udHJvbA==', + fileName: 'file.bin', + type: 'bad\u0000type', + }, + ]), + ).toThrow(ProposalPayloadValidationError); + + const oversized = new FormData(); + oversized.append( + 'file', + new Blob([Buffer.alloc(MAX_PROPOSAL_FILE_BYTES + 1)]), + 'large.bin', + ); + await expect(serialize(oversized)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + + const exactAggregate = new FormData(); + for (let index = 0; index < 3; index += 1) + exactAggregate.append( + `file-${index}`, + new Blob([Buffer.alloc(4 * 1024 * 1024)]), + `${index}.bin`, + ); + await expect(serialize(exactAggregate)).resolves.toHaveLength(3); + + const aggregate = new FormData(); + for (let index = 0; index < 3; index += 1) + aggregate.append( + `file-${index}`, + new Blob([Buffer.alloc(4 * 1024 * 1024)]), + `${index}.bin`, + ); + aggregate.append( + 'over', + new Blob([ + Buffer.alloc(MAX_PROPOSAL_FILE_BYTES_TOTAL - 12 * 1024 * 1024 + 1), + ]), + 'over.bin', + ); + await expect(serialize(aggregate)).rejects.toBeInstanceOf( + ProposalPayloadValidationError, + ); + }); +}); + +describe('serialized proposal file data', () => { + const fileEntry = (value: string) => [ + { + name: 'file', + kind: 'file' as const, + value, + fileName: 'file.bin', + type: 'application/octet-stream', + }, + ]; + + it('rejects invalid alphabet, padding, length, noncanonical, and empty data', () => { + for (const value of ['@@@', 'AA=A', 'YQ=', 'YQ===', 'AB==', '']) { + expect(() => validateSerializedProposalEntries(fileEntry(value))).toThrow( + ProposalPayloadValidationError, + ); + expect(() => + deserializeProposal(JSON.stringify(fileEntry(value))), + ).toThrow(ProposalPayloadValidationError); + } + }); + + it('rejects encoded file data beyond the per-file Base64 bound', () => { + const oversizedValue = 'A'.repeat( + 4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3) + 4, + ); + expect(() => + validateSerializedProposalEntries(fileEntry(oversizedValue)), + ).toThrow(ProposalPayloadValidationError); + }); + + it('round trips valid serialized file data without changing its bytes', async () => { + const bytes = Buffer.from('valid file data'); + const original = new FormData(); + original.append( + 'file', + new Blob([bytes], { type: 'application/octet-stream' }), + 'file.bin', + ); + + const serialized = await serializeProposal(original); + expect(() => validateSerializedProposalEntries(serialized)).not.toThrow(); + const restored = deserializeProposal(JSON.stringify(serialized)); + const restoredFile = restored.get('file'); + expect(restoredFile).toBeInstanceOf(File); + if (!(restoredFile instanceof File)) throw new Error('Expected a file.'); + expect(restoredFile.name).toBe('file.bin'); + expect(restoredFile.type).toBe('application/octet-stream'); + expect(Buffer.from(await restoredFile.arrayBuffer())).toEqual(bytes); + }); +}); + +describe('atomic contributor proposal queue', () => { + it('records only contributor-note presence in the immutable creation audit', async () => { + const form = new FormData(); + form.set('id', ''); + form.set('name', 'Proposal with note'); + form.set('contributorNote', 'creation-note-sentinel'); + + const result = await queueDataProposal( + { + id: 'contributor', + username: 'Contributor', + loginUsername: null, + displayName: 'Contributor', + publicSlug: null, + role: 'admin', + discordLinked: false, + avatarUrl: '', + }, + 'talents', + 'saveTalent', + form, + ); + const audit = await ( + await database.prepare( + "SELECT before_json,after_json FROM audit_log WHERE action='proposal.created' AND entity_id=?", + ) + ).get(result.id); + expect(audit?.before_json).toBe('null'); + expect(String(audit?.after_json)).not.toContain('creation-note-sentinel'); + expect(JSON.parse(String(audit?.after_json))).toMatchObject({ + contributorNotePresent: true, + }); + expect( + await ( + await database.prepare( + 'SELECT contributor_note FROM data_change_proposals WHERE id=?', + ) + ).get(result.id), + ).toEqual({ contributor_note: 'creation-note-sentinel' }); + }); + + it('records no failure message in the immutable failure audit', async () => { + await insertProposal('failed-note', { name: 'Failed proposal' }); + await failPhase2Proposal( + database, + 'reviewer', + 'failed-note', + 'failure-note-sentinel', + ); + const audit = await ( + await database.prepare( + "SELECT after_json FROM audit_log WHERE action='proposal.failed' AND entity_id='failed-note'", + ) + ).get(); + expect(String(audit?.after_json)).not.toContain('failure-note-sentinel'); + expect(JSON.parse(String(audit?.after_json))).toEqual({ + status: 'failed', + messagePresent: true, + }); + }); + + it('rolls back the proposal and dependencies when creation audit fails', async () => { + await insertProposal( + 'dependency', + { name: 'Dependency' }, + { draftEntityId: 'dependency-id' }, + ); + await database.exec(`CREATE TRIGGER reject_created_proposal_audit + BEFORE INSERT ON audit_log WHEN NEW.action='proposal.created' + BEGIN SELECT RAISE(ABORT, 'proposal creation audit failed'); END`); + + const form = new FormData(); + form.set('id', ''); + form.set('name', 'Uses dependency-id'); + await expect( + queueDataProposal( + { + id: 'contributor', + username: 'Contributor', + loginUsername: null, + displayName: 'Contributor', + publicSlug: null, + role: 'admin', + discordLinked: false, + avatarUrl: '', + }, + 'talents', + 'saveTalent', + form, + ), + ).rejects.toThrow('proposal creation audit failed'); + expect( + await ( + await database.prepare( + 'SELECT COUNT(*) AS count FROM data_change_proposals WHERE id<>?', + ) + ).get('dependency'), + ).toEqual({ count: 0 }); + expect( + await ( + await database.prepare( + 'SELECT COUNT(*) AS count FROM data_change_proposal_dependencies', + ) + ).get(), + ).toEqual({ count: 0 }); + expect( + await ( + await database.prepare('SELECT COUNT(*) AS count FROM audit_log') + ).get(), + ).toEqual({ count: 0 }); + }); +}); diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index b43f20f..11536cc 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -5,6 +5,37 @@ import type { AuthUser } from '$lib/server/auth/session'; import { recordIsacAudit } from '$lib/server/audit'; import { getContributorMode } from '$lib/server/contributor-mode'; import { notifyOversightReview } from '$lib/server/oversight-notifications'; +import type { Database } from '@tursodatabase/database'; +import { GameDataIconValidationError } from '$lib/server/game-data-icons'; +import { ManagedImageValidationError } from '$lib/server/managed-images'; +import { MediaMutationJournal } from '$lib/server/media-journal'; + +import { + readTalentFormMedia, + saveTalentFromFormInTransaction, + TalentNotFoundError, + type SaveTalentInTransactionResult, +} from '../../routes/isac/talents/talent-save'; +import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources'; +import { + parseProtocolForm, + ProtocolFormValidationError, + ProtocolNotFoundError, + saveProtocolInTransaction, +} from '../../routes/isac/protocols/protocol-save'; +import { + _saveSkillChipInTransaction, + parseSkillChipForm, + SkillChipFormValidationError, + SkillChipNotFoundError, +} from '../../routes/isac/skillchips/skillchip-save'; +import { + parseSpecializationCombatEffectForm, + parseSpecializationNodeForm, + saveSpecializationCombatEffectInTransaction, + saveSpecializationNodeInTransaction, + SpecializationFormValidationError, +} from '../../routes/isac/specializations/specialization-save'; export const DATA_MODULES = [ 'configuration', @@ -93,7 +124,22 @@ export function proposalActionResult( : { proposalQueued: true, proposalId, resultToken: randomUUID() }; } -type SerializedEntry = +export const MAX_PROPOSAL_ENTRIES = 300; +export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000; +export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024; +export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024; +const MAX_PROPOSAL_FILE_BASE64_CHARS = + 4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3); +export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200; +export const MAX_PROPOSAL_FILE_NAME_CHARS = 255; +export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255; + +const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const PROPOSAL_BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +export type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; @@ -103,58 +149,647 @@ type SerializedEntry = type: string; }; -type PendingDefinition = { +export class ProposalPayloadValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProposalPayloadValidationError'; + } +} + +export type PendingDefinition = { module: DataModule; action: string; kind: string; fields?: string[]; }; -async function serialize(form: FormData): Promise { +function validateProposalFieldName(name: string) { + if ( + Array.from(name).length > MAX_PROPOSAL_FIELD_NAME_CHARS || + PROPOSAL_CONTROL_CHARACTERS.test(name) + ) + throw new ProposalPayloadValidationError( + `Proposal field names must be at most ${MAX_PROPOSAL_FIELD_NAME_CHARS} characters and contain no control characters.`, + ); +} + +function validateProposalFileMetadata(fileName: string, type: string) { + if ( + Array.from(fileName).length > MAX_PROPOSAL_FILE_NAME_CHARS || + PROPOSAL_CONTROL_CHARACTERS.test(fileName) + ) + throw new ProposalPayloadValidationError( + `Proposal file names must be at most ${MAX_PROPOSAL_FILE_NAME_CHARS} characters and contain no control characters.`, + ); + if ( + Array.from(type).length > MAX_PROPOSAL_FILE_TYPE_CHARS || + PROPOSAL_CONTROL_CHARACTERS.test(type) + ) + throw new ProposalPayloadValidationError( + `Proposal file types must be at most ${MAX_PROPOSAL_FILE_TYPE_CHARS} characters and contain no control characters.`, + ); +} + +function decodeSerializedProposalFile(value: string) { + if (value.length > MAX_PROPOSAL_FILE_BASE64_CHARS) + throw new ProposalPayloadValidationError('Proposal file data is invalid.'); + if (!PROPOSAL_BASE64_PATTERN.test(value)) + throw new ProposalPayloadValidationError('Proposal file data is invalid.'); + const bytes = Buffer.from(value, 'base64'); + if (!bytes.length || bytes.toString('base64') !== value) + throw new ProposalPayloadValidationError('Proposal file data is invalid.'); + return bytes; +} + +export function validateSerializedProposalEntries(entries: SerializedEntry[]) { + if (!Array.isArray(entries)) + throw new ProposalPayloadValidationError('Proposal entries are invalid.'); + if (entries.length > MAX_PROPOSAL_ENTRIES) + throw new ProposalPayloadValidationError( + `A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`, + ); + + let textBytes = 0; + let fileBytes = 0; + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') + throw new ProposalPayloadValidationError('Proposal entries are invalid.'); + validateProposalFieldName(entry.name); + if (entry.kind === 'text') { + if (typeof entry.value !== 'string') + throw new ProposalPayloadValidationError( + 'Proposal entries are invalid.', + ); + if (Array.from(entry.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS) + throw new ProposalPayloadValidationError( + `Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`, + ); + textBytes += Buffer.byteLength(entry.value, 'utf8'); + if (textBytes > MAX_PROPOSAL_TEXT_BYTES) + throw new ProposalPayloadValidationError( + 'The aggregate proposal text exceeds the safe size limit.', + ); + continue; + } + if (entry.kind !== 'file') + throw new ProposalPayloadValidationError('Proposal entries are invalid.'); + if ( + typeof entry.value !== 'string' || + typeof entry.fileName !== 'string' || + typeof entry.type !== 'string' + ) + throw new ProposalPayloadValidationError('Proposal entries are invalid.'); + validateProposalFileMetadata(entry.fileName, entry.type); + const bytes = decodeSerializedProposalFile(entry.value); + if (bytes.length > MAX_PROPOSAL_FILE_BYTES) + throw new ProposalPayloadValidationError( + 'Proposal files exceed the safe per-file size limit.', + ); + fileBytes += bytes.length; + if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL) + throw new ProposalPayloadValidationError( + 'The aggregate proposal files exceed the safe size limit.', + ); + } +} + +export async function serializeProposal( + form: FormData, +): Promise { const entries: SerializedEntry[] = []; + let entryCount = 0; + let textBytes = 0; + let fileBytes = 0; + for (const [name, value] of form.entries()) { - if (typeof value === 'string') entries.push({ name, kind: 'text', value }); - else if (value.size > 0) - entries.push({ - name, - kind: 'file', - value: Buffer.from(await value.arrayBuffer()).toString('base64'), - fileName: value.name, - type: value.type, - }); + entryCount += 1; + if (entryCount > MAX_PROPOSAL_ENTRIES) + throw new ProposalPayloadValidationError( + `A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`, + ); + validateProposalFieldName(name); + + if (typeof value === 'string') { + if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS) + throw new ProposalPayloadValidationError( + `Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`, + ); + textBytes += Buffer.byteLength(value, 'utf8'); + if (textBytes > MAX_PROPOSAL_TEXT_BYTES) + throw new ProposalPayloadValidationError( + 'The aggregate proposal text exceeds the safe size limit.', + ); + entries.push({ name, kind: 'text', value }); + continue; + } + + validateProposalFileMetadata(value.name, value.type); + if (value.size === 0) continue; + if (value.size > MAX_PROPOSAL_FILE_BYTES) + throw new ProposalPayloadValidationError( + 'Proposal files exceed the safe per-file size limit.', + ); + if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL) + throw new ProposalPayloadValidationError( + 'The aggregate proposal files exceed the safe size limit.', + ); + const bytes = Buffer.from(await value.arrayBuffer()); + if (bytes.length > MAX_PROPOSAL_FILE_BYTES) + throw new ProposalPayloadValidationError( + 'Proposal files exceed the safe per-file size limit.', + ); + fileBytes += bytes.length; + if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL) + throw new ProposalPayloadValidationError( + 'The aggregate proposal files exceed the safe size limit.', + ); + entries.push({ + name, + kind: 'file', + value: bytes.toString('base64'), + fileName: value.name, + type: value.type, + }); } return entries; } +export function parseSerializedProposalEntries( + payload: string, +): SerializedEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + validateSerializedProposalEntries(parsed as SerializedEntry[]); + } catch (error) { + if (error instanceof ProposalPayloadValidationError) throw error; + throw new ProposalPayloadValidationError('Proposal entries are invalid.'); + } + return parsed as SerializedEntry[]; +} + export function deserializeProposal(payload: string): FormData { + const entries = parseSerializedProposalEntries(payload); const form = new FormData(); - for (const entry of JSON.parse(payload) as SerializedEntry[]) { + for (const entry of entries) { if (entry.kind === 'text') form.append(entry.name, entry.value); else form.append( entry.name, - new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }), + new Blob([decodeSerializedProposalFile(entry.value)], { + type: entry.type, + }), entry.fileName, ); } return form; } +export class ProposalNotPendingError extends Error { + constructor() { + super('That pending proposal no longer exists.'); + this.name = 'ProposalNotPendingError'; + } +} + +export class ProposalDependenciesBlockedError extends Error { + readonly dependencies: string; + + constructor(dependencies: string) { + super(`Approve this proposal's dependencies first: ${dependencies}.`); + this.name = 'ProposalDependenciesBlockedError'; + this.dependencies = dependencies; + } +} + +export class ProposalDependencyCycleError extends Error { + readonly proposalId: string; + readonly dependencyId: string; + + constructor(proposalId: string, dependencyId: string) { + super( + `Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`, + ); + this.name = 'ProposalDependencyCycleError'; + this.proposalId = proposalId; + this.dependencyId = dependencyId; + } +} + +export class ProposalCanonicalValidationError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'ProposalCanonicalValidationError'; + this.status = status; + } +} + +const safeValidationMessage = (error: unknown) => { + const message = error instanceof Error ? error.message : ''; + return message && message.length <= 500 + ? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim() + : 'The proposal did not pass current canonical validation.'; +}; + +function canonicalFailure( + error: unknown, +): ProposalCanonicalValidationError | null { + if (error instanceof ProposalCanonicalValidationError) + return new ProposalCanonicalValidationError( + error.status, + safeValidationMessage(error), + ); + if (error instanceof ProposalPayloadValidationError) + return new ProposalCanonicalValidationError( + 400, + 'The stored proposal payload is invalid.', + ); + if (error instanceof MechanicSourceFingerprintConflictError) + return new ProposalCanonicalValidationError( + 409, + safeValidationMessage(error), + ); + if ( + error instanceof GameDataIconValidationError || + error instanceof ManagedImageValidationError + ) + return new ProposalCanonicalValidationError( + 400, + safeValidationMessage(error), + ); + if ( + error instanceof ProtocolFormValidationError || + error instanceof SkillChipFormValidationError || + error instanceof SpecializationFormValidationError + ) + return new ProposalCanonicalValidationError( + error.status, + safeValidationMessage(error), + ); + if ( + error instanceof ProtocolNotFoundError || + error instanceof TalentNotFoundError || + error instanceof SkillChipNotFoundError + ) + return new ProposalCanonicalValidationError( + 404, + safeValidationMessage(error), + ); + if ( + error instanceof Error && + /no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test( + error.message, + ) + ) + return new ProposalCanonicalValidationError( + 409, + safeValidationMessage(error), + ); + return null; +} + +const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([ + 'The specialization tree node no longer exists.', + 'The specialization combat effect no longer exists.', +]); + +const SPECIALIZATION_CONFLICT_ERRORS = new Set([ + 'The node identity does not match the supplied record.', + 'A saved tree node cannot be moved to another focus.', + 'A combat-effect attachment was submitted more than once.', + 'A submitted combat-effect attachment does not belong to this node.', + 'A new combat-effect attachment reused an existing ID.', + 'The combat-effect identity does not match the supplied record.', + 'A saved combat effect cannot be moved to another specialization.', +]); + +function classifySpecializationTransactionError(error: unknown) { + if (error instanceof SpecializationFormValidationError) return error; + if (error instanceof MechanicSourceFingerprintConflictError) return error; + if (!(error instanceof Error)) return null; + const message = error.message.trim(); + if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message)) + return new SpecializationFormValidationError( + 404, + safeValidationMessage(error), + ); + if (SPECIALIZATION_CONFLICT_ERRORS.has(message)) + return new SpecializationFormValidationError( + 409, + safeValidationMessage(error), + ); + const isKnownValidationMessage = + message === 'Every conditional effect requires an authored description.' || + message === + 'The authored mechanic owner does not match its attachment.' || + message === 'One or more conditional effects are unavailable for this specialization.' || + message === 'One or more authored description Attributes are unavailable.' || + message.startsWith('The authored template ') || + message.startsWith('Clause ') || + message.startsWith('Conditional mechanic '); + return isKnownValidationMessage + ? new SpecializationFormValidationError(400, safeValidationMessage(error)) + : null; +} + +function enforcePhase2ProposalIdentity( + proposal: Record, + form: FormData, +) { + const entityId = String(proposal.entity_id ?? '').trim(); + if (entityId) { + form.set('id', entityId); + form.delete('_proposedEntityId'); + return; + } + const draftEntityId = String(proposal.draft_entity_id ?? '').trim(); + if (!draftEntityId) + throw new ProposalCanonicalValidationError( + 400, + 'The proposal does not have a stored draft identity.', + ); + form.delete('id'); + form.set('_proposedEntityId', draftEntityId); +} + +async function replayPhase2Owner( + database: Database, + proposal: Record, + reviewerId: string, + form: FormData, + proposalId: string, + journal: MediaMutationJournal, +) { + const mutationContext = { origin: 'proposal' as const, proposalId }; + switch (`${String(proposal.module)}:${String(proposal.action)}`) { + case 'talents:saveTalent': { + const media = await readTalentFormMedia(form); + const result: SaveTalentInTransactionResult = + await saveTalentFromFormInTransaction(database, { + form, + operatorId: reviewerId, + proposedEntityId: + String(proposal.draft_entity_id ?? '').trim() || undefined, + ...media, + mutationContext, + journal, + }); + if (!result.ok) + throw new ProposalCanonicalValidationError( + result.status, + result.errors[0]?.message ?? 'The talent could not be saved.', + ); + return { entityId: result.talentId, mode: result.talentMode }; + } + case 'protocols:save': { + const input = await parseProtocolForm( + database, + form, + reviewerId, + mutationContext, + ); + const result = await saveProtocolInTransaction(database, { + ...input, + journal, + }); + return { entityId: result.protocolId, mode: result.mode }; + } + case 'skillchips:save': { + const input = await parseSkillChipForm( + database, + form, + reviewerId, + mutationContext, + ); + const result = await _saveSkillChipInTransaction(database, { + ...input, + journal, + }); + return { entityId: result.setId, mode: result.mode }; + } + case 'specializations:saveNode': { + const input = await parseSpecializationNodeForm( + database, + form, + reviewerId, + mutationContext, + ); + try { + const result = await saveSpecializationNodeInTransaction(database, { + ...input, + journal, + }); + return { + entityId: input.nodeId, + mode: input.id ? 'edit' : 'add', + result, + }; + } catch (error) { + throw classifySpecializationTransactionError(error) ?? error; + } + } + case 'specializations:saveCombatEffect': { + const input = await parseSpecializationCombatEffectForm( + database, + form, + reviewerId, + mutationContext, + ); + try { + const result = await saveSpecializationCombatEffectInTransaction( + database, + input, + ); + return { + entityId: input.effectId, + mode: input.id ? 'edit' : 'add', + result, + }; + } catch (error) { + throw classifySpecializationTransactionError(error) ?? error; + } + } + default: + throw new Error('Unsupported Phase 2 proposal action.'); + } +} + +export type Phase2ApprovalResult = { + proposal: Record; + entityId: string; + mode: string; +}; + +/** Runs one Phase 2 owner replay and proposal decision in one immediate tx. */ +export async function approvePhase2Proposal( + database: Database, + reviewerId: string, + proposalId: string, + note: string | null, +): Promise { + const journal = new MediaMutationJournal(); + try { + const operation = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const proposal = await ( + await tx.prepare( + "SELECT * FROM data_change_proposals WHERE id=? AND status='pending'", + ) + ).get(proposalId); + if (!proposal) throw new ProposalNotPendingError(); + const claimed = await ( + await tx.prepare( + "UPDATE data_change_proposals SET status='approved' WHERE id=? AND status='pending'", + ) + ).run(proposalId); + if (!Number(claimed?.changes ?? 0)) throw new ProposalNotPendingError(); + const unresolved = await ( + await tx.prepare(`SELECT dependency.id,dependency.summary,dependency.status + FROM data_change_proposal_dependencies link + JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id + WHERE link.proposal_id=? AND dependency.status<>'approved'`) + ).all(proposalId); + if (unresolved.length) { + const names = unresolved + .map((item) => `${item.summary} (${item.status})`) + .join(', '); + throw new ProposalDependenciesBlockedError(names); + } + let result: { entityId: string; mode: string }; + try { + const form = deserializeProposal(String(proposal.payload_json)); + enforcePhase2ProposalIdentity(proposal as Record, form); + result = await replayPhase2Owner( + tx, + proposal, + reviewerId, + form, + proposalId, + journal, + ); + } catch (error) { + const failure = canonicalFailure(error); + if (failure) throw failure; + throw error; + } + const now = new Date().toISOString(); + const updated = await ( + await tx.prepare( + "UPDATE data_change_proposals SET applied_entity_id=?,reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=? AND status='approved'", + ) + ).run(result.entityId, reviewerId, note || null, now, now, proposalId); + if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); + await recordIsacAudit({ + actorId: reviewerId, + category: 'data_moderation', + action: 'proposal.approved', + entityId: proposalId, + before: { status: 'pending' }, + after: { + status: 'approved', + module: proposal.module, + action: proposal.action, + proposedBy: proposal.proposed_by, + appliedEntityId: result.entityId, + }, + database: tx, + }); + return { proposal, entityId: result.entityId, mode: result.mode }; + }); + const result = await operation.immediate(); + journal.clear(); + return result; + } catch (error) { + await journal.rollback(error); + throw error; + } +} + +export async function failPhase2Proposal( + database: Database, + reviewerId: string, + proposalId: string, + message: string, +) { + const operation = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const proposal = await ( + await tx.prepare( + "SELECT * FROM data_change_proposals WHERE id=? AND status='pending'", + ) + ).get(proposalId); + if (!proposal) throw new ProposalNotPendingError(); + const now = new Date().toISOString(); + const updated = await ( + await tx.prepare( + "UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'", + ) + ).run(reviewerId, message, now, proposalId); + if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); + await recordIsacAudit({ + actorId: reviewerId, + category: 'data_moderation', + action: 'proposal.failed', + entityId: proposalId, + before: { status: 'pending' }, + after: { status: 'failed', messagePresent: Boolean(message) }, + database: tx, + }); + return proposal as Record; + }); + return operation.immediate(); +} + export async function refreshProposalDependencies( proposalId: string, payload: string, + suppliedDatabase?: Database, ) { - const database = await getDatabase(); - const text = serializedTextValues(payload) - .map((entry) => entry.value) - .join('\n'); + const textValues = serializedTextValues(payload); + // A malformed persisted payload cannot provide reliable dependency evidence. + // Leave existing links intact rather than mutating state during recovery. + if (!textValues) return []; + const database = suppliedDatabase ?? (await getDatabase()); + const text = textValues.map((entry) => entry.value).join('\n'); const candidates = await ( await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals - WHERE id<>? AND draft_entity_id IS NOT NULL`) + WHERE id<>? AND status='pending' AND draft_entity_id IS NOT NULL`) ).all(proposalId); const dependencies = candidates.filter((candidate) => text.includes(String(candidate.draft_entity_id)), ); + + const existingLinks = await ( + await database.prepare( + 'SELECT proposal_id,depends_on_proposal_id FROM data_change_proposal_dependencies', + ) + ).all(); + const links = new Map(); + for (const link of existingLinks) { + const proposalLinks = links.get(String(link.proposal_id)) ?? []; + proposalLinks.push(String(link.depends_on_proposal_id)); + links.set(String(link.proposal_id), proposalLinks); + } + const reachesProposal = (start: string) => { + const visited = new Set(); + const toVisit = [start]; + while (toVisit.length) { + const current = toVisit.pop()!; + if (current === proposalId) return true; + if (visited.has(current)) continue; + visited.add(current); + toVisit.push(...(links.get(current) ?? [])); + } + return false; + }; + for (const dependency of dependencies) { + const dependencyId = String(dependency.id); + if (reachesProposal(dependencyId)) + throw new ProposalDependencyCycleError(proposalId, dependencyId); + } + await ( await database.prepare( 'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?', @@ -170,11 +805,19 @@ export async function refreshProposalDependencies( return dependencies.map((dependency) => String(dependency.id)); } -const serializedTextValues = (payload: string) => - (JSON.parse(payload) as SerializedEntry[]).filter( - (entry): entry is Extract => - entry.kind === 'text', - ); +const serializedTextValues = ( + payload: string, +): Array> | null => { + try { + return parseSerializedProposalEntries(payload).filter( + (entry): entry is Extract => + entry.kind === 'text', + ); + } catch (error) { + if (error instanceof ProposalPayloadValidationError) return null; + throw error; + } +}; export async function getPendingEntities( _userId: string, @@ -211,24 +854,30 @@ export async function getPendingEntities( (item) => item.module === row.module && item.action === row.action, )!; const values = serializedTextValues(String(row.payload_json)); + const unusable = values === null; + const safeValues = values ?? []; const field = (name: string) => - values.find((entry) => entry.name === name)?.value ?? ''; + safeValues.find((entry) => entry.name === name)?.value ?? ''; const fieldValue = (name: string) => { - const matches = values + const matches = safeValues .filter((entry) => entry.name === name) .map((entry) => entry.value); return matches.length > 1 ? matches : (matches[0] ?? ''); }; return { - id: String(row.draft_entity_id), + id: String(row.draft_entity_id ?? row.id), proposalId: String(row.id), pending: true, active: 1, + ...(unusable ? { unusable: true } : {}), kind: definition.kind, - name: field('name'), + name: unusable ? 'UNUSABLE PROPOSAL' : field('name'), proposerName: String(row.proposer_name), ...Object.fromEntries( - (definition.fields ?? []).map((name) => [name, fieldValue(name)]), + (definition.fields ?? []).map((name) => [ + name, + unusable ? '' : fieldValue(name), + ]), ), }; }); @@ -258,58 +907,63 @@ export async function queueDataProposal( const name = String(form.get('name') ?? '').trim(); const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`; const now = new Date().toISOString(); - const payload = JSON.stringify(await serialize(form)); + const payload = JSON.stringify(await serializeProposal(form)); const database = await getDatabase(); - await ( - await database.prepare(`INSERT INTO data_change_proposals + const createProposal = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + await ( + await tx.prepare(`INSERT INTO data_change_proposals (id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at) VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`) - ).run( - id, - module, - action, - entityId, - draftEntityId, - summary, - contributorNote || null, - payload, - user.id, - now, - ); - const text = serializedTextValues(payload) - .map((entry) => entry.value) - .join('\n'); - const candidates = await ( - await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals - WHERE status='pending' AND draft_entity_id IS NOT NULL`) - ).all(); - const dependencies = candidates.filter( - (candidate) => - candidate.id !== id && text.includes(String(candidate.draft_entity_id)), - ); - for (const dependency of dependencies) { - await ( - await database.prepare( - 'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)', - ) - ).run(id, dependency.id); - } - await recordIsacAudit({ - actorId: user.id, - category: 'data_moderation', - action: 'proposal.created', - entityId: id, - before: null, - after: { + ).run( + id, module, action, entityId, draftEntityId, - dependencies: dependencies.map((item) => item.id), summary, - contributorNote: contributorNote || null, - }, + contributorNote || null, + payload, + user.id, + now, + ); + const text = (serializedTextValues(payload) ?? []) + .map((entry) => entry.value) + .join('\n'); + const candidates = await ( + await tx.prepare(`SELECT id,draft_entity_id FROM data_change_proposals + WHERE status='pending' AND draft_entity_id IS NOT NULL`) + ).all(); + const dependencies = candidates.filter( + (candidate) => + candidate.id !== id && text.includes(String(candidate.draft_entity_id)), + ); + for (const dependency of dependencies) { + await ( + await tx.prepare( + 'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)', + ) + ).run(id, dependency.id); + } + await recordIsacAudit({ + actorId: user.id, + category: 'data_moderation', + action: 'proposal.created', + entityId: id, + before: null, + after: { + module, + action, + entityId, + draftEntityId, + dependencies: dependencies.map((item) => item.id), + summary, + contributorNotePresent: Boolean(contributorNote), + }, + database: tx, + }); }); + await createProposal.immediate(); await notifyOversightReview({ category: 'data_changes', itemId: id, diff --git a/src/lib/server/database.test.ts b/src/lib/server/database.test.ts new file mode 100644 index 0000000..f656fb0 --- /dev/null +++ b/src/lib/server/database.test.ts @@ -0,0 +1,23 @@ +import { connect } from '@tursodatabase/database'; +import { describe, expect, it } from 'vitest'; +import { configureDatabase } from './database'; + +describe('database connection configuration', () => { + it('enables and enforces foreign keys on application connections', async () => { + const database = await configureDatabase(await connect(':memory:')); + try { + expect( + await (await database.prepare('PRAGMA foreign_keys')).get(), + ).toEqual({ foreign_keys: 1 }); + await database.exec('CREATE TABLE parent(id TEXT PRIMARY KEY)'); + await database.exec( + 'CREATE TABLE child(parent_id TEXT REFERENCES parent(id))', + ); + await expect( + database.exec("INSERT INTO child(parent_id) VALUES('missing')"), + ).rejects.toThrow(); + } finally { + await database.close(); + } + }); +}); diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 84e474f..b425521 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -4,6 +4,18 @@ import { resolve } from 'node:path'; let databasePromise: Promise | undefined; +export async function configureDatabase(database: Database): Promise { + await database.exec('PRAGMA foreign_keys=ON'); + const state = await ( + await database.prepare('PRAGMA foreign_keys') + ).get(); + if (Number(state?.foreign_keys) !== 1) { + await database.close(); + throw new Error('Database foreign-key enforcement could not be enabled.'); + } + return database; +} + /** * Returns the process-wide embedded Turso connection. * @@ -15,7 +27,7 @@ export function getDatabase(): Promise { env.TDR_DATABASE_PATH || './data/shd-build-development.db'; if (!databasePromise) { - databasePromise = connect(resolve(configuredPath)); + databasePromise = connect(resolve(configuredPath)).then(configureDatabase); } return databasePromise; diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts index 6e4d9c4..222184a 100644 --- a/src/lib/server/game-data-icons.ts +++ b/src/lib/server/game-data-icons.ts @@ -8,6 +8,12 @@ import { removeBackgroundPreview, } from './remove-background'; import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots'; +import { + acquireMediaPathLock, + captureMediaFile, + restoreMediaFile, + type MediaMutationJournal, +} from './media-journal'; export const GAME_ICON_TYPES = [ 'talent', @@ -374,54 +380,124 @@ export async function saveGameDataIcon( entityId: string, output: Buffer, actorId: string, + journal?: MediaMutationJournal, ): Promise { const key = storageKey(type, entityId); - await mkdir(gameDataIconStorageDirectory(), { recursive: true }); - await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 }); - const now = new Date().toISOString(); - await ( - await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) + const filePath = storagePath(key); + let release: (() => void) | undefined; + let transactionStarted = false; + let committed = false; + try { + if (journal) { + await journal.lockPath(filePath); + const previous = await captureMediaFile(filePath); + journal.register(() => restoreMediaFile(filePath, previous)); + } else { + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + release = await acquireMediaPathLock(filePath); + } + await mkdir(gameDataIconStorageDirectory(), { recursive: true }); + await writeFile(filePath, output, { flag: 'w', mode: 0o600 }); + const now = new Date().toISOString(); + await ( + await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`) - ).run(type, entityId, key, output.length, now); - await registerLibraryIcon( - database, - type, - entityId, - key, - output.length, - now, - actorId, - ); - return now; + ).run(type, entityId, key, output.length, now); + await registerLibraryIcon( + database, + type, + entityId, + key, + output.length, + now, + actorId, + ); + if (transactionStarted) { + await database.exec('COMMIT'); + committed = true; + } + return now; + } catch (error) { + if (transactionStarted && !committed) + await database.exec('ROLLBACK').catch(() => undefined); + throw error; + } finally { + release?.(); + } } export async function deleteGameDataIcon( database: Database, type: GameDataIconType, entityId: string, + journal?: MediaMutationJournal, ): Promise { - const icon = await ( - await database.prepare( - 'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?', - ) - ).get(type, entityId); - await ( - await database.prepare( - 'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?', - ) - ).run(type, entityId); - await ( - await database.prepare( - 'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?', - ) - ).run(type, entityId); - if (typeof icon?.storage_key === 'string') { - await unlink(storagePath(icon.storage_key)).catch( - (error: NodeJS.ErrnoException) => { + const canonicalPath = storagePath(storageKey(type, entityId)); + let releaseCanonical: (() => void) | undefined; + let releaseFile: (() => void) | undefined; + let transactionStarted = false; + let committed = false; + try { + if (journal) await journal.lockPath(canonicalPath); + else { + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + releaseCanonical = await acquireMediaPathLock(canonicalPath); + } + + const icon = await ( + await database.prepare( + 'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?', + ) + ).get(type, entityId); + const filePath = + typeof icon?.storage_key === 'string' + ? storagePath(icon.storage_key) + : null; + if (filePath && path.resolve(filePath) !== path.resolve(canonicalPath)) { + if (journal) await journal.lockPath(filePath); + else releaseFile = await acquireMediaPathLock(filePath); + } + if (journal) { + for (const pathToCapture of [canonicalPath, filePath]) { + if (!pathToCapture) continue; + const previous = await captureMediaFile(pathToCapture); + journal.register(() => restoreMediaFile(pathToCapture, previous)); + } + } + await ( + await database.prepare( + 'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?', + ) + ).run(type, entityId); + await ( + await database.prepare( + 'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?', + ) + ).run(type, entityId); + if (filePath) { + await unlink(filePath).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error; - }, - ); + }); + } + if (!filePath || path.resolve(filePath) !== path.resolve(canonicalPath)) { + await unlink(canonicalPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + } + if (transactionStarted) { + await database.exec('COMMIT'); + committed = true; + } + } catch (error) { + if (transactionStarted && !committed) + await database.exec('ROLLBACK').catch(() => undefined); + throw error; + } finally { + releaseFile?.(); + releaseCanonical?.(); } } @@ -462,10 +538,19 @@ export async function applyIconMutation( entityId: string, mutation: { output: Buffer | null; remove: boolean }, actorId: string, + journal?: MediaMutationJournal, ): Promise { if (mutation.output) - await saveGameDataIcon(database, type, entityId, mutation.output, actorId); - else if (mutation.remove) await deleteGameDataIcon(database, type, entityId); + await saveGameDataIcon( + database, + type, + entityId, + mutation.output, + actorId, + journal, + ); + else if (mutation.remove) + await deleteGameDataIcon(database, type, entityId, journal); else { const icon = await ( await database.prepare( diff --git a/src/lib/server/managed-images.test.ts b/src/lib/server/managed-images.test.ts index a68829e..223a29c 100644 --- a/src/lib/server/managed-images.test.ts +++ b/src/lib/server/managed-images.test.ts @@ -160,7 +160,7 @@ describe('deleteManagedImage', () => { }); expect(database.images.has(imageId)).toBe(false); expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false); - expect(database.executed).toEqual(['BEGIN', 'COMMIT']); + expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']); expect(rmMock).toHaveBeenCalledOnce(); }, ); @@ -214,7 +214,7 @@ describe('deleteManagedImage', () => { expect(deleted?.sourceIcon).toBeNull(); expect(database.images.has(imageId)).toBe(false); - expect(database.executed).toEqual(['BEGIN', 'COMMIT']); + expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']); }); it('fails closed when the library and source rows reference different files', async () => { @@ -236,7 +236,7 @@ describe('deleteManagedImage', () => { ).rejects.toThrow(ManagedImageDeletionError); expect(database.images.has(imageId)).toBe(true); expect(database.icons.has(`talent:${sourceId}`)).toBe(true); - expect(database.executed).toEqual([]); + expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']); expect(rmMock).not.toHaveBeenCalled(); }); @@ -258,7 +258,7 @@ describe('deleteManagedImage', () => { deleteManagedImage(database as never, imageId), ).rejects.toThrow(ManagedImageDeletionError); expect(database.images.has(imageId)).toBe(true); - expect(database.executed).toEqual([]); + expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']); expect(rmMock).not.toHaveBeenCalled(); }, ); diff --git a/src/lib/server/managed-images.ts b/src/lib/server/managed-images.ts index 5d870b0..28f9f36 100644 --- a/src/lib/server/managed-images.ts +++ b/src/lib/server/managed-images.ts @@ -4,6 +4,12 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons'; +import { + acquireMediaPathLock, + captureMediaFile, + restoreMediaFile, + type MediaMutationJournal, +} from './media-journal'; import { BackgroundRemovalError, removeBackgroundPreview, @@ -173,12 +179,48 @@ export async function processManagedImage( } } -export async function saveManagedImageFile(id: string, output: Buffer) { +async function saveManagedImageFileUnlocked( + key: string, + filePath: string, + output: Buffer, + journal?: MediaMutationJournal, +) { + await mkdir(managedImageStorageDirectory(), { recursive: true }); + await writeFile(filePath, output, { flag: 'wx', mode: 0o600 }); + if (journal) { + try { + journal.register(() => rm(filePath, { force: true })); + } catch (error) { + try { + await rm(filePath, { force: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Unable to register or clean up the managed image.', + ); + } + throw error; + } + } + return key; +} + +export async function saveManagedImageFile( + id: string, + output: Buffer, + journal?: MediaMutationJournal, +) { if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier'); const key = `library-${id}.webp`; - await mkdir(managedImageStorageDirectory(), { recursive: true }); - await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 }); - return key; + const filePath = storagePath(key); + let release: (() => void) | undefined; + if (journal) await journal.lockPath(filePath); + else release = await acquireMediaPathLock(filePath); + try { + return await saveManagedImageFileUnlocked(key, filePath, output, journal); + } finally { + release?.(); + } } export async function readGameDataRenderMutation(form: FormData): Promise<{ @@ -193,6 +235,45 @@ export async function readGameDataRenderMutation(form: FormData): Promise<{ }; } +type ManagedImageRow = Record; + +type CurrentRenderPathLock = { + current: ManagedImageRow | null; + filePath: string; + release?: () => void; +}; + +async function lockCurrentGameDataRender( + database: Database, + sourceType: string, + entityId: string, + initialStorageKey: string, + journal?: MediaMutationJournal, +): Promise { + let filePath = storagePath(initialStorageKey); + let release: (() => void) | undefined; + for (;;) { + if (journal) await journal.lockPath(filePath); + else release = await acquireMediaPathLock(filePath); + const current = (await ( + await database.prepare( + 'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?', + ) + ).get(sourceType, entityId)) as ManagedImageRow | null; + if (!current) return { current: null, filePath, release }; + if (typeof current.storage_key !== 'string') { + release?.(); + throw new Error('Invalid managed image storage key'); + } + const currentPath = storagePath(current.storage_key); + if (path.resolve(currentPath) === path.resolve(filePath)) + return { current, filePath: currentPath, release }; + release?.(); + release = undefined; + filePath = currentPath; + } +} + export async function applyGameDataRender( database: Database, type: GameDataRenderType, @@ -204,6 +285,7 @@ export async function applyGameDataRender( processed?: { output: Buffer; width: number; height: number }; }, actorId: string, + journal?: MediaMutationJournal, ) { const sourceType = `${type}_render`; const existing = await ( @@ -214,12 +296,52 @@ export async function applyGameDataRender( if (!mutation.file && !mutation.remove) return existing ?? null; if (mutation.remove) { if (!existing) return null; - await ( - await database.prepare('DELETE FROM managed_images WHERE id=?') - ).run(existing.id); - if (typeof existing.storage_key === 'string') - await rm(storagePath(existing.storage_key), { force: true }); - return null; + if (typeof existing.storage_key !== 'string') { + await ( + await database.prepare('DELETE FROM managed_images WHERE id=?') + ).run(existing.id); + return null; + } + let lock: CurrentRenderPathLock | undefined; + let transactionStarted = false; + let committed = false; + try { + if (!journal) { + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + } + lock = await lockCurrentGameDataRender( + database, + sourceType, + entityId, + existing.storage_key, + journal, + ); + const activeLock = lock; + if (activeLock.current) { + if (journal) { + const previous = await captureMediaFile(activeLock.filePath); + journal.register(() => + restoreMediaFile(activeLock.filePath, previous), + ); + } + await ( + await database.prepare('DELETE FROM managed_images WHERE id=?') + ).run(activeLock.current.id); + await rm(activeLock.filePath, { force: true }); + } + if (transactionStarted) { + await database.exec('COMMIT'); + committed = true; + } + return null; + } catch (error) { + if (transactionStarted && !committed) + await database.exec('ROLLBACK').catch(() => undefined); + throw error; + } finally { + lock?.release?.(); + } } const processed = mutation.processed ?? @@ -242,65 +364,137 @@ export async function applyGameDataRender( specialization_node: 'Specializations', }; const tags = JSON.stringify(['isac', 'game data', type, 'render']); - if (existing) { - await writeFile( - storagePath(String(existing.storage_key)), - processed.output, - { mode: 0o600 }, - ); - await ( - await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0, + if (existing && typeof existing.storage_key !== 'string') + throw new Error('Invalid managed image storage key'); + if (existing && typeof existing.storage_key === 'string') { + let lock: CurrentRenderPathLock | undefined; + let transactionStarted = false; + let committed = false; + try { + if (!journal) { + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + } + lock = await lockCurrentGameDataRender( + database, + sourceType, + entityId, + existing.storage_key, + journal, + ); + const activeLock = lock; + if (activeLock.current) { + if (journal) { + const previous = await captureMediaFile(activeLock.filePath); + journal.register(() => + restoreMediaFile(activeLock.filePath, previous), + ); + } + await writeFile(activeLock.filePath, processed.output, { + mode: 0o600, + }); + await ( + await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0, original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`) + ).run( + category[type], + tags, + mutation.file!.name, + processed.output.length, + processed.width, + processed.height, + now, + activeLock.current.id, + ); + const result = + (await ( + await database.prepare('SELECT * FROM managed_images WHERE id=?') + ).get(activeLock.current.id)) ?? null; + if (transactionStarted) { + await database.exec('COMMIT'); + committed = true; + } + return result; + } + if (transactionStarted) { + await database.exec('COMMIT'); + committed = true; + } + } catch (error) { + if (transactionStarted && !committed) + await database.exec('ROLLBACK').catch(() => undefined); + throw error; + } finally { + lock?.release?.(); + } + } + const id = randomUUID(); + const storageKey = `library-${id}.webp`; + const filePath = storagePath(storageKey); + let release: (() => void) | undefined; + let transactionStarted = false; + let committed = false; + try { + if (!journal) { + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + release = await acquireMediaPathLock(filePath); + } else await journal.lockPath(filePath); + await saveManagedImageFileUnlocked( + storageKey, + filePath, + processed.output, + journal, + ); + const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`; + await ( + await database.prepare(`INSERT INTO managed_images + (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, + source_entity_type,source_entity_id,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) ).run( + id, + name, category[type], tags, + 0, + 0, + storageKey, mutation.file!.name, processed.output.length, processed.width, processed.height, + sourceType, + entityId, + actorId, + now, now, - existing.id, ); - return ( + const result = (await ( await database.prepare('SELECT * FROM managed_images WHERE id=?') - ).get(existing.id)) ?? null - ); + ).get(id)) ?? null; + if (transactionStarted) { + await database.exec('COMMIT'); + committed = true; + } + return result; + } catch (error) { + if (transactionStarted && !committed) + await database.exec('ROLLBACK').catch(() => undefined); + throw error; + } finally { + release?.(); } - const id = randomUUID(); - const storageKey = await saveManagedImageFile(id, processed.output); - const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`; - await ( - await database.prepare(`INSERT INTO managed_images - (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, - source_entity_type,source_entity_id,created_by,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) - ).run( - id, - name, - category[type], - tags, - 0, - 0, - storageKey, - mutation.file!.name, - processed.output.length, - processed.width, - processed.height, - sourceType, - entityId, - actorId, - now, - now, - ); - return ( - (await ( - await database.prepare('SELECT * FROM managed_images WHERE id=?') - ).get(id)) ?? null - ); } -export async function deleteManagedImage(database: Database, id: string) { +export async function deleteManagedImage( + database: Database, + id: string, +): Promise<{ + image: Record; + sourceIcon: Record | null; +} | null> { const image = await ( await database.prepare('SELECT * FROM managed_images WHERE id=?') ).get(id); @@ -310,64 +504,98 @@ export async function deleteManagedImage(database: Database, id: string) { 'Managed image is missing its storage key.', ); - const sourceType = - typeof image.source_entity_type === 'string' - ? image.source_entity_type - : null; - const sourceId = - typeof image.source_entity_id === 'string' ? image.source_entity_id : null; - if (Boolean(sourceType) !== Boolean(sourceId)) { - throw new ManagedImageDeletionError( - 'This image has an incomplete source assignment and cannot be deleted safely.', - ); - } - if ( - sourceType && - !GAME_ICON_TYPES.includes(sourceType as GameDataIconType) && - !GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`) - ) { - throw new ManagedImageDeletionError( - 'This image has an unsupported source assignment and cannot be deleted safely.', - ); - } - - const sourceIcon = - sourceType && - sourceId && - GAME_ICON_TYPES.includes(sourceType as GameDataIconType) - ? ((await ( - await database.prepare( - 'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?', - ) - ).get(sourceType, sourceId)) ?? null) - : null; - if (sourceIcon && sourceIcon.storage_key !== image.storage_key) { - throw new ManagedImageDeletionError( - 'The library image and its source assignment reference different files. Resolve the assignment before deleting.', - ); - } - - await database.exec('BEGIN'); + const filePath = storagePath(image.storage_key); + let release: (() => void) | undefined; + let transactionStarted = false; + let committed = false; + let retry = false; + let deleted: { + image: typeof image; + sourceIcon: typeof image | null; + } | null = null; try { - if (sourceIcon && sourceType && sourceId) { + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + release = await acquireMediaPathLock(filePath); + const current = await ( + await database.prepare('SELECT * FROM managed_images WHERE id=?') + ).get(id); + if (!current) { + await database.exec('COMMIT'); + committed = true; + return null; + } + if ( + current.storage_key !== image.storage_key || + current.source_entity_type !== image.source_entity_type || + current.source_entity_id !== image.source_entity_id + ) { + retry = true; + } else { + const sourceType = + typeof current.source_entity_type === 'string' + ? current.source_entity_type + : null; + const sourceId = + typeof current.source_entity_id === 'string' + ? current.source_entity_id + : null; + if (Boolean(sourceType) !== Boolean(sourceId)) { + throw new ManagedImageDeletionError( + 'This image has an incomplete source assignment and cannot be deleted safely.', + ); + } + if ( + sourceType && + !GAME_ICON_TYPES.includes(sourceType as GameDataIconType) && + !GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`) + ) { + throw new ManagedImageDeletionError( + 'This image has an unsupported source assignment and cannot be deleted safely.', + ); + } + + const sourceIcon = + sourceType && + sourceId && + GAME_ICON_TYPES.includes(sourceType as GameDataIconType) + ? ((await ( + await database.prepare( + 'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?', + ) + ).get(sourceType, sourceId)) ?? null) + : null; + if (sourceIcon && sourceIcon.storage_key !== current.storage_key) { + throw new ManagedImageDeletionError( + 'The library image and its source assignment reference different files. Resolve the assignment before deleting.', + ); + } + + if (sourceIcon && sourceType && sourceId) { + await ( + await database.prepare( + 'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?', + ) + ).run(sourceType, sourceId, current.storage_key); + } await ( await database.prepare( - 'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?', + 'DELETE FROM managed_images WHERE id=? AND storage_key=?', ) - ).run(sourceType, sourceId, image.storage_key); + ).run(id, current.storage_key); + await rm(filePath, { force: true }); + deleted = { image: current, sourceIcon }; } - await ( - await database.prepare( - 'DELETE FROM managed_images WHERE id=? AND storage_key=?', - ) - ).run(id, image.storage_key); await database.exec('COMMIT'); + committed = true; } catch (error) { - await database.exec('ROLLBACK').catch(() => undefined); + if (transactionStarted && !committed) + await database.exec('ROLLBACK').catch(() => undefined); throw error; + } finally { + release?.(); } - await rm(storagePath(image.storage_key), { force: true }); - return { image, sourceIcon }; + return retry ? deleteManagedImage(database, id) : deleted; } export async function readManagedImage(database: Database, id: string) { diff --git a/src/lib/server/mechanic-authored-sources.ts b/src/lib/server/mechanic-authored-sources.ts new file mode 100644 index 0000000..aab5146 --- /dev/null +++ b/src/lib/server/mechanic-authored-sources.ts @@ -0,0 +1,311 @@ +import { + parseMechanicAuthoredSource, + type MechanicAuthoredOwner, + type MechanicAuthoredSource, +} from '$lib/calculations/mechanic-authored-description'; +import { hashMechanicAuthoredSource } from '$lib/calculations/mechanic-lifecycle'; +import type { Database } from '@tursodatabase/database'; +import { randomUUID } from 'node:crypto'; + +export type MechanicAuthoredSourceRecord = { + id: string; + source: MechanicAuthoredSource; + fingerprint: string; + createdAt: string; + updatedAt: string; +}; + +export type SaveMechanicAuthoredSourceOptions = { + /** Undefined is reserved for migration and trusted repair code. */ + expectedSourceFingerprint?: string | null; +}; + +export class MechanicSourceFingerprintConflictError extends Error { + readonly expectedFingerprint: string | null; + readonly currentFingerprint: string | null; + + constructor( + expectedFingerprint: string | null, + currentFingerprint: string | null, + ) { + super('The authored mechanic changed after this editor was loaded.'); + this.name = 'MechanicSourceFingerprintConflictError'; + this.expectedFingerprint = expectedFingerprint; + this.currentFingerprint = currentFingerprint; + } +} + +type SourceRow = Record; +type VariantRow = Record; +type SlotRow = Record; + +function ownerFromRow(row: SourceRow): MechanicAuthoredOwner { + const type = String(row.owner_type); + const id = String(row.owner_id); + if (type === 'talent') { + return { + type, + id, + scope: row.talent_scope === 'exotic' ? 'exotic' : 'standard', + ...(Number(row.talent_has_tier_ii ?? 0) === 1 + ? { hasTierII: true } + : {}), + }; + } + if ( + type === 'os_protocol_talent' || + type === 'skill_chip_talent' || + type === 'specialization_tree_rank' + ) { + return { type, id }; + } + throw new Error(`Unsupported authored mechanic owner type "${type}".`); +} + +function sourceFromRows( + row: SourceRow, + variants: VariantRow[], + slots: SlotRow[], +): MechanicAuthoredSource { + const owner = ownerFromRow(row); + const source: MechanicAuthoredSource = { + version: Number( + row.authored_contract_version, + ) as MechanicAuthoredSource['version'], + owner, + template: String(row.template), + variants: variants.map((variant) => ({ + key: String(variant.variant_key), + materialized: String(variant.materialized), + slots: slots + .filter( + (slot) => String(slot.variant_key) === String(variant.variant_key), + ) + .map((slot) => ({ + key: String(slot.slot_key), + value: String(slot.display_value), + order: Number(slot.sort_order), + ...(slot.unit === null || slot.unit === undefined + ? {} + : { unit: String(slot.unit) }), + ...(slot.stat_attribute_id === null || + slot.stat_attribute_id === undefined + ? {} + : { attributeId: String(slot.stat_attribute_id) }), + })), + })), + ...(owner.type === 'specialization_tree_rank' + ? { + specialization: { + rankDescription: String(row.specialization_rank_description), + effectId: String(row.specialization_effect_id), + effectTemplate: String(row.specialization_effect_template), + }, + } + : {}), + }; + + const parsed = parseMechanicAuthoredSource(source); + if (!parsed.ok) { + throw new Error( + `Stored authored mechanic source is invalid: ${parsed.errors + .map((error) => error.message) + .join(' ')}`, + ); + } + return parsed.value; +} + +async function loadSourceRows(database: Database, row: SourceRow) { + const [variants, slots] = await Promise.all([ + ( + await database.prepare( + `SELECT * FROM mechanic_authored_variants + WHERE source_id=? ORDER BY sort_order`, + ) + ).all(row.id), + ( + await database.prepare( + `SELECT * FROM mechanic_authored_slots + WHERE source_id=? ORDER BY variant_key,sort_order`, + ) + ).all(row.id), + ]); + return { variants, slots }; +} + +export async function loadMechanicAuthoredSource( + database: Database, + ownerType: MechanicAuthoredOwner['type'], + ownerId: string, +): Promise { + const row = await ( + await database.prepare( + `SELECT * FROM mechanic_authored_sources + WHERE owner_type=? AND owner_id=?`, + ) + ).get(ownerType, ownerId); + if (!row) return null; + const { variants, slots } = await loadSourceRows(database, row); + return { + id: String(row.id), + source: sourceFromRows(row, variants, slots), + fingerprint: String(row.source_fingerprint), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; +} + +export async function loadMechanicAuthoredSourceById( + database: Database, + sourceId: string, +): Promise { + const row = await ( + await database.prepare( + 'SELECT * FROM mechanic_authored_sources WHERE id=?', + ) + ).get(sourceId); + if (!row) return null; + const { variants, slots } = await loadSourceRows(database, row); + return { + id: String(row.id), + source: sourceFromRows(row, variants, slots), + fingerprint: String(row.source_fingerprint), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; +} + +/** + * Replaces one mutable authored source inside the caller's active owner + * transaction. Model revisions and selections are intentionally untouched. + */ +export async function saveMechanicAuthoredSourceInTransaction( + database: Database, + source: MechanicAuthoredSource, + options: SaveMechanicAuthoredSourceOptions = {}, +): Promise { + const parsed = parseMechanicAuthoredSource(source); + if (!parsed.ok) { + throw new Error(parsed.errors.map((error) => error.message).join(' ')); + } + const fingerprintResult = await hashMechanicAuthoredSource(parsed.value); + if (!fingerprintResult.ok) { + throw new Error( + fingerprintResult.errors.map((error) => error.message).join(' '), + ); + } + + const existing = await ( + await database.prepare( + `SELECT id,source_fingerprint,created_at + FROM mechanic_authored_sources + WHERE owner_type=? AND owner_id=?`, + ) + ).get(parsed.value.owner.type, parsed.value.owner.id); + const currentFingerprint = existing + ? String(existing.source_fingerprint) + : null; + if ( + options.expectedSourceFingerprint !== undefined && + options.expectedSourceFingerprint !== currentFingerprint + ) { + throw new MechanicSourceFingerprintConflictError( + options.expectedSourceFingerprint, + currentFingerprint, + ); + } + + const now = new Date().toISOString(); + const sourceId = String(existing?.id ?? randomUUID()); + const owner = parsed.value.owner; + await ( + await database.prepare(`INSERT INTO mechanic_authored_sources ( + id,owner_type,owner_id,template,authored_contract_version,source_fingerprint, + talent_scope,talent_has_tier_ii,specialization_rank_description, + specialization_effect_id,specialization_effect_template,created_at,updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(owner_type,owner_id) DO UPDATE SET + template=excluded.template, + authored_contract_version=excluded.authored_contract_version, + source_fingerprint=excluded.source_fingerprint, + talent_scope=excluded.talent_scope, + talent_has_tier_ii=excluded.talent_has_tier_ii, + specialization_rank_description=excluded.specialization_rank_description, + specialization_effect_id=excluded.specialization_effect_id, + specialization_effect_template=excluded.specialization_effect_template, + updated_at=excluded.updated_at`) + ).run( + sourceId, + owner.type, + owner.id, + parsed.value.template, + parsed.value.version, + fingerprintResult.fingerprint, + owner.type === 'talent' ? owner.scope : null, + owner.type === 'talent' ? (owner.hasTierII ? 1 : 0) : null, + parsed.value.specialization?.rankDescription ?? null, + parsed.value.specialization?.effectId ?? null, + parsed.value.specialization?.effectTemplate ?? null, + String(existing?.created_at ?? now), + now, + ); + + await ( + await database.prepare( + 'DELETE FROM mechanic_authored_slots WHERE source_id=?', + ) + ).run(sourceId); + await ( + await database.prepare( + 'DELETE FROM mechanic_authored_variants WHERE source_id=?', + ) + ).run(sourceId); + + for (const [variantIndex, variant] of parsed.value.variants.entries()) { + await ( + await database.prepare(`INSERT INTO mechanic_authored_variants + (source_id,variant_key,materialized,sort_order) VALUES (?,?,?,?)`) + ).run(sourceId, variant.key, variant.materialized, variantIndex); + for (const slot of variant.slots) { + await ( + await database.prepare(`INSERT INTO mechanic_authored_slots + (source_id,variant_key,slot_key,display_value,sort_order,unit,stat_attribute_id) + VALUES (?,?,?,?,?,?,?)`) + ).run( + sourceId, + variant.key, + slot.key, + slot.value, + slot.order, + slot.unit ?? null, + slot.attributeId ?? null, + ); + } + } + + return { + id: sourceId, + source: parsed.value, + fingerprint: fingerprintResult.fingerprint, + createdAt: String(existing?.created_at ?? now), + updatedAt: now, + }; +} + +export function mechanicAuthoredSourceSnapshot( + record: MechanicAuthoredSourceRecord | null, +) { + if (!record) return null; + return { + id: record.id, + owner: record.source.owner, + template: record.source.template, + authoredContractVersion: record.source.version, + sourceFingerprint: record.fingerprint, + variants: record.source.variants, + ...(record.source.specialization + ? { specialization: record.source.specialization } + : {}), + }; +} diff --git a/src/lib/server/mechanic-model-revisions.ts b/src/lib/server/mechanic-model-revisions.ts new file mode 100644 index 0000000..2d8ad4b --- /dev/null +++ b/src/lib/server/mechanic-model-revisions.ts @@ -0,0 +1,634 @@ +import { + parseConditionalMechanicDraft, + type ConditionalMechanicDraftV1, + type ConditionalMechanicOutcomeDraft, + type ConditionalMechanicPoolDraft, +} from '$lib/calculations/mechanic-editor'; +import { + MECHANIC_PROGRAM_HASH_VERSION, + hashMechanicProgramVariants, + type MechanicSimulationReadiness, +} from '$lib/calculations/mechanic-lifecycle'; +import type { MechanicPublicProgramVariant } from '$lib/calculations/mechanic-public-renderer'; +import type { Database } from '@tursodatabase/database'; +import { randomUUID } from 'node:crypto'; +import type { MechanicAuthoredSourceRecord } from './mechanic-authored-sources'; + +export const MECHANIC_SELECTION_INTENTS = ['preserve', 'save', 'clear'] as const; +export type MechanicSelectionIntent = + (typeof MECHANIC_SELECTION_INTENTS)[number]; + +export type MechanicModelRevisionRecord = { + id: string; + sourceId: string; + revisionNumber: number; + sourceFingerprint: string; + mechanicContractVersion: number; + programHash: string; + simulationReadiness: MechanicSimulationReadiness; + programName: string; + draft: ConditionalMechanicDraftV1; + variants: MechanicPublicProgramVariant[]; + origin: string; + actorId: string | null; + createdAt: string; +}; + +export type SaveMechanicModelRevisionOptions = { + programName: string; + origin: string; + actorId: string | null; +}; + +type Row = Record; +type ClauseDraft = ConditionalMechanicDraftV1['clauses'][number]; + +type StoredVariant = { key: string; label: string }; + +const parseJson = (value: unknown, label: string): T => { + try { + return JSON.parse(String(value)) as T; + } catch { + throw new Error(`Stored mechanic ${label} JSON is invalid.`); + } +}; + +function variantLabel(key: string) { + if (key === 'I') return 'Tier I'; + if (key === 'II') return 'Tier II'; + if (key === 'minimum') return 'Minimum'; + if (key === 'maximum') return 'Maximum'; + return 'Base'; +} + +function sourceVariants(source: MechanicAuthoredSourceRecord): StoredVariant[] { + return source.source.variants.map((variant) => ({ + key: variant.key, + label: variantLabel(variant.key), + })); +} + +function parseDraft( + draft: ConditionalMechanicDraftV1, + variants: StoredVariant[], + programName: string, +) { + const parsed = parseConditionalMechanicDraft(JSON.stringify(draft), { + variantKeys: variants.map((variant) => variant.key), + programName, + }); + if (!parsed.ok) throw new Error(parsed.errors.join(' ')); + return { + draft: parsed.value.draft, + variants: variants.map((variant) => ({ + ...variant, + program: parsed.value.programs[variant.key], + })), + }; +} + +function assertVariantMetadataConsistent( + effectKey: string, + rows: Row[], + columns: readonly string[], +) { + const first = rows[0]; + for (const row of rows.slice(1)) { + const mismatch = columns.find( + (column) => (row[column] ?? null) !== (first[column] ?? null), + ); + if (mismatch) { + throw new Error( + `Stored mechanic outcome "${effectKey}" has inconsistent ${mismatch} metadata.`, + ); + } + } +} + +const outcomeMetadataColumns = [ + 'label', + 'outcome_kind', + 'effect_scope', + 'value_basis', + 'resource_kind', + 'delivery_mode', + 'delivery_duration_seconds', + 'application_phase', + 'combat_timing', + 'stat_attribute_id', + 'state_pool_id', + 'per_stack', + 'min_state_value', + 'max_state_value', + 'targeting_json', + 'probability_percent', + 'sort_order', +] as const; + +function draftFromRows( + revision: Row, + poolRows: Row[], + clauseRows: Row[], + outcomeRows: Row[], + variants: StoredVariant[], +): ConditionalMechanicDraftV1 { + const poolKeyById = new Map( + poolRows.map((row) => [String(row.id), String(row.pool_key)]), + ); + const pools = poolRows.map( + (row): ConditionalMechanicPoolDraft => ({ + key: String(row.pool_key), + label: String(row.label), + stateKind: String( + row.state_kind, + ) as ConditionalMechanicPoolDraft['stateKind'], + scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'], + initial: Number(row.initial_value), + ...(row.minimum_value === null ? {} : { min: Number(row.minimum_value) }), + ...(row.maximum_value === null ? {} : { max: Number(row.maximum_value) }), + ...(row.best_case_value === null + ? {} + : { bestCaseValue: Number(row.best_case_value) }), + ...(row.duration_seconds === null + ? {} + : { expiresAfterSeconds: Number(row.duration_seconds) }), + ...(row.duration_variants_json === null + ? {} + : { + expiresAfterSecondsVariants: parseJson>( + row.duration_variants_json, + 'pool duration variants', + ), + }), + }), + ); + + const outcomesByClause = new Map(); + for (const row of outcomeRows) { + const clauseId = String(row.clause_id); + outcomesByClause.set(clauseId, [ + ...(outcomesByClause.get(clauseId) ?? []), + row, + ]); + } + + const clauses = clauseRows.map((clause) => { + const grouped = new Map(); + for (const outcome of outcomesByClause.get(String(clause.id)) ?? []) { + const effectKey = String(outcome.effect_key); + grouped.set(effectKey, [...(grouped.get(effectKey) ?? []), outcome]); + } + const outcomes = [...grouped.entries()] + .sort( + ([, left], [, right]) => + Number(left[0].sort_order) - Number(right[0].sort_order), + ) + .map(([effectKey, rows]): ConditionalMechanicOutcomeDraft => { + assertVariantMetadataConsistent( + effectKey, + rows, + outcomeMetadataColumns, + ); + const first = rows[0]; + const statePool = + first.state_pool_id === null + ? undefined + : poolKeyById.get(String(first.state_pool_id)); + return { + key: effectKey, + label: String(first.label), + phase: String( + first.application_phase, + ) as ConditionalMechanicOutcomeDraft['phase'], + kind: String( + first.outcome_kind, + ) as ConditionalMechanicOutcomeDraft['kind'], + scope: String( + first.effect_scope, + ) as ConditionalMechanicOutcomeDraft['scope'], + basis: String( + first.value_basis, + ) as ConditionalMechanicOutcomeDraft['basis'], + ...(first.resource_kind === null + ? {} + : { + resource: String( + first.resource_kind, + ) as ConditionalMechanicOutcomeDraft['resource'], + }), + ...(first.delivery_mode === 'continuous' + ? { + delivery: 'continuous' as const, + durationSeconds: Number(first.delivery_duration_seconds), + } + : {}), + ...(first.combat_timing === 'pre_event' + ? { combatTiming: 'pre_event' as const } + : {}), + ...(first.stat_attribute_id === null + ? {} + : { attributeId: String(first.stat_attribute_id) }), + ...(statePool ? { statePool } : {}), + perStack: Number(first.per_stack) === 1, + ...(first.min_state_value === null + ? {} + : { minStacks: Number(first.min_state_value) }), + ...(first.max_state_value === null + ? {} + : { maxStacks: Number(first.max_state_value) }), + ...(first.targeting_json === null + ? {} + : { + targeting: parseJson( + first.targeting_json, + 'outcome targeting', + ), + }), + ...(first.probability_percent === null + ? {} + : { probabilityPercent: Number(first.probability_percent) }), + variants: Object.fromEntries( + variants.map((variant) => { + const row = rows.find( + (candidate) => candidate.variant_key === variant.key, + ); + if (!row) { + throw new Error( + `Stored mechanic outcome "${effectKey}" is missing variant ${variant.key}.`, + ); + } + return [ + variant.key, + { + value: Number(row.value), + ...(row.value_expression_json === null + ? {} + : { + expression: parseJson( + row.value_expression_json, + 'outcome expression', + ), + }), + }, + ]; + }), + ), + }; + }); + const result: ClauseDraft = { + key: String(clause.clause_key), + ...(clause.label === null ? {} : { label: String(clause.label) }), + event: String(clause.event_kind) as ConditionalMechanicDraftV1['clauses'][number]['event'], + ...(clause.recipe_key === null + ? {} + : { recipeKey: String(clause.recipe_key) }), + ...(clause.conditions_json === null + ? {} + : { + if: parseJson>( + clause.conditions_json, + 'clause conditions', + ), + }), + reducer: parseJson( + clause.reducer_json, + 'clause reducer', + ), + ...(clause.weight_expression_json === null + ? {} + : { + weight: parseJson>( + clause.weight_expression_json, + 'clause weight', + ), + }), + ...(clause.cooldown_seconds === null + ? {} + : { cooldownSeconds: Number(clause.cooldown_seconds) }), + ...(clause.proc_chance_percent === null + ? {} + : { procChancePercent: Number(clause.proc_chance_percent) }), + ...(clause.requires_assigned_ability_active === null + ? {} + : { + requiresAssignedAbilityActive: + Number(clause.requires_assigned_ability_active) === 1, + }), + mutations: parseJson( + clause.mutations_json, + 'clause mutations', + ), + outcomes, + }; + return result; + }); + + return { + version: 1, + simulationReadiness: String( + revision.simulation_readiness, + ) as MechanicSimulationReadiness, + scenarioInputs: parseJson( + revision.scenario_inputs_json, + 'Scenario Inputs', + ), + pools, + clauses, + }; +} + +async function revisionFromRow( + database: Database, + revision: Row, +): Promise { + const [pools, clauses, outcomes] = await Promise.all([ + ( + await database.prepare( + `SELECT * FROM mechanic_model_revision_pools + WHERE revision_id=? ORDER BY sort_order`, + ) + ).all(revision.id), + ( + await database.prepare( + `SELECT * FROM mechanic_model_revision_clauses + WHERE revision_id=? ORDER BY sort_order`, + ) + ).all(revision.id), + ( + await database.prepare( + `SELECT * FROM mechanic_model_revision_outcomes + WHERE revision_id=? ORDER BY sort_order,variant_key`, + ) + ).all(revision.id), + ]); + const storedVariants = parseJson( + revision.source_variants_json, + 'source variants', + ); + const draft = draftFromRows(revision, pools, clauses, outcomes, storedVariants); + const parsed = parseDraft(draft, storedVariants, String(revision.program_name)); + return { + id: String(revision.id), + sourceId: String(revision.source_id), + revisionNumber: Number(revision.revision_number), + sourceFingerprint: String(revision.source_fingerprint), + mechanicContractVersion: Number(revision.mechanic_contract_version), + programHash: String(revision.program_hash), + simulationReadiness: String( + revision.simulation_readiness, + ) as MechanicSimulationReadiness, + programName: String(revision.program_name), + draft: parsed.draft, + variants: parsed.variants, + origin: String(revision.origin), + actorId: + revision.actor_id === null || revision.actor_id === undefined + ? null + : String(revision.actor_id), + createdAt: String(revision.created_at), + }; +} + +export async function loadMechanicModelRevision( + database: Database, + revisionId: string, +): Promise { + const revision = await ( + await database.prepare( + 'SELECT * FROM mechanic_model_revisions WHERE id=?', + ) + ).get(revisionId); + return revision ? revisionFromRow(database, revision) : null; +} + +export async function loadSelectedMechanicModelRevision( + database: Database, + sourceId: string, +): Promise { + const revision = await ( + await database.prepare(`SELECT revision.* + FROM mechanic_model_selections selection + JOIN mechanic_model_revisions revision + ON revision.id=selection.revision_id + AND revision.source_id=selection.source_id + WHERE selection.source_id=?`) + ).get(sourceId); + return revision ? revisionFromRow(database, revision) : null; +} + +export async function selectMechanicModelRevisionInTransaction( + database: Database, + sourceId: string, + revisionId: string, +) { + await ( + await database.prepare(`INSERT INTO mechanic_model_selections + (source_id,revision_id,updated_at) VALUES (?,?,?) + ON CONFLICT(source_id) DO UPDATE SET + revision_id=excluded.revision_id, + updated_at=excluded.updated_at`) + ).run(sourceId, revisionId, new Date().toISOString()); +} + +export async function clearMechanicModelSelectionInTransaction( + database: Database, + sourceId: string, +) { + await ( + await database.prepare( + 'DELETE FROM mechanic_model_selections WHERE source_id=?', + ) + ).run(sourceId); +} + +/** Appends and selects a normalized model revision in the caller transaction. */ +export async function saveMechanicModelRevisionInTransaction( + database: Database, + source: MechanicAuthoredSourceRecord, + draft: ConditionalMechanicDraftV1, + options: SaveMechanicModelRevisionOptions, +): Promise<{ created: boolean; revision: MechanicModelRevisionRecord }> { + const storedVariants = sourceVariants(source); + const parsed = parseDraft(draft, storedVariants, options.programName); + const programHash = await hashMechanicProgramVariants({ + simulationReadiness: parsed.draft.simulationReadiness, + variants: parsed.variants, + }); + + const selected = await loadSelectedMechanicModelRevision(database, source.id); + if ( + selected?.sourceFingerprint === source.fingerprint && + selected.mechanicContractVersion === MECHANIC_PROGRAM_HASH_VERSION && + selected.programHash === programHash + ) { + return { created: false, revision: selected }; + } + + const latest = await ( + await database.prepare( + `SELECT COALESCE(MAX(revision_number),0) AS revision_number + FROM mechanic_model_revisions WHERE source_id=?`, + ) + ).get(source.id); + const revisionNumber = Number(latest?.revision_number ?? 0) + 1; + const revisionId = randomUUID(); + const now = new Date().toISOString(); + await ( + await database.prepare(`INSERT INTO mechanic_model_revisions ( + id,source_id,revision_number,source_fingerprint,mechanic_contract_version, + program_hash,simulation_readiness,scenario_inputs_json,source_variants_json, + program_name,origin,actor_id,created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + revisionId, + source.id, + revisionNumber, + source.fingerprint, + MECHANIC_PROGRAM_HASH_VERSION, + programHash, + parsed.draft.simulationReadiness, + JSON.stringify(parsed.draft.scenarioInputs), + JSON.stringify(storedVariants), + options.programName, + options.origin, + options.actorId, + now, + ); + + const poolIds = new Map(); + for (const [index, pool] of parsed.draft.pools.entries()) { + const poolId = randomUUID(); + poolIds.set(pool.key, poolId); + await ( + await database.prepare(`INSERT INTO mechanic_model_revision_pools ( + id,revision_id,pool_key,label,state_kind,state_scope,initial_value, + minimum_value,maximum_value,best_case_value,duration_seconds, + duration_variants_json,sort_order + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + poolId, + revisionId, + pool.key, + pool.label, + pool.stateKind, + pool.scope, + pool.initial, + pool.min ?? null, + pool.max ?? null, + pool.bestCaseValue ?? null, + pool.expiresAfterSeconds ?? null, + pool.expiresAfterSecondsVariants === undefined + ? null + : JSON.stringify(pool.expiresAfterSecondsVariants), + index, + ); + } + + for (const [clauseIndex, clause] of parsed.draft.clauses.entries()) { + const clauseId = randomUUID(); + await ( + await database.prepare(`INSERT INTO mechanic_model_revision_clauses ( + id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json, + reducer_json,weight_expression_json,mutations_json,cooldown_seconds, + proc_chance_percent,requires_assigned_ability_active,sort_order + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + clauseId, + revisionId, + clause.key, + clause.label ?? null, + clause.event, + clause.recipeKey ?? null, + clause.if === undefined ? null : JSON.stringify(clause.if), + JSON.stringify(clause.reducer), + clause.weight === undefined ? null : JSON.stringify(clause.weight), + JSON.stringify(clause.mutations), + clause.cooldownSeconds ?? null, + clause.procChancePercent ?? null, + clause.requiresAssignedAbilityActive === undefined + ? null + : clause.requiresAssignedAbilityActive + ? 1 + : 0, + clauseIndex, + ); + + for (const [outcomeIndex, outcome] of clause.outcomes.entries()) { + for (const storedVariant of storedVariants) { + const value = outcome.variants[storedVariant.key]; + if (!value) { + throw new Error( + `Mechanic outcome ${outcome.key} is missing variant ${storedVariant.key}.`, + ); + } + await ( + await database.prepare(`INSERT INTO mechanic_model_revision_outcomes ( + id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label, + outcome_kind,effect_scope,value,value_expression_json,value_basis, + resource_kind,delivery_mode,delivery_duration_seconds,application_phase, + combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value, + targeting_json,probability_percent,sort_order + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + randomUUID(), + revisionId, + clauseId, + outcome.statePool ? (poolIds.get(outcome.statePool) ?? null) : null, + outcome.key, + storedVariant.key, + outcome.label, + outcome.kind, + outcome.scope, + value.value, + value.expression === undefined + ? null + : JSON.stringify(value.expression), + outcome.basis, + outcome.resource ?? null, + outcome.delivery ?? 'instant', + outcome.durationSeconds ?? null, + outcome.phase, + outcome.combatTiming ?? 'current', + outcome.attributeId ?? null, + outcome.perStack ? 1 : 0, + outcome.minStacks ?? null, + outcome.maxStacks ?? null, + outcome.targeting === undefined + ? null + : JSON.stringify(outcome.targeting), + outcome.probabilityPercent ?? null, + outcomeIndex, + ); + } + } + } + + await selectMechanicModelRevisionInTransaction( + database, + source.id, + revisionId, + ); + const revision = await loadMechanicModelRevision(database, revisionId); + if (!revision) throw new Error('The appended mechanic revision was not found.'); + return { created: true, revision }; +} + +export function mechanicModelRevisionSnapshot( + revision: MechanicModelRevisionRecord | null, +) { + if (!revision) return null; + return { + id: revision.id, + revisionNumber: revision.revisionNumber, + sourceFingerprint: revision.sourceFingerprint, + mechanicContractVersion: revision.mechanicContractVersion, + programHash: revision.programHash, + simulationReadiness: revision.simulationReadiness, + scenarioInputs: revision.draft.scenarioInputs, + pools: revision.draft.pools, + clauses: revision.draft.clauses, + origin: revision.origin, + actorId: revision.actorId, + createdAt: revision.createdAt, + }; +} diff --git a/src/lib/server/mechanic-owner-mutation.test.ts b/src/lib/server/mechanic-owner-mutation.test.ts new file mode 100644 index 0000000..dc4785f --- /dev/null +++ b/src/lib/server/mechanic-owner-mutation.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; +import { + parseExpectedOwnerUpdatedAt, + validateMechanicAuthoredTemplate, + validateMechanicOwnerCapabilities, +} from './mechanic-owner-mutation'; + +const draft = (): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'primary', + label: 'Primary', + event: 'weapon_hit', + if: { kind: 'true' }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + key: 'effect', + label: 'Effect', + phase: 'on_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + variants: { base: { value: 1 } }, + }, + ], + }, + ], +}); + +const withAssignedAbilityBehavior = () => { + const value = draft(); + value.clauses[0].requiresAssignedAbilityActive = true; + value.clauses[0].mutations = [ + { kind: 'extend_duration', target: 'assigned_ability', seconds: 1 }, + ]; + value.clauses[0].outcomes[0].scope = 'assigned_ability'; + value.clauses[0].if = { + kind: 'exists', + value: { source: 'assigned_ability', path: 'active' }, + }; + return value; +}; + +const withProtocolUpgradeReference = () => { + const value = draft(); + value.clauses[0].outcomes[0].variants.base.expression = { + op: 'add', + left: { ref: { source: 'variant_value' } }, + right: { + ref: { source: 'equipment', path: 'protocolTalentUpgrades' }, + }, + }; + return value; +}; + +describe('owner optimistic-concurrency token parsing', () => { + it('requires exact bounded tokens for existing owners', () => { + expect(parseExpectedOwnerUpdatedAt('2026-08-02T12:00:00.000Z', true)).toBe( + '2026-08-02T12:00:00.000Z', + ); + expect(parseExpectedOwnerUpdatedAt('', false)).toBeNull(); + expect(() => parseExpectedOwnerUpdatedAt('', true)).toThrow(/required/); + expect(() => parseExpectedOwnerUpdatedAt(' padded ', true)).toThrow(/invalid/); + expect(() => parseExpectedOwnerUpdatedAt('bad\u0000token', true)).toThrow( + /invalid/, + ); + expect(() => parseExpectedOwnerUpdatedAt('x'.repeat(101), true)).toThrow( + /invalid/, + ); + }); +}); + +describe('mechanic owner capability validation', () => { + it('rejects assigned-ability behavior for Talent, Protocol, and Specialization owners', () => { + for (const ownerType of [ + 'talent', + 'os_protocol_talent', + 'specialization_tree_rank', + ] as const) { + expect( + validateMechanicOwnerCapabilities( + ownerType, + withAssignedAbilityBehavior(), + ), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining('assigned ability'), + expect.stringContaining('assigned-ability'), + ]), + ); + } + }); + + it('allows the supported owner-specific capability and rejects protocol scaling elsewhere', () => { + expect( + validateMechanicOwnerCapabilities( + 'skill_chip_talent', + withAssignedAbilityBehavior(), + ), + ).toEqual([]); + expect( + validateMechanicOwnerCapabilities( + 'os_protocol_talent', + withProtocolUpgradeReference(), + ), + ).toEqual([]); + + for (const ownerType of [ + 'talent', + 'skill_chip_talent', + 'specialization_tree_rank', + ] as const) { + expect( + validateMechanicOwnerCapabilities( + ownerType, + withProtocolUpgradeReference(), + ), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining('protocolTalentUpgrades'), + ]), + ); + } + }); + + it('uses the authored template grammar without requiring an owner source', () => { + expect( + validateMechanicAuthoredTemplate('Gain {1} for {duration}.'), + ).toEqual([]); + expect(validateMechanicAuthoredTemplate('Gain {unsupported}.')).toEqual( + expect.arrayContaining([expect.stringContaining('unsupported token')]), + ); + expect(validateMechanicAuthoredTemplate('Gain {1.')).toEqual( + expect.arrayContaining([expect.stringContaining('malformed')]), + ); + expect(validateMechanicAuthoredTemplate('')).toEqual( + expect.arrayContaining([expect.stringContaining('required')]), + ); + }); +}); diff --git a/src/lib/server/mechanic-owner-mutation.ts b/src/lib/server/mechanic-owner-mutation.ts new file mode 100644 index 0000000..86b92ef --- /dev/null +++ b/src/lib/server/mechanic-owner-mutation.ts @@ -0,0 +1,340 @@ +import { + parseMechanicAuthoredSource, + type MechanicAuthoredOwner, + type MechanicAuthoredSource, + type SpecializationAuthoredInputs, +} from '$lib/calculations/mechanic-authored-description'; +import { + parseConditionalMechanicDraft, + type ConditionalMechanicDraftV1, +} from '$lib/calculations/mechanic-editor'; +import type { MechanicAttributeLabelResolver } from '$lib/calculations/mechanic-public-renderer'; +import type { Database } from '@tursodatabase/database'; +import { + mechanicAuthoredSourceSnapshot, + saveMechanicAuthoredSourceInTransaction, +} from './mechanic-authored-sources'; +import { + clearMechanicModelSelectionInTransaction, + loadSelectedMechanicModelRevision, + mechanicModelRevisionSnapshot, + saveMechanicModelRevisionInTransaction, + type MechanicModelRevisionRecord, + type MechanicSelectionIntent, +} from './mechanic-model-revisions'; +import { + mechanicPublicProjectionSnapshot, + refreshMechanicPublicProjectionInTransaction, +} from './mechanic-public-projection'; + +type AuthoredPayload = Pick< + MechanicAuthoredSource, + 'version' | 'template' | 'variants' +>; + +type MechanicOwnerType = MechanicAuthoredOwner['type']; + +const authoredTokenPattern = /\{([^{}]+)\}/g; +const authoredNamedTokens = new Set(['duration', 'max_stacks']); + +/** + * Validates the token grammar shared by authored descriptions and reusable + * Specialization combat-effect templates. This intentionally does not require + * an owner or variant payload, so templates can be checked before any source + * exists (including a reusable effect with no rank attachments). + */ +export function validateMechanicAuthoredTemplate(template: unknown): string[] { + const errors: string[] = []; + if (typeof template !== 'string' || !template.trim()) { + errors.push('The authored template is required.'); + return errors; + } + if (/[{}]/.test(template.replace(authoredTokenPattern, ''))) { + errors.push('The authored template contains malformed placeholder braces.'); + } + for (const match of template.matchAll(authoredTokenPattern)) { + const token = match[1]; + if (!authoredNamedTokens.has(token) && !/^[1-9]\d*$/.test(token)) { + errors.push( + `The authored template contains unsupported token {${token}}.`, + ); + } + } + return errors; +} + +/** + * Returns owner-specific restrictions for a typed conditional mechanic draft. + * The draft has already passed its structural parser, but this second walk is + * required because callers outside a form action can still supply forged JSON. + */ +export function validateMechanicOwnerCapabilities( + ownerType: MechanicOwnerType, + draft: ConditionalMechanicDraftV1, +): string[] { + const errors: string[] = []; + const allowsAssignedAbility = ownerType === 'skill_chip_talent'; + const allowsProtocolUpgradeScaling = ownerType === 'os_protocol_talent'; + const add = (message: string) => { + if (!errors.includes(message)) errors.push(message); + }; + + if (!allowsAssignedAbility) { + draft.clauses.forEach((clause, clauseIndex) => { + const path = `Clause ${clauseIndex + 1}`; + if (clause.requiresAssignedAbilityActive === true) { + add( + `${path} cannot require an active assigned ability for this owner.`, + ); + } + clause.mutations.forEach((mutation, mutationIndex) => { + if ( + mutation.kind === 'extend_duration' && + mutation.target === 'assigned_ability' + ) { + add( + `${path} mutation ${mutationIndex + 1} cannot extend assigned-ability duration for this owner.`, + ); + } + }); + clause.outcomes.forEach((outcome, outcomeIndex) => { + if (outcome.scope === 'assigned_ability') { + add( + `${path} outcome ${outcomeIndex + 1} cannot use the assigned-ability scope for this owner.`, + ); + } + }); + }); + } + + // Walk every typed expression, predicate, reducer, and nested draft value so + // references cannot be hidden in a branch that the editor does not expose. + const visit = (value: unknown, path: string) => { + if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, `${path}[${index}]`)); + return; + } + if (typeof value !== 'object' || value === null) return; + const record = value as Record; + if (!allowsAssignedAbility && record.source === 'assigned_ability') { + add(`${path} cannot reference assigned-ability state for this owner.`); + } + if ( + !allowsProtocolUpgradeScaling && + record.source === 'equipment' && + record.path === 'protocolTalentUpgrades' + ) { + add( + `${path} cannot reference equipment.protocolTalentUpgrades for this owner.`, + ); + } + Object.entries(record).forEach(([key, nested]) => + visit(nested, `${path}.${key}`), + ); + }; + visit(draft, 'Conditional mechanic'); + return errors; +} + +export type MechanicOwnerSelection = + | { intent: 'preserve' } + | { intent: 'clear' } + | { intent: 'save'; draft: ConditionalMechanicDraftV1 }; + +export type PersistMechanicOwnerStateInput = { + source: MechanicAuthoredSource; + expectedSourceFingerprint: string | null; + selection: MechanicOwnerSelection; + programName: string; + origin: string; + actorId: string | null; + resolveAttributeLabel?: MechanicAttributeLabelResolver; +}; + +export type PersistedMechanicOwnerState = { + source: Awaited>; + selectedRevision: MechanicModelRevisionRecord | null; + revisionCreated: boolean; + projection: Awaited< + ReturnType + >; +}; + +function parseJsonObject( + raw: FormDataEntryValue | string | null, + label: string, +) { + if (typeof raw !== 'string' || !raw) throw new Error(`${label} is required.`); + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(); + } + return value as Record; + } catch { + throw new Error(`${label} must be valid JSON.`); + } +} + +export function parseMechanicAuthoredSourcePayload( + raw: FormDataEntryValue | string | null, + owner: MechanicAuthoredOwner, + specialization?: SpecializationAuthoredInputs, +): MechanicAuthoredSource { + const payload = parseJsonObject(raw, 'The authored description'); + const allowed = new Set(['version', 'template', 'variants']); + if (Object.keys(payload).some((key) => !allowed.has(key))) { + throw new Error('The authored description contains an unsupported field.'); + } + const parsed = parseMechanicAuthoredSource({ + ...payload, + owner, + ...(specialization ? { specialization } : {}), + }); + if (!parsed.ok) { + throw new Error(parsed.errors.map((error) => error.message).join(' ')); + } + return parsed.value; +} + +export function parseExpectedSourceFingerprint( + raw: FormDataEntryValue | string | null, +): string | null { + if (raw === null || raw === '') return null; + const value = String(raw); + if (!/^[0-9a-f]{64}$/.test(value)) { + throw new Error('The expected authored-source fingerprint is invalid.'); + } + return value; +} + +export function parseExpectedOwnerUpdatedAt( + raw: FormDataEntryValue | string | null, + required: boolean, +): string | null { + if (raw === null || raw === '') { + if (required) + throw new Error('The expected owner version is required. Reload the editor and try again.'); + return null; + } + const value = String(raw); + if ( + value !== value.trim() || + value.length > 100 || + /[\u0000-\u001f\u007f-\u009f]/u.test(value) + ) { + throw new Error('The expected owner version is invalid.'); + } + return value; +} + +export function parseMechanicSelectionIntent( + rawIntent: FormDataEntryValue | string | null, + rawDraft: FormDataEntryValue | string | null, + options: { + variantKeys: readonly string[]; + allowedAttributeIds?: ReadonlySet; + allowedRecipeKeys?: ReadonlySet; + programName: string; + ownerType?: MechanicOwnerType; + }, +): MechanicOwnerSelection { + const intent = String(rawIntent ?? '') as MechanicSelectionIntent; + if (intent === 'preserve' || intent === 'clear') return { intent }; + if (intent !== 'save') { + throw new Error('Choose whether to preserve, save, or clear the model.'); + } + const parsed = parseConditionalMechanicDraft(rawDraft, { + variantKeys: options.variantKeys, + allowedAttributeIds: options.allowedAttributeIds, + allowedRecipeKeys: options.allowedRecipeKeys, + programName: options.programName, + }); + if (!parsed.ok) throw new Error(parsed.errors.join(' ')); + const capabilityErrors = validateMechanicOwnerCapabilities( + options.ownerType ?? 'talent', + parsed.value.draft, + ); + if (capabilityErrors.length) throw new Error(capabilityErrors.join(' ')); + return { intent, draft: parsed.value.draft }; +} + +/** + * Persists authored source, selection intent, immutable revision, and public + * projection inside the caller's active owner transaction. + */ +export async function persistMechanicOwnerStateInTransaction( + database: Database, + input: PersistMechanicOwnerStateInput, +): Promise { + if (input.selection.intent === 'save') { + const capabilityErrors = validateMechanicOwnerCapabilities( + input.source.owner.type, + input.selection.draft, + ); + if (capabilityErrors.length) throw new Error(capabilityErrors.join(' ')); + } + + const source = await saveMechanicAuthoredSourceInTransaction( + database, + input.source, + { expectedSourceFingerprint: input.expectedSourceFingerprint }, + ); + + let revisionCreated = false; + let selectedRevision: MechanicModelRevisionRecord | null; + let forceAuthoredFallback = false; + if (input.selection.intent === 'save') { + const saved = await saveMechanicModelRevisionInTransaction( + database, + source, + input.selection.draft, + { + programName: input.programName, + origin: input.origin, + actorId: input.actorId, + }, + ); + revisionCreated = saved.created; + selectedRevision = saved.revision; + } else if (input.selection.intent === 'clear') { + await clearMechanicModelSelectionInTransaction(database, source.id); + selectedRevision = null; + } else { + selectedRevision = await loadSelectedMechanicModelRevision( + database, + source.id, + ); + if (selectedRevision) { + forceAuthoredFallback = + validateMechanicOwnerCapabilities( + source.source.owner.type, + selectedRevision.draft, + ).length > 0; + } + } + + const projection = await refreshMechanicPublicProjectionInTransaction( + database, + source, + { + resolveAttributeLabel: input.resolveAttributeLabel, + forceAuthoredFallback, + }, + ); + return { source, selectedRevision, revisionCreated, projection }; +} + +export function mechanicOwnerStateSnapshot( + state: PersistedMechanicOwnerState | null, +) { + if (!state) return null; + return { + authoredSource: mechanicAuthoredSourceSnapshot(state.source), + selectedRevision: mechanicModelRevisionSnapshot(state.selectedRevision), + publicProjection: mechanicPublicProjectionSnapshot( + state.selectedRevision, + state.projection.variants, + ), + }; +} diff --git a/src/lib/server/mechanic-persistence.test.ts b/src/lib/server/mechanic-persistence.test.ts new file mode 100644 index 0000000..bc8e1d7 --- /dev/null +++ b/src/lib/server/mechanic-persistence.test.ts @@ -0,0 +1,598 @@ +import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + MechanicSourceFingerprintConflictError, + loadMechanicAuthoredSource, + saveMechanicAuthoredSourceInTransaction, +} from './mechanic-authored-sources'; +import { + clearMechanicModelSelectionInTransaction, + loadSelectedMechanicModelRevision, + saveMechanicModelRevisionInTransaction, +} from './mechanic-model-revisions'; +import { + parseExpectedSourceFingerprint, + parseMechanicAuthoredSourcePayload, + parseMechanicSelectionIntent, + persistMechanicOwnerStateInTransaction, +} from './mechanic-owner-mutation'; +import { + loadMechanicPublicProjection, + refreshMechanicPublicProjectionInTransaction, +} from './mechanic-public-projection'; + +let database: Database; + +const applySchema = async (target: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +const talentSource = (secondTierDamage = '20'): MechanicAuthoredSource => ({ + version: 1, + owner: { type: 'talent', id: 'talent-1', scope: 'standard' }, + template: 'After {1} hits, gain {2}% weapon damage for {duration} seconds.', + variants: [ + { + key: 'I', + materialized: 'After 10 hits, gain 15% weapon damage for 5 seconds.', + slots: [ + { key: '1', value: '10', order: 0 }, + { + key: '2', + value: '15', + order: 1, + unit: '%', + attributeId: 'weapon-damage', + }, + { key: 'duration', value: '5', order: 2, unit: 'seconds' }, + ], + }, + { + key: 'II', + materialized: `After 8 hits, gain ${secondTierDamage}% weapon damage for 7 seconds.`, + slots: [ + { key: '1', value: '8', order: 0 }, + { + key: '2', + value: secondTierDamage, + order: 1, + unit: '%', + attributeId: 'weapon-damage', + }, + { key: 'duration', value: '7', order: 2, unit: 'seconds' }, + ], + }, + ], +}); + +const mechanicDraft = (tierTwoValue = 20): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'weapon-hit', + label: 'Weapon hit', + event: 'weapon_hit', + reducer: { kind: 'count', threshold: 10 }, + mutations: [], + outcomes: [ + { + key: 'weapon-damage', + label: 'Weapon damage', + phase: 'after_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + basis: 'flat', + attributeId: 'weapon-damage', + perStack: false, + variants: { + I: { value: 15 }, + II: { value: tierTwoValue }, + }, + }, + ], + }, + ], +}); + +const protocolSource = ( + description = 'Gain weapon damage when the protocol activates.', +): MechanicAuthoredSource => ({ + version: 1, + owner: { type: 'os_protocol_talent', id: 'protocol-1' }, + template: description, + variants: [{ key: 'base', materialized: description, slots: [] }], +}); + +const protocolDraftWithForbiddenScaling = (): ConditionalMechanicDraftV1 => { + const value = mechanicDraft(); + value.clauses[0].outcomes[0].variants = { base: { value: 15 } }; + value.clauses[0].outcomes[0].variants.base.expression = { + op: 'add', + left: { ref: { source: 'variant_value' } }, + right: { + ref: { source: 'equipment', path: 'protocolTalentUpgrades' }, + }, + }; + return value; +}; + +const count = async (table: string) => + Number( + ( + await ( + await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`) + ).get() + )?.count ?? 0, + ); + +const execute = async (sql: string, ...parameters: unknown[]) => + (await database.prepare(sql)).run(...parameters); + +describe('mechanic authored source and revision persistence', () => { + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + await database.exec(`INSERT INTO users(id,username,role,created_at) + VALUES('actor-1','Agent','admin','now')`); + await database.exec(`INSERT INTO stat_attributes( + id,name,type,category,calculation_type,unit,created_at,updated_at + ) VALUES('weapon-damage','Weapon Damage','Offensive','Weapon','Additive','%','now','now')`); + }); + + afterEach(async () => { + await database.close(); + }); + + it('constructs trusted owner sources and explicit selection intent from form payloads', () => { + const source = talentSource(); + const payload = JSON.stringify({ + version: source.version, + template: source.template, + variants: source.variants, + }); + expect(parseMechanicAuthoredSourcePayload(payload, source.owner)).toEqual( + source, + ); + expect(() => + parseMechanicAuthoredSourcePayload( + JSON.stringify({ + version: source.version, + owner: { type: 'talent', id: 'attacker' }, + template: source.template, + variants: source.variants, + }), + source.owner, + ), + ).toThrow(/unsupported field/); + + expect(parseExpectedSourceFingerprint('a'.repeat(64))).toBe('a'.repeat(64)); + expect(parseExpectedSourceFingerprint('')).toBeNull(); + expect(() => parseExpectedSourceFingerprint('not-a-hash')).toThrow( + /fingerprint is invalid/, + ); + expect( + parseMechanicSelectionIntent('preserve', null, { + variantKeys: ['I', 'II'], + programName: 'Measured fire', + }), + ).toEqual({ intent: 'preserve' }); + expect( + parseMechanicSelectionIntent('save', JSON.stringify(mechanicDraft()), { + variantKeys: ['I', 'II'], + programName: 'Measured fire', + allowedAttributeIds: new Set(['weapon-damage']), + }), + ).toMatchObject({ intent: 'save', draft: mechanicDraft() }); + }); + + it('rejects a forged owner-restricted draft before writing its authored source', async () => { + const forged = mechanicDraft(); + forged.clauses[0].requiresAssignedAbilityActive = true; + forged.clauses[0].outcomes[0].scope = 'assigned_ability'; + forged.clauses[0].if = { + kind: 'exists', + value: { source: 'assigned_ability', path: 'active' }, + }; + + await expect( + persistMechanicOwnerStateInTransaction(database, { + source: talentSource(), + expectedSourceFingerprint: null, + selection: { intent: 'save', draft: forged }, + programName: 'Measured fire', + origin: 'isac', + actorId: 'actor-1', + }), + ).rejects.toThrow(/assigned/); + expect(await count('mechanic_authored_sources')).toBe(0); + expect(await count('mechanic_model_revisions')).toBe(0); + }); + + it('round-trips exact authored variants and rejects stale fingerprints without writes', async () => { + const saved = await saveMechanicAuthoredSourceInTransaction( + database, + talentSource(), + { expectedSourceFingerprint: null }, + ); + expect( + await loadMechanicAuthoredSource(database, 'talent', 'talent-1'), + ).toEqual(saved); + + await expect( + saveMechanicAuthoredSourceInTransaction(database, talentSource('25'), { + expectedSourceFingerprint: 'f'.repeat(64), + }), + ).rejects.toBeInstanceOf(MechanicSourceFingerprintConflictError); + + expect(await count('mechanic_authored_sources')).toBe(1); + expect(await count('mechanic_authored_variants')).toBe(2); + expect(await count('mechanic_authored_slots')).toBe(6); + expect( + (await loadMechanicAuthoredSource(database, 'talent', 'talent-1')) + ?.fingerprint, + ).toBe(saved.fingerprint); + }); + + it('appends revisions, skips the identical selected retry, and preserves history when cleared', async () => { + const source = await saveMechanicAuthoredSourceInTransaction( + database, + talentSource(), + { expectedSourceFingerprint: null }, + ); + const first = await saveMechanicModelRevisionInTransaction( + database, + source, + mechanicDraft(), + { programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' }, + ); + expect(first.created).toBe(true); + expect(first.revision.revisionNumber).toBe(1); + + const retry = await saveMechanicModelRevisionInTransaction( + database, + source, + mechanicDraft(), + { programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' }, + ); + expect(retry).toMatchObject({ + created: false, + revision: { id: first.revision.id, revisionNumber: 1 }, + }); + expect(await count('mechanic_model_revisions')).toBe(1); + + const second = await saveMechanicModelRevisionInTransaction( + database, + source, + mechanicDraft(25), + { programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' }, + ); + expect(second).toMatchObject({ + created: true, + revision: { revisionNumber: 2 }, + }); + expect(await count('mechanic_model_revisions')).toBe(2); + expect(await count('mechanic_model_revision_outcomes')).toBe(4); + expect( + await loadSelectedMechanicModelRevision(database, source.id), + ).toMatchObject({ + id: second.revision.id, + revisionNumber: 2, + draft: mechanicDraft(25), + }); + + await clearMechanicModelSelectionInTransaction(database, source.id); + expect( + await loadSelectedMechanicModelRevision(database, source.id), + ).toBeNull(); + expect(await count('mechanic_model_revisions')).toBe(2); + expect(await count('mechanic_model_revision_outcomes')).toBe(4); + }); + + it('applies save, preserve, and clear intent through one owner persistence seam', async () => { + const source = talentSource(); + const saved = await persistMechanicOwnerStateInTransaction(database, { + source, + expectedSourceFingerprint: null, + selection: { intent: 'save', draft: mechanicDraft() }, + programName: 'Measured fire', + origin: 'isac', + actorId: 'actor-1', + resolveAttributeLabel: () => 'Weapon Damage', + }); + expect(saved).toMatchObject({ + revisionCreated: true, + selectedRevision: { revisionNumber: 1 }, + projection: { lifecycle: { status: 'current' } }, + }); + + const proseChanged = talentSource('25'); + const preserved = await persistMechanicOwnerStateInTransaction(database, { + source: proseChanged, + expectedSourceFingerprint: saved.source.fingerprint, + selection: { intent: 'preserve' }, + programName: 'Measured fire', + origin: 'isac', + actorId: 'actor-1', + resolveAttributeLabel: () => 'Weapon Damage', + }); + expect(preserved).toMatchObject({ + revisionCreated: false, + selectedRevision: { id: saved.selectedRevision?.id }, + projection: { + lifecycle: { status: 'stale', reason: 'fingerprint_mismatch' }, + }, + }); + + const cleared = await persistMechanicOwnerStateInTransaction(database, { + source: proseChanged, + expectedSourceFingerprint: preserved.source.fingerprint, + selection: { intent: 'clear' }, + programName: 'Measured fire', + origin: 'isac', + actorId: 'actor-1', + resolveAttributeLabel: () => 'Weapon Damage', + }); + expect(cleared).toMatchObject({ + revisionCreated: false, + selectedRevision: null, + projection: { lifecycle: { status: 'absent' } }, + }); + expect(await count('mechanic_model_revisions')).toBe(1); + }); + + it('preserves an invalid selected capability while falling back to authored prose', async () => { + const source = await saveMechanicAuthoredSourceInTransaction( + database, + protocolSource(), + { expectedSourceFingerprint: null }, + ); + const revision = await saveMechanicModelRevisionInTransaction( + database, + source, + protocolDraftWithForbiddenScaling(), + { programName: 'Protocol scaling', origin: 'isac', actorId: 'actor-1' }, + ); + + const changedSource = protocolSource( + 'Gain weapon damage after the protocol activates.', + ); + const preserved = await persistMechanicOwnerStateInTransaction(database, { + source: changedSource, + expectedSourceFingerprint: source.fingerprint, + selection: { intent: 'preserve' }, + programName: 'Protocol scaling', + origin: 'isac', + actorId: 'actor-1', + }); + + expect(preserved.selectedRevision?.id).toBe(revision.revision.id); + expect(preserved.projection.variants).toMatchObject([ + { + variantKey: 'base', + status: 'not_modeled', + displaySource: 'authored', + behaviorText: 'Gain weapon damage after the protocol activates.', + }, + ]); + expect(preserved.source.source.template).toBe(changedSource.template); + expect( + await loadSelectedMechanicModelRevision(database, source.id), + ).toMatchObject({ + id: revision.revision.id, + }); + expect(await count('mechanic_model_revisions')).toBe(1); + }); + + it('fails closed for corrupted public projection rows', async () => { + const persisted = await persistMechanicOwnerStateInTransaction(database, { + source: talentSource(), + expectedSourceFingerprint: null, + selection: { intent: 'save', draft: mechanicDraft() }, + programName: 'Measured fire', + origin: 'isac', + actorId: 'actor-1', + resolveAttributeLabel: () => 'Weapon Damage', + }); + const second = await saveMechanicModelRevisionInTransaction( + database, + persisted.source, + mechanicDraft(25), + { programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' }, + ); + await refreshMechanicPublicProjectionInTransaction( + database, + persisted.source, + { resolveAttributeLabel: () => 'Weapon Damage' }, + ); + const selectedRevisionId = second.revision.id; + const alternateRevisionId = persisted.selectedRevision?.id; + expect(alternateRevisionId).toBeTruthy(); + expect( + await loadMechanicPublicProjection(database, persisted.source.id), + ).toHaveLength(2); + + const expectClosed = async () => + expect( + await loadMechanicPublicProjection(database, persisted.source.id), + ).toEqual([]); + const refresh = async () => { + await refreshMechanicPublicProjectionInTransaction( + database, + persisted.source, + { resolveAttributeLabel: () => 'Weapon Damage' }, + ); + }; + + await execute( + "UPDATE mechanic_public_variants SET behavior_text='tampered model text' WHERE source_id=? AND variant_key='I'", + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + await execute( + 'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?', + 'b'.repeat(64), + selectedRevisionId, + ); + await expectClosed(); + await execute( + 'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?', + second.revision.programHash, + selectedRevisionId, + ); + await refresh(); + + await execute( + 'UPDATE mechanic_public_variants SET current_source_fingerprint=? WHERE source_id=?', + 'b'.repeat(64), + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + await execute( + "DELETE FROM mechanic_public_variants WHERE source_id=? AND variant_key='II'", + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + await database.exec('PRAGMA foreign_keys=OFF'); + await execute( + "UPDATE mechanic_public_variants SET variant_key='extra' WHERE source_id=? AND variant_key='I'", + persisted.source.id, + ); + await expectClosed(); + await database.exec('PRAGMA foreign_keys=ON'); + await refresh(); + + await execute( + "UPDATE mechanic_public_variants SET selected_revision_id=? WHERE source_id=? AND variant_key='I'", + alternateRevisionId, + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + await execute( + 'UPDATE mechanic_public_variants SET authored_contract_version=2 WHERE source_id=?', + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + await execute( + 'UPDATE mechanic_public_variants SET mechanic_contract_version=2 WHERE source_id=?', + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + await database.exec('PRAGMA ignore_check_constraints=ON'); + await execute( + "UPDATE mechanic_public_variants SET status='not_modeled' WHERE source_id=? AND variant_key='I'", + persisted.source.id, + ); + await expectClosed(); + await database.exec('PRAGMA ignore_check_constraints=OFF'); + await refresh(); + + await database.exec('PRAGMA ignore_check_constraints=ON'); + await execute( + "UPDATE mechanic_public_variants SET status='not_modeled',display_source='authored',behavior_text='forged fallback' WHERE source_id=? AND variant_key='I'", + persisted.source.id, + ); + await expectClosed(); + await database.exec('PRAGMA ignore_check_constraints=OFF'); + await refresh(); + + await execute( + 'UPDATE mechanic_public_variants SET updated_at=? WHERE source_id=?', + 'not-a-timestamp', + persisted.source.id, + ); + await expectClosed(); + await refresh(); + + expect( + await loadMechanicPublicProjection(database, persisted.source.id), + ).toHaveLength(2); + expect(selectedRevisionId).toBe( + (await loadSelectedMechanicModelRevision(database, persisted.source.id)) + ?.id, + ); + }); + + it('keeps a selected revision stale after prose changes and projects authored fallback', async () => { + const source = await saveMechanicAuthoredSourceInTransaction( + database, + talentSource(), + { expectedSourceFingerprint: null }, + ); + const revision = await saveMechanicModelRevisionInTransaction( + database, + source, + mechanicDraft(), + { programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' }, + ); + const modeled = await refreshMechanicPublicProjectionInTransaction( + database, + source, + { resolveAttributeLabel: () => 'Weapon Damage' }, + ); + expect(modeled.lifecycle.status).toBe('current'); + expect( + modeled.variants.every((variant) => variant.status === 'modeled'), + ).toBe(true); + + const changedSource = await saveMechanicAuthoredSourceInTransaction( + database, + talentSource('25'), + { expectedSourceFingerprint: source.fingerprint }, + ); + const fallback = await refreshMechanicPublicProjectionInTransaction( + database, + changedSource, + { resolveAttributeLabel: () => 'Weapon Damage' }, + ); + + expect(fallback.lifecycle).toEqual({ + status: 'stale', + reason: 'fingerprint_mismatch', + }); + expect(fallback.variants).toMatchObject([ + { variantKey: 'I', status: 'not_modeled', displaySource: 'authored' }, + { + variantKey: 'II', + status: 'not_modeled', + displaySource: 'authored', + behaviorText: 'After 8 hits, gain 25% weapon damage for 7 seconds.', + }, + ]); + expect( + await loadSelectedMechanicModelRevision(database, source.id), + ).toMatchObject({ + id: revision.revision.id, + }); + expect(await loadMechanicPublicProjection(database, source.id)).toEqual( + fallback.variants, + ); + }); +}); diff --git a/src/lib/server/mechanic-public-projection.ts b/src/lib/server/mechanic-public-projection.ts new file mode 100644 index 0000000..f2c67b8 --- /dev/null +++ b/src/lib/server/mechanic-public-projection.ts @@ -0,0 +1,390 @@ +import { MECHANIC_AUTHORED_CONTRACT_VERSION } from '$lib/calculations/mechanic-authored-description'; +import { + MECHANIC_PROGRAM_HASH_VERSION, + classifyMechanicVariantLifecycle, + hashMechanicAuthoredSource, + type MechanicLifecycleResult, +} from '$lib/calculations/mechanic-lifecycle'; +import { + renderPublicMechanicPrograms, + type MechanicAttributeLabelResolver, +} from '$lib/calculations/mechanic-public-renderer'; +import type { Database } from '@tursodatabase/database'; +import { + loadMechanicAuthoredSourceById, + type MechanicAuthoredSourceRecord, +} from './mechanic-authored-sources'; +import { + loadSelectedMechanicModelRevision, + type MechanicModelRevisionRecord, +} from './mechanic-model-revisions'; + +export type MechanicPublicVariantRecord = { + sourceId: string; + variantKey: string; + selectedRevisionId: string | null; + currentSourceFingerprint: string; + authoredContractVersion: number; + mechanicContractVersion: number | null; + status: 'modeled' | 'not_modeled'; + behaviorText: string; + displaySource: 'model' | 'authored'; + updatedAt: string; +}; + +export type MechanicPublicProjection = { + lifecycle: MechanicLifecycleResult; + variants: MechanicPublicVariantRecord[]; +}; + +/** Rebuilds one owner's complete public projection in the caller transaction. */ +export async function refreshMechanicPublicProjectionInTransaction( + database: Database, + source: MechanicAuthoredSourceRecord, + options: { + resolveAttributeLabel?: MechanicAttributeLabelResolver; + /** Keeps a selected revision while deliberately publishing authored text. */ + forceAuthoredFallback?: boolean; + } = {}, +): Promise { + const selected = await loadSelectedMechanicModelRevision(database, source.id); + const lifecycle = await classifyMechanicVariantLifecycle({ + currentSourceFingerprint: source.fingerprint, + resolveAttributeLabel: options.resolveAttributeLabel, + selectedRevision: selected + ? { + variants: selected.variants, + sourceFingerprint: selected.sourceFingerprint, + programHash: selected.programHash, + mechanicContractVersion: selected.mechanicContractVersion, + simulationReadiness: selected.simulationReadiness, + } + : null, + }); + + const renderedByVariant = new Map(); + const modeled = + !options.forceAuthoredFallback && lifecycle.status === 'current'; + if (modeled && selected) { + const rendered = renderPublicMechanicPrograms(selected.variants, { + resolveAttributeLabel: options.resolveAttributeLabel, + }); + if (!rendered.ok) { + throw new Error( + `A current mechanic revision could not be rendered: ${rendered.errors + .map((error) => error.message) + .join(' ')}`, + ); + } + for (const variant of rendered.variants) { + renderedByVariant.set(variant.key, variant.text); + } + } + + const now = new Date().toISOString(); + const variants: MechanicPublicVariantRecord[] = source.source.variants.map( + (variant) => { + const behaviorText = modeled + ? renderedByVariant.get(variant.key) + : variant.materialized; + if (behaviorText === undefined) { + throw new Error( + `The selected mechanic revision is missing public variant ${variant.key}.`, + ); + } + return { + sourceId: source.id, + variantKey: variant.key, + selectedRevisionId: selected?.id ?? null, + currentSourceFingerprint: source.fingerprint, + authoredContractVersion: source.source.version, + mechanicContractVersion: selected?.mechanicContractVersion ?? null, + status: modeled ? 'modeled' : 'not_modeled', + behaviorText, + displaySource: modeled ? 'model' : 'authored', + updatedAt: now, + }; + }, + ); + + await ( + await database.prepare( + 'DELETE FROM mechanic_public_variants WHERE source_id=?', + ) + ).run(source.id); + for (const variant of variants) { + await ( + await database.prepare(`INSERT INTO mechanic_public_variants ( + source_id,variant_key,selected_revision_id,current_source_fingerprint, + authored_contract_version,mechanic_contract_version,status,behavior_text, + display_source,updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?)`) + ).run( + variant.sourceId, + variant.variantKey, + variant.selectedRevisionId, + variant.currentSourceFingerprint, + variant.authoredContractVersion, + variant.mechanicContractVersion, + variant.status, + variant.behaviorText, + variant.displaySource, + variant.updatedAt, + ); + } + + return { lifecycle, variants }; +} + +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isValidTimestamp(value: unknown): value is string { + return isNonEmptyString(value) && !Number.isNaN(Date.parse(value)); +} + +/** + * Loads only a complete, internally consistent projection. The projection is + * cached text, so a malformed or partially replaced cache must never become a + * public response and must not make a page load throw. + */ +export async function loadMechanicPublicProjection( + database: Database, + sourceId: string, +): Promise { + try { + if (!isNonEmptyString(sourceId)) return []; + const source = await loadMechanicAuthoredSourceById(database, sourceId); + if (!source || source.id !== sourceId) return []; + if ( + !SHA256_HEX_PATTERN.test(source.fingerprint) || + source.source.version !== MECHANIC_AUTHORED_CONTRACT_VERSION + ) { + return []; + } + const fingerprint = await hashMechanicAuthoredSource(source.source); + if (!fingerprint.ok || fingerprint.fingerprint !== source.fingerprint) { + return []; + } + + const [rows, selectionRow, attributeRows] = await Promise.all([ + ( + await database.prepare( + 'SELECT * FROM mechanic_public_variants WHERE source_id=?', + ) + ).all(sourceId), + ( + await database.prepare( + 'SELECT revision_id FROM mechanic_model_selections WHERE source_id=?', + ) + ).get(sourceId), + ( + await database.prepare( + 'SELECT id,name FROM stat_attributes WHERE active=1', + ) + ).all(), + ]); + const selected = await loadSelectedMechanicModelRevision( + database, + sourceId, + ); + const selectionRevisionId = selectionRow?.revision_id; + if (selectionRow != null && !isNonEmptyString(selectionRevisionId)) { + return []; + } + if ( + (selectionRevisionId === undefined) !== (selected === null) || + (selected !== null && selectionRevisionId !== selected.id) + ) { + return []; + } + + const expectedSelectedRevisionId = selected?.id ?? null; + const expectedMechanicContract = selected?.mechanicContractVersion ?? null; + if ( + selected && + (!Number.isInteger(expectedMechanicContract) || + expectedMechanicContract !== MECHANIC_PROGRAM_HASH_VERSION) + ) { + return []; + } + + const attributeLabels = new Map(); + for (const row of attributeRows) { + if (isNonEmptyString(row.id) && isNonEmptyString(row.name)) { + attributeLabels.set(row.id, row.name); + } + } + const resolveAttributeLabel: MechanicAttributeLabelResolver = (id) => + attributeLabels.get(id) ?? null; + const authoredVariants = source.source.variants; + const lifecycle = await classifyMechanicVariantLifecycle({ + currentSourceFingerprint: source.fingerprint, + resolveAttributeLabel, + selectedRevision: selected + ? { + variants: selected.variants, + sourceFingerprint: selected.sourceFingerprint, + programHash: selected.programHash, + mechanicContractVersion: selected.mechanicContractVersion, + simulationReadiness: selected.simulationReadiness, + } + : null, + }); + if (rows.length !== authoredVariants.length) return []; + const seenKeys = new Set(); + const rowsByKey = new Map(); + for (const row of rows) { + if ( + typeof row.source_id !== 'string' || + row.source_id !== sourceId || + typeof row.variant_key !== 'string' || + !row.variant_key.trim() || + seenKeys.has(row.variant_key) || + row.selected_revision_id === undefined || + row.current_source_fingerprint === undefined || + row.authored_contract_version === undefined || + row.mechanic_contract_version === undefined || + typeof row.status !== 'string' || + typeof row.display_source !== 'string' + ) { + return []; + } + seenKeys.add(row.variant_key); + + const selectedRevisionId = + row.selected_revision_id === null ? null : row.selected_revision_id; + const mechanicContractVersion = + row.mechanic_contract_version === null + ? null + : row.mechanic_contract_version; + if ( + (selectedRevisionId !== null && + !isNonEmptyString(selectedRevisionId)) || + (mechanicContractVersion !== null && + (!Number.isInteger(mechanicContractVersion) || + mechanicContractVersion <= 0)) || + selectedRevisionId !== expectedSelectedRevisionId || + mechanicContractVersion !== expectedMechanicContract || + typeof row.current_source_fingerprint !== 'string' || + row.current_source_fingerprint !== source.fingerprint || + !SHA256_HEX_PATTERN.test(row.current_source_fingerprint) || + typeof row.authored_contract_version !== 'number' || + !Number.isInteger(row.authored_contract_version) || + row.authored_contract_version !== source.source.version || + (row.status !== 'modeled' && row.status !== 'not_modeled') || + (row.display_source !== 'model' && row.display_source !== 'authored') || + (row.status === 'modeled' && + (row.display_source !== 'model' || + selectedRevisionId === null || + !selected || + selected.sourceFingerprint !== source.fingerprint || + selected.mechanicContractVersion !== + MECHANIC_PROGRAM_HASH_VERSION)) || + (row.status === 'not_modeled' && row.display_source !== 'authored') || + !isNonEmptyString(row.behavior_text) || + /[{}]/.test(row.behavior_text) || + !isValidTimestamp(row.updated_at) + ) { + return []; + } + + const parsed: MechanicPublicVariantRecord = { + sourceId: row.source_id, + variantKey: row.variant_key, + selectedRevisionId, + currentSourceFingerprint: row.current_source_fingerprint, + authoredContractVersion: row.authored_contract_version, + mechanicContractVersion, + status: row.status, + behaviorText: row.behavior_text, + displaySource: row.display_source, + updatedAt: row.updated_at, + }; + rowsByKey.set(parsed.variantKey, parsed); + } + + const variants = authoredVariants.map((authored) => { + const projection = rowsByKey.get(authored.key); + if (!projection) throw new Error('Missing authored mechanic variant.'); + if ( + projection.displaySource === 'authored' && + projection.behaviorText !== authored.materialized + ) { + throw new Error('Authored mechanic fallback text does not match.'); + } + return projection; + }); + const hasModeledRows = variants.some( + (variant) => variant.displaySource === 'model', + ); + if (hasModeledRows && lifecycle.status !== 'current') return []; + + const renderedByVariant = new Map(); + if (hasModeledRows && selected) { + const rendered = renderPublicMechanicPrograms(selected.variants, { + resolveAttributeLabel, + }); + if (!rendered.ok) return []; + if ( + rendered.variants.length !== authoredVariants.length || + rendered.variants.some( + (variant, index) => variant.key !== authoredVariants[index].key, + ) + ) { + return []; + } + for (const variant of rendered.variants) { + renderedByVariant.set(variant.key, variant.text); + } + if ( + variants.some( + (variant) => + variant.displaySource === 'model' && + renderedByVariant.get(variant.variantKey) !== variant.behaviorText, + ) + ) { + return []; + } + } + if ( + seenKeys.size !== authoredVariants.length || + authoredVariants.some( + (variant, index) => variants[index].variantKey !== variant.key, + ) + ) { + return []; + } + const firstStatus = variants[0]?.status; + if ( + firstStatus === undefined || + variants.some((variant) => variant.status !== firstStatus) + ) { + return []; + } + return variants; + } catch { + return []; + } +} + +export function mechanicPublicProjectionSnapshot( + selected: MechanicModelRevisionRecord | null, + projection: MechanicPublicVariantRecord[], +) { + return { + selectedRevisionId: selected?.id ?? null, + variants: projection.map((variant) => ({ + key: variant.variantKey, + status: variant.status, + displaySource: variant.displaySource, + behaviorText: variant.behaviorText, + sourceFingerprint: variant.currentSourceFingerprint, + authoredContractVersion: variant.authoredContractVersion, + mechanicContractVersion: variant.mechanicContractVersion, + })), + }; +} diff --git a/src/lib/server/media-journal.test.ts b/src/lib/server/media-journal.test.ts new file mode 100644 index 0000000..9fee275 --- /dev/null +++ b/src/lib/server/media-journal.test.ts @@ -0,0 +1,709 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { connect, type Database } from '@tursodatabase/database'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + applyIconMutation, + deleteGameDataIcon, + gameDataIconStorageDirectory, + saveGameDataIcon, +} from './game-data-icons'; +import { + applyGameDataRender, + deleteManagedImage, + managedImageStorageDirectory as renderStorageDirectory, + saveManagedImageFile, +} from './managed-images'; +import { + MediaMutationJournal, + captureMediaFile, + restoreMediaFile, +} from './media-journal'; + +const iconId = '11111111-1111-4111-8111-111111111111'; +const imageId = '33333333-3333-4333-8333-333333333333'; +const alternateImageId = '44444444-4444-4444-8444-444444444444'; + +let database: Database; +let storageDirectory: string; +const previousStorageDirectory = process.env.TDR_GAME_ICON_STORAGE_PATH; + +beforeEach(async () => { + storageDirectory = await mkdtemp( + path.join(os.tmpdir(), 'shd-media-journal-'), + ); + process.env.TDR_GAME_ICON_STORAGE_PATH = storageDirectory; + database = await connect(':memory:'); + await database.exec(` + CREATE TABLE talents (id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL); + CREATE TABLE talent_weapon_scopes (talent_id TEXT NOT NULL, scope TEXT NOT NULL); + CREATE TABLE game_data_icons ( + entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, storage_key TEXT, + byte_size INTEGER, updated_at TEXT, PRIMARY KEY(entity_type, entity_id) + ); + CREATE TABLE managed_images ( + id TEXT PRIMARY KEY, name TEXT, category TEXT, tags_json TEXT, is_icon INTEGER, + force_white INTEGER, storage_key TEXT, original_filename TEXT, byte_size INTEGER, + width INTEGER, height INTEGER, source_entity_type TEXT, source_entity_id TEXT, + created_by TEXT, created_at TEXT, updated_at TEXT + ); + `); + await database.exec( + `INSERT INTO talents(id,name,category) VALUES('${iconId}','Test Talent','Weapon')`, + ); +}); + +afterEach(async () => { + await database.close(); + await rm(storageDirectory, { recursive: true, force: true }); + if (previousStorageDirectory === undefined) + delete process.env.TDR_GAME_ICON_STORAGE_PATH; + else process.env.TDR_GAME_ICON_STORAGE_PATH = previousStorageDirectory; +}); + +const iconPath = () => + path.join(gameDataIconStorageDirectory(), `talent-${iconId}.webp`); +const renderPath = (key: string) => path.join(renderStorageDirectory(), key); +const renderMutation = (output: string, fileName = 'render.png') => ({ + file: new File(['source'], fileName, { type: 'image/png' }), + remove: false, + processed: { output: Buffer.from(output), width: 1000, height: 1000 }, +}); +const flushTasks = () => new Promise((resolve) => setImmediate(resolve)); + +const seedRender = async (sourceType = 'talent_render') => { + const key = `library-${imageId}.webp`; + await mkdir(renderStorageDirectory(), { recursive: true }); + await writeFile(renderPath(key), Buffer.from('old-render'), { flag: 'w' }); + await ( + await database.prepare(`INSERT INTO managed_images + (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, + source_entity_type,source_entity_id,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + imageId, + 'Old render', + 'Talents', + '[]', + 0, + 0, + key, + 'old.png', + 10, + 1000, + 1000, + sourceType, + iconId, + 'operator', + 'old', + 'old', + ); +}; + +describe('MediaMutationJournal', () => { + it('runs compensations in LIFO order once', async () => { + const journal = new MediaMutationJournal(); + const events: string[] = []; + journal.register(async () => { + events.push('first'); + }); + journal.register(async () => { + events.push('second'); + }); + + await journal.rollback(new Error('operation failed')); + await journal.rollback(new Error('operation failed again')); + + expect(events).toEqual(['second', 'first']); + }); + + it('aggregates the operation and compensation failures', async () => { + const journal = new MediaMutationJournal(); + const original = new Error('audit failed'); + const compensation = new Error('restore failed'); + journal.register(async () => { + throw compensation; + }); + + let failure: unknown; + try { + await journal.rollback(original); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([ + original, + compensation, + ]); + }); + + it('restores exact bytes and absence', async () => { + const filePath = path.join(storageDirectory, 'snapshot.webp'); + await writeFile(filePath, Buffer.from('before')); + const snapshot = await captureMediaFile(filePath); + await writeFile(filePath, Buffer.from('after')); + await restoreMediaFile(filePath, snapshot); + expect(await readFile(filePath, 'utf8')).toBe('before'); + + const absentPath = path.join(storageDirectory, 'absent.webp'); + const absent = await captureMediaFile(absentPath); + await writeFile(absentPath, Buffer.from('new')); + await restoreMediaFile(absentPath, absent); + await expect(readFile(absentPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('does not deadlock when a journal locks a path repeatedly', async () => { + const journal = new MediaMutationJournal(); + const filePath = path.join(storageDirectory, 'repeat.webp'); + await journal.lockPath(filePath); + await journal.lockPath(filePath); + journal.clear(); + }); + + it('releases a path when rollback compensation throws', async () => { + const first = new MediaMutationJournal(); + const second = new MediaMutationJournal(); + const filePath = path.join(storageDirectory, 'throwing.webp'); + await first.lockPath(filePath); + first.register(() => { + throw new Error('restore failed'); + }); + const waiting = second.lockPath(filePath); + + await expect(first.rollback(new Error('operation failed'))).rejects.toThrow( + 'Media rollback did not complete.', + ); + await waiting; + second.clear(); + }); + + it('starts legacy DB work before waiting on media across connections', async () => { + const databasePath = path.join(storageDirectory, 'deadlock.db'); + const firstDatabase = await connect(databasePath); + const secondDatabase = await connect(databasePath); + const storageKey = `library-${imageId}.webp`; + const filePath = renderPath(storageKey); + let beginResolved!: () => void; + const beginStarted = new Promise((resolve) => { + beginResolved = resolve; + }); + try { + await firstDatabase.exec(` + CREATE TABLE managed_images ( + id TEXT PRIMARY KEY, name TEXT, category TEXT, tags_json TEXT, + is_icon INTEGER, force_white INTEGER, storage_key TEXT, + original_filename TEXT, byte_size INTEGER, width INTEGER, height INTEGER, + source_entity_type TEXT, source_entity_id TEXT, created_by TEXT, + created_at TEXT, updated_at TEXT + ); + `); + await ( + await firstDatabase.prepare(`INSERT INTO managed_images + (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, + source_entity_type,source_entity_id,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + imageId, + 'Render', + 'Talents', + '[]', + 0, + 0, + storageKey, + 'old.png', + 10, + 1000, + 1000, + 'talent_render', + iconId, + 'operator', + 'old', + 'old', + ); + await mkdir(renderStorageDirectory(), { recursive: true }); + await writeFile(filePath, Buffer.from('old-render')); + + const legacyDatabase = { + exec: async (sql: string) => { + const result = await secondDatabase.exec(sql); + if (sql === 'BEGIN IMMEDIATE') beginResolved(); + return result; + }, + prepare: (sql: string) => secondDatabase.prepare(sql), + } as unknown as Database; + const holder = new MediaMutationJournal(); + await holder.lockPath(filePath); + const deletion = deleteManagedImage(legacyDatabase, imageId); + await Promise.race([ + beginStarted, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('legacy DB transaction did not start')), + 1000, + ), + ), + ]); + await expect(readFile(filePath, 'utf8')).resolves.toBe('old-render'); + holder.clear(); + await deletion; + await expect(readFile(filePath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + await firstDatabase.close(); + await secondDatabase.close(); + } + }); +}); + +describe('game-data icon media rollback', () => { + it.each([ + { + label: 'create', + before: null, + mutation: { output: Buffer.from('new-icon'), remove: false }, + }, + { + label: 'replace', + before: Buffer.from('old-icon'), + mutation: { output: Buffer.from('new-icon'), remove: false }, + }, + { + label: 'remove', + before: Buffer.from('old-icon'), + mutation: { output: null, remove: true }, + }, + ])( + '$label restores after a later operation failure', + async ({ before, mutation }) => { + if (before) { + await mkdir(gameDataIconStorageDirectory(), { recursive: true }); + await writeFile(iconPath(), before); + await ( + await database.prepare( + 'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)', + ) + ).run('talent', iconId, `talent-${iconId}.webp`, before.length, 'old'); + } + const journal = new MediaMutationJournal(); + await applyIconMutation( + database, + 'talent', + iconId, + mutation, + 'operator', + journal, + ); + if (before) { + if (mutation.remove) + await expect(readFile(iconPath())).rejects.toMatchObject({ + code: 'ENOENT', + }); + else expect(await readFile(iconPath())).toEqual(mutation.output); + } else expect(await readFile(iconPath())).toEqual(mutation.output); + + await journal.rollback(new Error('audit failed')); + if (before) expect(await readFile(iconPath())).toEqual(before); + else + await expect(readFile(iconPath())).rejects.toMatchObject({ + code: 'ENOENT', + }); + }, + ); + + it('retains a new icon after commit', async () => { + const journal = new MediaMutationJournal(); + await applyIconMutation( + database, + 'talent', + iconId, + { output: Buffer.from('committed-icon'), remove: false }, + 'operator', + journal, + ); + journal.clear(); + expect(await readFile(iconPath(), 'utf8')).toBe('committed-icon'); + }); + + it('waits for an earlier journal to clear before mutating the icon', async () => { + const first = new MediaMutationJournal(); + await applyIconMutation( + database, + 'talent', + iconId, + { output: Buffer.from('first-icon'), remove: false }, + 'operator', + first, + ); + + const second = new MediaMutationJournal(); + const mutation = applyIconMutation( + database, + 'talent', + iconId, + { output: Buffer.from('second-icon'), remove: false }, + 'operator', + second, + ); + + await flushTasks(); + await flushTasks(); + expect(await readFile(iconPath(), 'utf8')).toBe('first-icon'); + first.clear(); + await mutation; + expect(await readFile(iconPath(), 'utf8')).toBe('second-icon'); + second.clear(); + }); + + it('does not let rollback overwrite a later serialized icon mutation', async () => { + await mkdir(gameDataIconStorageDirectory(), { recursive: true }); + await writeFile(iconPath(), Buffer.from('old-icon')); + await ( + await database.prepare( + 'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)', + ) + ).run('talent', iconId, `talent-${iconId}.webp`, 8, 'old'); + + const first = new MediaMutationJournal(); + await applyIconMutation( + database, + 'talent', + iconId, + { output: Buffer.from('first-icon'), remove: false }, + 'operator', + first, + ); + const second = new MediaMutationJournal(); + const mutation = applyIconMutation( + database, + 'talent', + iconId, + { output: Buffer.from('second-icon'), remove: false }, + 'operator', + second, + ); + + await flushTasks(); + await flushTasks(); + expect(await readFile(iconPath(), 'utf8')).toBe('first-icon'); + await first.rollback(new Error('transaction failed')); + expect(await readFile(iconPath(), 'utf8')).toBe('old-icon'); + await mutation; + expect(await readFile(iconPath(), 'utf8')).toBe('second-icon'); + second.clear(); + }); + + it('also serializes a legacy icon writer without a journal', async () => { + const first = new MediaMutationJournal(); + await first.lockPath(iconPath()); + const mutation = saveGameDataIcon( + database, + 'talent', + iconId, + Buffer.from('legacy-icon'), + 'operator', + ); + await flushTasks(); + await expect(readFile(iconPath())).rejects.toMatchObject({ + code: 'ENOENT', + }); + first.clear(); + await mutation; + expect(await readFile(iconPath(), 'utf8')).toBe('legacy-icon'); + }); + + it('also serializes a legacy icon deleter without a journal', async () => { + await mkdir(gameDataIconStorageDirectory(), { recursive: true }); + await writeFile(iconPath(), Buffer.from('old-icon')); + await ( + await database.prepare( + 'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)', + ) + ).run('talent', iconId, `talent-${iconId}.webp`, 8, 'old'); + const first = new MediaMutationJournal(); + await first.lockPath(iconPath()); + const deletion = deleteGameDataIcon(database, 'talent', iconId); + await flushTasks(); + expect(await readFile(iconPath(), 'utf8')).toBe('old-icon'); + first.clear(); + await deletion; + await expect(readFile(iconPath())).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); +}); + +describe('managed render media rollback', () => { + it('removes a newly created render after a later operation failure', async () => { + const journal = new MediaMutationJournal(); + const result = await applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('new-render'), + 'operator', + journal, + ); + const key = String(result?.storage_key); + expect(await readFile(renderPath(key), 'utf8')).toBe('new-render'); + await journal.rollback(new Error('audit failed')); + await expect(readFile(renderPath(key))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('restores replacement and removal renders after a later operation failure', async () => { + await seedRender(); + const replacement = new MediaMutationJournal(); + await applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('replacement'), + 'operator', + replacement, + ); + expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe( + 'replacement', + ); + await replacement.rollback(new Error('audit failed')); + expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe( + 'old-render', + ); + + const removal = new MediaMutationJournal(); + await applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + { file: null, remove: true }, + 'operator', + removal, + ); + await removal.rollback(new Error('audit failed')); + expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe( + 'old-render', + ); + }); + + it('cleans up a new file if compensation registration is unavailable', async () => { + const journal = new MediaMutationJournal(); + journal.clear(); + + await expect( + saveManagedImageFile(imageId, Buffer.from('orphan'), journal), + ).rejects.toThrow('Cannot register media compensation after commit.'); + await expect( + readFile(renderPath(`library-${imageId}.webp`)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not register or delete a pre-existing file after an exclusive-create collision', async () => { + await mkdir(renderStorageDirectory(), { recursive: true }); + const filePath = renderPath(`library-${imageId}.webp`); + await writeFile(filePath, Buffer.from('pre-existing')); + const journal = new MediaMutationJournal(); + + await expect( + saveManagedImageFile(imageId, Buffer.from('replacement'), journal), + ).rejects.toMatchObject({ code: 'EEXIST' }); + await journal.rollback(new Error('collision')); + expect(await readFile(filePath, 'utf8')).toBe('pre-existing'); + }); + + it('retains a new render after commit', async () => { + const journal = new MediaMutationJournal(); + const result = await applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('committed-render'), + 'operator', + journal, + ); + const key = String(result?.storage_key); + journal.clear(); + expect(await readFile(renderPath(key), 'utf8')).toBe('committed-render'); + }); + + it('waits for an earlier journal to clear before mutating a render', async () => { + await seedRender(); + const first = new MediaMutationJournal(); + await applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('first-render'), + 'operator', + first, + ); + + const second = new MediaMutationJournal(); + const filePath = renderPath(`library-${imageId}.webp`); + const mutation = applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('second-render'), + 'operator', + second, + ); + + await flushTasks(); + await flushTasks(); + expect(await readFile(filePath, 'utf8')).toBe('first-render'); + first.clear(); + await mutation; + expect(await readFile(filePath, 'utf8')).toBe('second-render'); + second.clear(); + }); + + it('does not let rollback overwrite a later serialized render mutation', async () => { + await seedRender(); + const first = new MediaMutationJournal(); + const filePath = renderPath(`library-${imageId}.webp`); + await applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('first-render'), + 'operator', + first, + ); + const second = new MediaMutationJournal(); + const mutation = applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('second-render'), + 'operator', + second, + ); + + await flushTasks(); + await flushTasks(); + expect(await readFile(filePath, 'utf8')).toBe('first-render'); + await first.rollback(new Error('transaction failed')); + expect(await readFile(filePath, 'utf8')).toBe('old-render'); + await mutation; + expect(await readFile(filePath, 'utf8')).toBe('second-render'); + second.clear(); + }); + + it('also serializes a legacy managed-image writer without a journal', async () => { + const first = new MediaMutationJournal(); + const filePath = renderPath(`library-${imageId}.webp`); + await first.lockPath(filePath); + const mutation = saveManagedImageFile( + imageId, + Buffer.from('legacy-render'), + ); + await flushTasks(); + await expect(readFile(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + first.clear(); + await mutation; + expect(await readFile(filePath, 'utf8')).toBe('legacy-render'); + }); + + it('also serializes a legacy managed-image deleter without a journal', async () => { + await seedRender(); + const first = new MediaMutationJournal(); + const filePath = renderPath(`library-${imageId}.webp`); + await first.lockPath(filePath); + const deletion = deleteManagedImage(database, imageId); + await flushTasks(); + expect(await readFile(filePath, 'utf8')).toBe('old-render'); + first.clear(); + await deletion; + await expect(readFile(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('revalidates the render path before a waiting removal', async () => { + await seedRender(); + const oldPath = renderPath(`library-${imageId}.webp`); + const alternateKey = `library-${alternateImageId}.webp`; + const alternatePath = renderPath(alternateKey); + const first = new MediaMutationJournal(); + await first.lockPath(oldPath); + const second = new MediaMutationJournal(); + const waiting = second.lockPath(oldPath); + const removal = applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + { file: null, remove: true }, + 'operator', + second, + ); + + await flushTasks(); + await flushTasks(); + await writeFile(alternatePath, Buffer.from('current-render')); + await ( + await database.prepare( + 'UPDATE managed_images SET storage_key=? WHERE id=?', + ) + ).run(alternateKey, imageId); + first.clear(); + await waiting; + await removal; + await expect(readFile(alternatePath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + const removed = await ( + await database.prepare('SELECT id FROM managed_images WHERE id=?') + ).get(imageId); + expect(removed).toBeUndefined(); + await expect(readFile(oldPath, 'utf8')).resolves.toBe('old-render'); + second.clear(); + }); + + it('revalidates the render path before a waiting replacement', async () => { + await seedRender(); + const oldPath = renderPath(`library-${imageId}.webp`); + const alternateKey = `library-${alternateImageId}.webp`; + const alternatePath = renderPath(alternateKey); + const first = new MediaMutationJournal(); + await first.lockPath(oldPath); + const second = new MediaMutationJournal(); + const waiting = second.lockPath(oldPath); + const replacement = applyGameDataRender( + database, + 'talent', + iconId, + 'Test Talent', + renderMutation('second-render'), + 'operator', + second, + ); + + await flushTasks(); + await flushTasks(); + await writeFile(alternatePath, Buffer.from('current-render')); + await ( + await database.prepare( + 'UPDATE managed_images SET storage_key=? WHERE id=?', + ) + ).run(alternateKey, imageId); + first.clear(); + await waiting; + const result = await replacement; + expect(result).toMatchObject({ id: imageId, storage_key: alternateKey }); + expect(await readFile(alternatePath, 'utf8')).toBe('second-render'); + expect(await readFile(oldPath, 'utf8')).toBe('old-render'); + second.clear(); + }); +}); diff --git a/src/lib/server/media-journal.ts b/src/lib/server/media-journal.ts new file mode 100644 index 0000000..d365734 --- /dev/null +++ b/src/lib/server/media-journal.ts @@ -0,0 +1,172 @@ +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export type MediaFileSnapshot = { + exists: boolean; + bytes: Buffer | null; +}; + +/** Captures a media file exactly so a later transaction failure can restore it. */ +export async function captureMediaFile( + filePath: string, +): Promise { + try { + return { exists: true, bytes: await readFile(filePath) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { exists: false, bytes: null }; + throw error; + } +} + +/** Restores a snapshot captured by captureMediaFile. */ +export async function restoreMediaFile( + filePath: string, + snapshot: MediaFileSnapshot, +): Promise { + if (snapshot.exists) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, snapshot.bytes ?? Buffer.alloc(0), { + flag: 'w', + mode: 0o600, + }); + return; + } + await unlink(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); +} + +type Compensation = () => void | Promise; +export type ReleaseMediaPathLock = () => void; + +/** + * A process-local queue for each storage path. The queue promise represents + * the next holder, so releasing an earlier holder wakes only the next waiter. + */ +const mediaPathQueues = new Map>(); + +export async function acquireMediaPathLock( + filePath: string, +): Promise { + const key = path.resolve(filePath); + const previous = mediaPathQueues.get(key) ?? Promise.resolve(); + let releaseNext!: () => void; + const next = new Promise((resolve) => { + releaseNext = resolve; + }); + mediaPathQueues.set(key, next); + await previous; + + let released = false; + return () => { + if (released) return; + released = true; + if (mediaPathQueues.get(key) === next) mediaPathQueues.delete(key); + releaseNext(); + }; +} + +/** + * Per-operation LIFO filesystem rollback state. A journal is deliberately + * passed through the save call graph rather than stored in ambient state. + */ +export class MediaMutationJournal { + private compensations: Compensation[] = []; + private committed = false; + private rolledBack = false; + private rollbackPromise: Promise | null = null; + private pathLocks = new Map>(); + private locksReleased = false; + + /** + * Acquires a path for this journal. Repeated requests for the same path are + * idempotent, including while the first request is still waiting in queue. + */ + async lockPath(filePath: string): Promise { + if (this.committed) + throw new Error('Cannot register media compensation after commit.'); + if (this.rolledBack) + throw new Error('Cannot register media compensation after rollback.'); + + const key = path.resolve(filePath); + let lock = this.pathLocks.get(key); + if (!lock) { + lock = acquireMediaPathLock(key); + this.pathLocks.set(key, lock); + } + const release = await lock; + if (this.committed || this.rolledBack) { + release(); + throw new Error( + this.committed + ? 'Cannot register media compensation after commit.' + : 'Cannot register media compensation after rollback.', + ); + } + } + + register(compensation: Compensation): void { + if (this.committed) + throw new Error('Cannot register media compensation after commit.'); + if (this.rolledBack) + throw new Error('Cannot register media compensation after rollback.'); + this.compensations.push(compensation); + } + + /** Releases all path locks after a successful operation. */ + clear(): void { + if (this.rolledBack || this.committed) return; + this.compensations = []; + this.committed = true; + this.releasePathLocks(); + } + + commit(): void { + this.clear(); + } + + /** + * Runs each compensation once in reverse order. The original failure is + * included first when any compensation also fails, preserving both causes. + */ + async rollback(originalError?: unknown): Promise { + if (this.committed) return; + if (this.rollbackPromise) return this.rollbackPromise; + this.rolledBack = true; + const compensations = this.compensations.splice(0).reverse(); + this.rollbackPromise = this.runRollback(compensations, originalError); + return this.rollbackPromise; + } + + private releasePathLocks(): void { + if (this.locksReleased) return; + this.locksReleased = true; + const locks = [...this.pathLocks.values()]; + this.pathLocks.clear(); + for (const lock of locks) void lock.then((release) => release()); + } + + private async runRollback( + compensations: Compensation[], + originalError: unknown, + ): Promise { + const failures: unknown[] = []; + try { + for (const compensation of compensations) { + try { + await compensation(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) { + const causes = + originalError === undefined ? failures : [originalError, ...failures]; + throw new AggregateError(causes, 'Media rollback did not complete.'); + } + } finally { + this.releasePathLocks(); + } + } +} diff --git a/src/routes/isac/data-moderation/+page.server.ts b/src/routes/isac/data-moderation/+page.server.ts index 4832b24..c0036f9 100644 --- a/src/routes/isac/data-moderation/+page.server.ts +++ b/src/routes/isac/data-moderation/+page.server.ts @@ -3,15 +3,33 @@ import { randomUUID } from 'node:crypto'; import { getSessionUser } from '$lib/server/auth/session'; import { requirePermission } from '$lib/server/auth/authorization'; import { getDatabase } from '$lib/server/database'; +import type { Database } from '@tursodatabase/database'; import { + approvePhase2Proposal, deserializeProposal, + failPhase2Proposal, + MAX_PROPOSAL_TEXT_ENTRY_CHARS, + ProposalCanonicalValidationError, + ProposalPayloadValidationError, + ProposalDependenciesBlockedError, + ProposalDependencyCycleError, + ProposalNotPendingError, + parseSerializedProposalEntries, refreshProposalDependencies, + validateSerializedProposalEntries, } from '$lib/server/data-moderation'; import { getPagination, getRegistrySort } from '$lib/server/pagination'; +import { isPreviewFieldHidden, safePreviewValue } from '$lib/components/ProposalChangePreview.helpers'; import { recordIsacAudit } from '$lib/server/audit'; import type { Actions, PageServerLoad } from './$types'; import { createNotification } from '$lib/server/notifications'; -import { conditionalRuleSnapshot } from '$lib/server/conditional-effects'; +import { talentOwnerAuditSnapshot } from '../talents/talent-save'; +import { protocolSnapshot } from '../protocols/protocol-save'; +import { snapshot as skillChipSnapshot } from '../skillchips/skillchip-save'; +import { + combatEffectSnapshot, + nodeSnapshot, +} from '../specializations/specialization-save'; import { getSkillChipPrimaryProfiles, getSkillChipSecondaryPool, @@ -20,6 +38,9 @@ import { gameDataIconMetadata } from '$lib/server/game-data-icons'; import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots'; const statuses = ['pending', 'approved', 'rejected', 'failed']; +const INVALID_PROPOSAL_PAYLOAD_MESSAGE = 'The stored proposal payload is invalid.'; +const CANONICAL_FAILURE_MESSAGE = + 'The proposal did not pass current canonical validation.'; const modules = [ 'configuration', 'calculations', @@ -44,7 +65,31 @@ type SerializedEntry = type: string; }; -async function specializationProposalSnapshot(database: any, proposal: any) { +function proposalPayloadIsValid(payload: unknown) { + try { + parseSerializedProposalEntries(String(payload)); + return true; + } catch (error) { + if (error instanceof ProposalPayloadValidationError) return false; + throw error; + } +} + +async function notifySafely( + input: Parameters[0], + context: string, +) { + try { + await createNotification(input); + } catch (error) { + console.error(`Unable to notify Data Oversight ${context}`, error); + } +} + +async function specializationProposalSnapshot( + database: Database, + proposal: Record, +) { const id = String(proposal.entity_id ?? ''); if (proposal.action === 'saveProgression') { return { @@ -108,21 +153,8 @@ async function specializationProposalSnapshot(database: any, proposal: any) { ).all(id, id), }; } - if (proposal.action === 'saveCombatEffect') { - return { - record: - (await ( - await database.prepare( - 'SELECT * FROM specialization_combat_effects WHERE id=?', - ) - ).get(id)) ?? null, - attachments: await ( - await database.prepare( - 'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order', - ) - ).all(id), - }; - } + if (proposal.action === 'saveCombatEffect') + return combatEffectSnapshot(database, id); if (proposal.action === 'saveFocusAbilities') { return { record: @@ -138,72 +170,12 @@ async function specializationProposalSnapshot(database: any, proposal: any) { ).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), - combatEffects: await ( - await database.prepare( - 'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order', - ) - ).all(id), - }; - } + if (proposal.action === 'saveNode') return nodeSnapshot(database, id); return null; } -async function skillChipProposalSnapshot(database: any, id: string) { - const talent = await ( - await database.prepare( - 'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?', - ) - ).get(id); - return { - record: - (await ( - await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?') - ).get(id)) ?? null, - twoPiece: - (await ( - await database.prepare( - 'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?', - ) - ).get(id)) ?? null, - talent: talent ?? null, - talentEffects: talent - ? await ( - await database.prepare( - 'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order', - ) - ).all(talent.id) - : [], - conditional: talent - ? await conditionalRuleSnapshot( - database, - 'skill_chip_talent', - String(talent.id), - ) - : null, - }; +async function skillChipProposalSnapshot(database: Database, id: string) { + return skillChipSnapshot(database, id); } async function calculationProposalSnapshot(database: any, proposal: any) { @@ -316,40 +288,55 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const proposalsWithSnapshots = await Promise.all( proposals.map(async (proposal) => ({ ...proposal, + payloadValid: proposalPayloadIsValid(proposal.payload_json), current_snapshot: proposal.module === 'attributes' && proposal.entity_id ? ((await ( await database.prepare('SELECT * FROM stat_attributes WHERE id=?') ).get(String(proposal.entity_id))) ?? null) - : proposal.module === 'skillchips' && - proposal.action === 'savePrimaryProfile' - ? await getSkillChipPrimaryProfiles(database) - : proposal.module === 'skillchips' && - proposal.action === 'saveSecondaryPool' - ? await getSkillChipSecondaryPool(database) - : proposal.module === 'weaponmods' && - proposal.action === 'saveSlotIcon' && - proposal.entity_id - ? { - slot: weaponModSlotDefinitionById( - String(proposal.entity_id), - ), - icon: await gameDataIconMetadata( - database, - 'weapon_mod_slot', - String(proposal.entity_id), - ), - } - : proposal.module === 'skillchips' && proposal.entity_id - ? await skillChipProposalSnapshot( - database, - String(proposal.entity_id), - ) - : proposal.module === 'calculations' - ? await calculationProposalSnapshot(database, proposal) - : proposal.module === 'specializations' - ? await specializationProposalSnapshot(database, proposal) - : null, + : proposal.module === 'talents' && + proposal.action === 'saveTalent' && + proposal.entity_id + ? await talentOwnerAuditSnapshot( + database, + String(proposal.entity_id), + ) + : proposal.module === 'protocols' && + proposal.action === 'save' && + proposal.entity_id + ? await protocolSnapshot(String(proposal.entity_id), database) + : proposal.module === 'skillchips' && + proposal.action === 'savePrimaryProfile' + ? await getSkillChipPrimaryProfiles(database) + : proposal.module === 'skillchips' && + proposal.action === 'saveSecondaryPool' + ? await getSkillChipSecondaryPool(database) + : proposal.module === 'weaponmods' && + proposal.action === 'saveSlotIcon' && + proposal.entity_id + ? { + slot: weaponModSlotDefinitionById( + String(proposal.entity_id), + ), + icon: await gameDataIconMetadata( + database, + 'weapon_mod_slot', + String(proposal.entity_id), + ), + } + : proposal.module === 'skillchips' && proposal.entity_id + ? await skillChipProposalSnapshot( + database, + String(proposal.entity_id), + ) + : proposal.module === 'calculations' + ? await calculationProposalSnapshot(database, proposal) + : proposal.module === 'specializations' + ? await specializationProposalSnapshot( + database, + proposal, + ) + : null, })), ); const summaryRows = await ( @@ -392,13 +379,19 @@ export const actions: Actions = { moderationError: 'The proposed corrections could not be read.', }); } + if ( + new Set(corrections.map((item) => item.index)).size !== corrections.length + ) + return fail(400, { + moderationError: 'Each proposal field may only be corrected once.', + }); if ( corrections.length > 250 || corrections.some( (item) => !Number.isInteger(item.index) || item.index < 0 || - item.value.length > 100_000, + Array.from(item.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS, ) ) { return fail(400, { @@ -407,87 +400,150 @@ export const actions: Actions = { }); } const database = await getDatabase(); - const proposal = await ( - await database.prepare( - "SELECT * FROM data_change_proposals WHERE id=? AND status='pending'", - ) - ).get(id); - if (!proposal) - return fail(404, { - moderationError: 'That pending proposal no longer exists.', - }); - const entries = JSON.parse( - String(proposal.payload_json), - ) as SerializedEntry[]; - const changes: Array<{ - index: number; - name: string; - before: string; - after: string; - }> = []; - for (const correction of corrections) { - const entry = entries[correction.index]; - if (!entry || entry.kind !== 'text') - return fail(400, { - moderationError: - 'Only existing text and numeric proposal fields may be corrected.', - }); - if (entry.value !== correction.value) { - changes.push({ - index: correction.index, - name: entry.name, - before: entry.value, - after: correction.value, - }); - entry.value = correction.value; - } - } - if (!changes.length) - return fail(400, { moderationError: 'No proposal fields were changed.' }); - const payload = JSON.stringify(entries); - const oldDependencies = ( - await ( - await database.prepare( - 'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id', + const correctProposal = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const proposal = await ( + await tx.prepare( + "SELECT * FROM data_change_proposals WHERE id=? AND status='pending'", ) - ).all(id) - ).map((row) => String(row.depends_on_proposal_id)); - const name = entries - .find((entry) => entry.kind === 'text' && entry.name === 'name') - ?.value.trim(); - const summary = `${String(proposal.action) - .replace(/([A-Z])/g, ' $1') - .toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`; - await ( - await database.prepare( - 'UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?', - ) - ).run(payload, summary, id); - const dependencies = await refreshProposalDependencies(id, payload); - await recordIsacAudit({ - actorId: reviewer.id, - category: 'data_moderation', - action: 'proposal.corrected', - entityId: id, - before: { - summary: proposal.summary, - dependencies: oldDependencies, - fields: changes.map((change) => ({ - index: change.index, - name: change.name, - value: change.before, - })), - }, - after: { - summary, - dependencies, - fields: changes.map((change) => ({ - index: change.index, - name: change.name, - value: change.after, - })), - }, + ).get(id); + if (!proposal) throw new ProposalNotPendingError(); + + let entries: SerializedEntry[]; + try { + entries = parseSerializedProposalEntries(String(proposal.payload_json)); + } catch (error) { + if (error instanceof ProposalPayloadValidationError) + return { + ok: false as const, + moderationError: INVALID_PROPOSAL_PAYLOAD_MESSAGE, + }; + throw error; + } + const changes: Array<{ + index: number; + name: string; + before: string; + after: string; + }> = []; + for (const correction of corrections) { + const entry = entries[correction.index]; + if ( + entry && + (entry.name === 'id' || + entry.name === '_proposedEntityId' || + entry.name === 'expectedTalentId' || + entry.name === 'expectedOwnerUpdatedAt' || + entry.name === 'ranksJson') + ) + return { + ok: false as const, + moderationError: 'Proposal identities cannot be corrected.', + }; + if (!entry || entry.kind !== 'text') + return { + ok: false as const, + moderationError: + 'Only existing text and numeric proposal fields may be corrected.', + }; + if ( + isPreviewFieldHidden(entry.name) || + safePreviewValue(entry.name, entry.value) !== entry.value || + safePreviewValue(entry.name, correction.value) !== correction.value + ) + return { + ok: false as const, + moderationError: + 'Protected proposal fields cannot be corrected.', + }; + if (entry.value !== correction.value) { + changes.push({ + index: correction.index, + name: entry.name, + before: entry.value, + after: correction.value, + }); + entry.value = correction.value; + } + } + if (!changes.length) + return { + ok: false as const, + moderationError: 'No proposal fields were changed.', + }; + try { + validateSerializedProposalEntries(entries); + } catch (error) { + if (error instanceof ProposalPayloadValidationError) + return { + ok: false as const, + moderationError: + 'One or more corrected fields exceed the safe proposal limits.', + }; + throw error; + } + + const payload = JSON.stringify(entries); + const name = entries + .find((entry) => entry.kind === 'text' && entry.name === 'name') + ?.value.trim(); + const summary = `${String(proposal.action) + .replace(/([A-Z])/g, ' $1') + .toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`; + const oldDependencies = ( + await ( + await tx.prepare( + 'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id', + ) + ).all(id) + ).map((row) => String(row.depends_on_proposal_id)); + const updated = await ( + await tx.prepare( + "UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=? AND status='pending'", + ) + ).run(payload, summary, id); + if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); + const dependencies = await refreshProposalDependencies(id, payload, tx); + await recordIsacAudit({ + actorId: reviewer.id, + category: 'data_moderation', + action: 'proposal.corrected', + entityId: id, + before: { + summaryLength: Array.from(String(proposal.summary)).length, + dependencies: oldDependencies, + fields: changes.map((change) => ({ + index: change.index, + name: change.name, + changed: true, + valueLength: Array.from(change.before).length, + })), + }, + after: { + summaryLength: Array.from(summary).length, + dependencies, + fields: changes.map((change) => ({ + index: change.index, + name: change.name, + changed: true, + valueLength: Array.from(change.after).length, + })), + }, + database: tx, + }); + return { ok: true as const }; }); + try { + const result = await correctProposal.immediate(); + if (!result.ok) + return fail(400, { moderationError: result.moderationError }); + } catch (error) { + if (error instanceof ProposalNotPendingError) + return fail(404, { moderationError: error.message }); + if (error instanceof ProposalDependencyCycleError) + return fail(409, { moderationError: error.message }); + throw error; + } return { moderationSaved: true, moderationToken: randomUUID() }; }, approve: async ({ cookies, request, fetch, url }) => { @@ -510,11 +566,81 @@ export const actions: Actions = { return fail(404, { moderationError: 'That pending proposal no longer exists.', }); + + const phase2 = + (proposal.module === 'talents' && proposal.action === 'saveTalent') || + (proposal.module === 'protocols' && proposal.action === 'save') || + (proposal.module === 'skillchips' && proposal.action === 'save') || + (proposal.module === 'specializations' && + (proposal.action === 'saveNode' || + proposal.action === 'saveCombatEffect')); + if (phase2) { + try { + const result = await approvePhase2Proposal( + database, + reviewer.id, + id, + note || null, + ); + await notifySafely( + { + userId: String(result.proposal.proposed_by), + category: 'contributions', + source: 'ISAC DATA OVERSIGHT', + title: 'Contribution approved', + body: `${String(result.proposal.summary)} was approved and deployed to the live builder data set.`, + link: `/isac/${result.proposal.module}`, + mandatory: true, + }, + 'contributor of approved proposal', + ); + return { moderationSaved: true, moderationToken: randomUUID() }; + } catch (error) { + if (error instanceof ProposalDependenciesBlockedError) + return fail(409, { moderationError: error.message }); + if (error instanceof ProposalNotPendingError) + return fail(404, { moderationError: error.message }); + if (error instanceof ProposalCanonicalValidationError) { + const message = error.message; + let failedProposal; + try { + failedProposal = await failPhase2Proposal( + database, + reviewer.id, + id, + message, + ); + } catch (failureError) { + if (failureError instanceof ProposalNotPendingError) + return fail(404, { moderationError: failureError.message }); + throw failureError; + } + await notifySafely( + { + userId: String(failedProposal.proposed_by), + category: 'contributions', + source: 'ISAC DATA OVERSIGHT', + title: 'Contribution could not be deployed', + body: `${String(failedProposal.summary)} did not pass current validation. Oversight evidence is available in your notification inbox.`, + link: `/isac/${failedProposal.module}`, + mandatory: true, + }, + 'contributor of failed proposal', + ); + return fail(409, { + moderationError: + 'The proposal did not pass the current canonical validation and was marked failed.', + }); + } + throw error; + } + } + const unresolved = await ( await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status - FROM data_change_proposal_dependencies link - JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id - WHERE link.proposal_id=? AND dependency.status<>'approved'`) + FROM data_change_proposal_dependencies link + JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id + WHERE link.proposal_id=? AND dependency.status<>'approved'`) ).all(id); if (unresolved.length) { const names = unresolved @@ -528,7 +654,59 @@ export const actions: Actions = { `/isac/${proposal.module}?/${proposal.action}`, url.origin, ); - const proposalForm = deserializeProposal(String(proposal.payload_json)); + let proposalForm: FormData; + try { + proposalForm = deserializeProposal(String(proposal.payload_json)); + } catch (error) { + if (!(error instanceof ProposalPayloadValidationError)) throw error; + const failStoredPayload = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const now = new Date().toISOString(); + const updated = await ( + await tx.prepare( + "UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'", + ) + ).run( + reviewer.id, + INVALID_PROPOSAL_PAYLOAD_MESSAGE, + now, + id, + ); + if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); + await recordIsacAudit({ + actorId: reviewer.id, + category: 'data_moderation', + action: 'proposal.failed', + entityId: id, + before: { status: 'pending' }, + after: { status: 'failed', reason: 'invalid_payload' }, + database: tx, + }); + }); + try { + await failStoredPayload.immediate(); + } catch (failureError) { + if (failureError instanceof ProposalNotPendingError) + return fail(404, { moderationError: failureError.message }); + throw failureError; + } + await notifySafely( + { + userId: String(proposal.proposed_by), + category: 'contributions', + source: 'ISAC DATA OVERSIGHT', + title: 'Contribution could not be deployed', + body: `${proposal.summary} could not be deployed because its stored payload is invalid.`, + link: `/isac/${proposal.module}`, + mandatory: true, + }, + 'contributor of invalid proposal', + ); + return fail(409, { + moderationError: + 'The proposal could not be deployed because its stored payload is invalid and was marked failed.', + }); + } if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id)); const response = await fetch(target, { @@ -538,17 +716,11 @@ export const actions: Actions = { }); const now = new Date().toISOString(); if (!response.ok) { - const details = (await response.text()).slice(0, 1000); await ( await database.prepare( "UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?", ) - ).run( - reviewer.id, - details || note || 'Canonical validation failed.', - now, - id, - ); + ).run(reviewer.id, CANONICAL_FAILURE_MESSAGE, now, id); await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', @@ -557,15 +729,18 @@ export const actions: Actions = { before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status }, }); - await createNotification({ - userId: String(proposal.proposed_by), - category: 'contributions', - source: 'ISAC DATA OVERSIGHT', - title: 'Contribution could not be deployed', - body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, - link: `/isac/${proposal.module}`, - mandatory: true, - }); + await notifySafely( + { + userId: String(proposal.proposed_by), + category: 'contributions', + source: 'ISAC DATA OVERSIGHT', + title: 'Contribution could not be deployed', + body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, + link: `/isac/${proposal.module}`, + mandatory: true, + }, + 'contributor of failed proposal', + ); return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.', @@ -598,7 +773,9 @@ export const actions: Actions = { body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/isac/${proposal.module}`, mandatory: true, - }); + }).catch((error) => + console.error('Unable to notify Data Oversight contributor', error), + ); return { moderationSaved: true, moderationToken: randomUUID() }; }, reject: async ({ cookies, request }) => { @@ -616,46 +793,59 @@ export const actions: Actions = { moderationError: 'Add a brief reason for rejecting this proposal.', }); const database = await getDatabase(); - const proposal = await ( - await database.prepare( - "SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'", - ) - ).get(id); - if (!proposal) - return fail(404, { - moderationError: 'That pending proposal no longer exists.', + const rejectProposal = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const proposal = await ( + await tx.prepare( + "SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'", + ) + ).get(id); + if (!proposal) throw new ProposalNotPendingError(); + const now = new Date().toISOString(); + const updated = await ( + await tx.prepare( + "UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'", + ) + ).run(reviewer.id, note, now, id); + if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); + await recordIsacAudit({ + actorId: reviewer.id, + category: 'data_moderation', + action: 'proposal.rejected', + entityId: id, + before: { status: 'pending' }, + after: { + status: 'rejected', + module: proposal.module, + action: proposal.action, + proposedBy: proposal.proposed_by, + }, + database: tx, }); - const now = new Date().toISOString(); - await ( - await database.prepare( - "UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?", - ) - ).run(reviewer.id, note, now, id); - await recordIsacAudit({ - actorId: reviewer.id, - category: 'data_moderation', - action: 'proposal.rejected', - entityId: id, - before: { status: 'pending' }, - after: { - status: 'rejected', - note, - module: proposal.module, - action: proposal.action, - proposedBy: proposal.proposed_by, + return proposal as Record; + }); + let proposal: Record; + try { + proposal = await rejectProposal.immediate(); + } catch (error) { + if (error instanceof ProposalNotPendingError) + return fail(404, { moderationError: error.message }); + throw error; + } + await notifySafely( + { + userId: String(proposal.proposed_by), + category: 'contributions', + source: 'ISAC DATA OVERSIGHT', + title: 'Contribution needs revision', + body: `${String(proposal.action) + .replace(/([A-Z])/g, ' $1') + .trim()} was not approved. Oversight reason: ${note}`, + link: `/isac/${proposal.module}`, + mandatory: true, }, - }); - await createNotification({ - userId: String(proposal.proposed_by), - category: 'contributions', - source: 'ISAC DATA OVERSIGHT', - title: 'Contribution needs revision', - body: `${String(proposal.action) - .replace(/([A-Z])/g, ' $1') - .trim()} was not approved. Oversight reason: ${note}`, - link: `/isac/${proposal.module}`, - mandatory: true, - }); + 'contributor of rejected proposal', + ); return { moderationSaved: true, moderationToken: randomUUID() }; }, }; diff --git a/src/routes/isac/data-moderation/+page.svelte b/src/routes/isac/data-moderation/+page.svelte index 0fbf62a..34bdf21 100644 --- a/src/routes/isac/data-moderation/+page.svelte +++ b/src/routes/isac/data-moderation/+page.svelte @@ -5,26 +5,57 @@ import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte'; import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; + import { isPreviewFieldHidden, parseProposalPreviewPayload, safePreviewValue } from '$lib/components/ProposalChangePreview.helpers'; + import { correctionFieldIsEditable, correctionPayloadForSubmission, MAX_CONTRIBUTOR_NOTE_CHARS, MAX_REVIEW_NOTE_CHARS, renderBoundedPlainText, type CorrectionEntry } from './data-moderation.helpers'; let { data, form } = $props(); let reviewing = $state(null); let decision = $state<'approve' | 'correct' | 'reject'>('approve'); - let correctionEntries = $state>([]); + let correctionEntries = $state([]); let handledToken = $state(''); + const omittedPreview = '[OMITTED FROM HUMAN PREVIEW]'; + type EvidenceEntry = + | { name: string; kind: 'text'; value: string } + | { name: string; kind: 'file'; fileName: string; type: string; encodedBytes: number }; const date = (value: string) => new Date(value).toLocaleString(); - const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) => - entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry - ); + const evidence = (payload: string): EvidenceEntry[] => parseProposalPreviewPayload(payload).entries.reduce((result, entry) => { + if (isPreviewFieldHidden(entry.name)) return result; + if (entry.kind === 'file') { + result.push({ + name: entry.name, + kind: 'file', + fileName: safePreviewValue('fileName', entry.fileName ?? 'UPLOADED FILE'), + type: safePreviewValue('type', entry.type ?? 'FILE EVIDENCE'), + encodedBytes: typeof entry.value === 'string' ? entry.value.length : 0 + }); + return result; + } + const value = safePreviewValue(entry.name, entry.value ?? ''); + if (value !== omittedPreview) result.push({ name: entry.name, kind: 'text', value }); + return result; + }, []); const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase(); const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value === 'gearmods' ? 'GEAR MODS' : value.toUpperCase(); const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) }))); const openReview = (proposal: any) => { reviewing = proposal; decision = 'approve'; - correctionEntries = (JSON.parse(proposal.payload_json) as any[]).map((entry, index) => ({ - index, name: entry.name, value: entry.kind === 'text' ? entry.value : '', kind: entry.kind, fileName: entry.fileName - })); + correctionEntries = parseProposalPreviewPayload(String(proposal.payload_json)).entries + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => !isPreviewFieldHidden(entry.name)) + .map(({ entry, index }) => { + const value = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value ?? '') : ''; + return { + index, + name: entry.name, + value, + initialValue: value, + kind: entry.kind, + editable: entry.kind === 'text' && correctionFieldIsEditable(entry.name, value, entry.value ?? ''), + fileName: entry.kind === 'file' ? safePreviewValue('fileName', entry.fileName ?? 'UPLOADED FILE') : undefined + }; + }); }; - const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value }))); + const correctionPayload = () => correctionPayloadForSubmission(correctionEntries); const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses', 'qualityValues'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{'); $effect(() => { if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) { @@ -71,24 +102,33 @@