feat: persist authored mechanic revisions

This commit is contained in:
jb
2026-09-02 15:39:50 -04:00
parent 98f7f51e46
commit 7e392a1f37
56 changed files with 20720 additions and 3279 deletions
+260
View File
@@ -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,
+265
View File
@@ -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 });
}
});
});
+14
View File
@@ -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
+51 -10
View File
@@ -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)',
);
+18
View File
@@ -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;
+20 -1
View File
@@ -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(
let proposal: Awaited<ReturnType<typeof queueDataProposal>>;
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,
@@ -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(
@@ -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<string, unknown> =>
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;
}
@@ -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 }],
},
],
};
+151
View File
@@ -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<string> {
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<MechanicLifecycleResult> {
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,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+292 -156
View File
@@ -1,5 +1,16 @@
<script lang="ts">
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
import {
formatMechanicToken,
formatMechanicValue,
normalizePhase2OwnerPreview,
parseProposalPreviewPayload,
safePreviewValue,
isPreviewFieldHidden,
type AuthoredSourcePreview,
type AttachmentPreview,
type MechanicPreviewState,
type ProposalPreviewEntry
} from './ProposalChangePreview.helpers';
let {
module,
@@ -13,8 +24,10 @@
currentSnapshot?: Record<string, unknown> | null;
} = $props();
const entries = $derived(JSON.parse(payload) as Entry[]);
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
const parsedPayload = $derived(parseProposalPreviewPayload(payload));
const entries = $derived(parsedPayload.entries as ProposalPreviewEntry[]);
const proposed = $derived(parsedPayload.fields);
const phase2OwnerPreview = $derived(normalizePhase2OwnerPreview(module, action, payload, currentSnapshot));
const label = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
const jsonValue = (value: unknown) => {
@@ -43,16 +56,8 @@
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const specializationFields = $derived(
action === 'saveProgression'
? [
@@ -116,23 +121,80 @@
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
const phase2Record = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const phase2TargetDisplay = (side: 'current' | 'proposed') =>
side === 'current'
? phase2OwnerPreview?.canonicalTargetId
: phase2OwnerPreview?.canonicalTargetId ?? 'ASSIGNED ON APPROVAL';
const phase2HasCurrentTarget = $derived(Boolean(phase2OwnerPreview?.canonicalTargetId));
const ownerIdentityFields = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
const value = (key: string) => side === 'current' ? phase2Record?.[key] : proposed[key];
if (family === 'talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'CATEGORY', value: value('category') },
{ label: 'OWNER TYPE', value: side === 'current' ? (currentSnapshot?.authoredSource as any)?.owner?.type : 'talent' },
{ label: 'OWNER ID', value: phase2TargetDisplay(side) }
];
if (family === 'protocol-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'QUALITY', value: value('quality') },
{ label: 'ATTRIBUTE TYPE', value: side === 'current' ? phase2Record?.primary_attribute_type : proposed.attributeType },
{ label: 'PRIMARY ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.primaryEffect as any)?.stat_attribute_id : proposed.primaryStatId },
{ label: 'TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'skill-chip-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION ABILITY', value: side === 'current' ? phase2Record?.specialization_ability_id : proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.twoPiece as any)?.stat_attribute_id : proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', value: side === 'current' ? `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}` : `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'specialization-node') return [
{ label: 'NAME', value: value('name') },
{ label: 'FOCUS', value: side === 'current' ? phase2Record?.focus_id : proposed.focusId },
{ label: 'ROW', value: side === 'current' ? phase2Record?.row_number : proposed.rowNumber },
{ label: 'COLUMN', value: side === 'current' ? phase2Record?.column_number : proposed.columnNumber },
{ label: 'MAXIMUM RANK', value: side === 'current' ? phase2Record?.max_rank : proposed.maxRank },
{ label: 'RANK DEFINITIONS', value: side === 'current' ? (currentSnapshot?.ranks as any[])?.length ?? 0 : (jsonValue(proposed.ranksJson) as any[])?.length ?? 0 }
];
return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION', value: side === 'current' ? phase2Record?.specialization_id : proposed.specializationId },
{ label: 'DESCRIPTION TEMPLATE', value: side === 'current' ? phase2Record?.description_template : proposed.descriptionTemplate },
{ label: 'ATTACHMENTS', value: side === 'current' ? (currentSnapshot?.attachments as any[])?.length ?? 0 : phase2OwnerPreview?.proposedAttachments.length ?? 0 }
];
};
const ownerRelationshipRows = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
if (family === 'talent') {
if (side === 'proposed') return [`CATEGORY · ${shown(proposed.category)}`, `RECORD ID · ${shown(phase2TargetDisplay(side))}`];
const relationships = (currentSnapshot?.relationships ?? {}) as any;
return [
`WEAPON SCOPE · ${shown(relationships.weaponScope?.scope)}`,
`WEAPON SLOT · ${shown(relationships.weaponSlot?.slot)}`,
`OWNER WEAPON · ${shown(relationships.ownerWeapon?.id)}${relationships.ownerWeapon?.name ? ` · ${relationships.ownerWeapon.name}` : ''}`,
`GEAR TALENTS · ${(relationships.gearTalents ?? []).map((item: any) => item.gear_id).join(', ') || 'NONE'}`,
`WEAPON TALENT OPTIONS · ${(relationships.weaponTalentOptions ?? []).map((item: any) => `${item.weapon_id} · SLOT ${item.talent_slot}`).join(', ') || 'NONE'}`
];
}
if (family === 'protocol-talent') return side === 'current'
? [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'skill-chip-talent') return side === 'current'
? [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'specialization-node') return side === 'current'
? [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(phase2Record?.focus_id)}`]
: [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(proposed.focusId)}`];
return side === 'current'
? [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `ATTACHMENT IDS · ${(currentSnapshot?.attachments as any[] ?? []).map((item: any) => item.id).join(', ') || 'NONE'}`]
: [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `FAN-OUT ATTACHMENTS · ${phase2OwnerPreview?.proposedAttachments.length ?? 0}`];
};
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
const conditionalRuleSummary = (effect: any) => {
const rule = effect.conditionalRule ?? {};
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
const outcomes = clauses.reduce((total: number, clause: any) =>
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
};
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
const storedJson = (value: unknown, fallback: unknown) => {
if (typeof value !== 'string') return value ?? fallback;
try { return JSON.parse(value); } catch { return fallback; }
};
const attackProfileLines = (attack: Record<string, unknown> | null) => {
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
const delivery = String(attack.delivery ?? '').toLowerCase();
@@ -156,90 +218,100 @@
}
return lines;
};
const conditionalRuleLines = (source: unknown) => {
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
const wrapper = source as Record<string, any>;
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
const rule = persisted ? wrapper.rule : wrapper;
const inputs = persisted
? storedJson(rule.scenario_inputs_json, [])
: rule.scenarioInputs;
const pools = persisted ? wrapper.statePools : rule.pools;
const clauses = persisted ? wrapper.clauses : rule.clauses;
const lines = [
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
];
if (Array.isArray(inputs)) {
for (const input of inputs) {
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
}
}
if (Array.isArray(pools)) {
for (const pool of pools) {
const poolKey = pool.key ?? pool.state_key;
const scope = pool.scope ?? pool.state_scope;
const maximum = pool.max ?? pool.maximum_value;
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
}
}
if (Array.isArray(clauses)) {
for (const [index, clause] of clauses.entries()) {
const event = clause.event ?? clause.event_kind;
const recipe = clause.recipeKey ?? clause.recipe_key;
const reducer = persisted
? storedJson(clause.reducer_json, { kind: 'none' })
: clause.reducer;
const mutations = persisted
? storedJson(clause.mutations_json, [])
: clause.mutations;
const branchOutcomes = persisted
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
: clause.outcomes ?? [];
lines.push([
`BRANCH ${index + 1}`,
readableToken(event),
recipe ? `RECIPE ${readableToken(recipe)}` : '',
`REDUCER ${readableToken(reducer?.kind)}`,
clause.cooldownSeconds ?? clause.cooldown_seconds
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
: '',
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
`${branchOutcomes.length} OUTCOMES`
].filter(Boolean).join(' · '));
for (const outcome of branchOutcomes) {
if (persisted) {
lines.push([
`↳ ${readableToken(outcome.variant_key)}`,
readableToken(outcome.application_kind),
readableToken(outcome.effect_scope),
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
readableToken(outcome.application_phase)
].join(' · '));
continue;
}
const variants = outcome.variants && typeof outcome.variants === 'object'
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
).join(' · ')
: 'NO VARIANTS';
lines.push([
`↳ ${shown(outcome.label ?? outcome.key)}`,
readableToken(outcome.kind),
readableToken(outcome.scope),
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
variants,
readableToken(outcome.phase)
].join(' · '));
}
}
}
return lines;
};
</script>
{#snippet authoredSourceBlock(source: AuthoredSourcePreview | null)}
{#if source}
<section class="proposal-mechanic-section authored-source-preview">
<header><span>AUTHORED SOURCE · VERSION {shown(source.version)}</span><b>{source.variants.length} ORDERED VARIANTS</b></header>
<div class="proposal-source-template"><span>TEMPLATE</span><p>{source.template}</p></div>
{#if source.specialization}
<div class="proposal-source-identity">
<div><span>RANK DESCRIPTION</span><p>{shown(source.specialization.rankDescription)}</p></div>
<div><span>REUSABLE EFFECT ID</span><p>{shown(source.specialization.effectId)}</p></div>
<div><span>REUSABLE EFFECT TEMPLATE</span><p>{shown(source.specialization.effectTemplate)}</p></div>
</div>
{/if}
<div class="proposal-source-variants">
{#each source.variants as variant, variantIndex}
<article>
<header><span>VARIANT {variantIndex + 1} · {variant.key}</span><b>{variant.slots.length} SLOTS</b></header>
<p class="proposal-materialized-text">{variant.materialized || 'NOT MATERIALIZED'}</p>
{#each variant.slots as slot, slotIndex}
<small>SLOT {slotIndex + 1} · ORDER {slot.order} · {slot.key} = {shown(slot.value)}{slot.unit ? ` · UNIT ${slot.unit}` : ''}{slot.attributeId ? ` · ATTRIBUTE ${slot.attributeId}` : ''}</small>
{:else}<small>NO TEMPLATE SLOTS</small>{/each}
</article>
{:else}<p class="proposal-structured-empty">NO AUTHORED VARIANTS</p>{/each}
</div>
</section>
{/if}
{/snippet}
{#snippet mechanicStateBlock(state: MechanicPreviewState, heading: string)}
<section class="proposal-mechanic-section mechanic-state-preview">
<header><span>{heading}</span><b>{state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : state.modelDraft ? 'MODEL SUBMISSION' : 'NO MODEL SELECTED'}</b></header>
<div class="proposal-model-summary">
<div><span>SELECTION STATE</span><strong>{state.selectedRevision ? formatMechanicToken(state.selectedRevisionStatus) : state.modelDraft ? 'PENDING REVISION' : 'ABSENT'}</strong></div>
<div><span>READINESS</span><strong>{formatMechanicToken((state.selectedRevision ?? state.modelDraft)?.readiness)}</strong></div>
<div><span>SCENARIO INPUTS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.scenarioInputs.length ?? 0}</strong></div>
<div><span>STATE POOLS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.pools.length ?? 0}</strong></div>
<div><span>CLAUSES</span><strong>{(state.selectedRevision ?? state.modelDraft)?.clauses.length ?? 0}</strong></div>
</div>
{#if state.selectedRevision ?? state.modelDraft}
{@const model = (state.selectedRevision ?? state.modelDraft)!}
<div class="proposal-graph-summary">
<section><header><span>SCENARIO INPUTS</span><b>{model.scenarioInputs.length}</b></header>
{#each model.scenarioInputs as input}<p>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)}` : ''}</p>{:else}<p>NO SCENARIO INPUTS</p>{/each}
</section>
<section><header><span>STATE POOLS</span><b>{model.pools.length}</b></header>
{#each model.pools as pool}<p>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` : ''}</p>{:else}<p>NO STATE POOLS</p>{/each}
</section>
<section><header><span>CLAUSE GRAPH</span><b>{model.clauses.length}</b></header>
{#each model.clauses as clause, clauseIndex}
<article class="proposal-clause">
<p><b>CLAUSE {clauseIndex + 1} · {clause.label}</b> · 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` : ''}</p>
<small>CONDITION · {formatMechanicValue(clause.condition)} · MUTATIONS {clause.mutations.length}</small>
{#each clause.mutations as mutation, mutationIndex}<small>MUTATION {mutationIndex + 1} · {formatMechanicValue(mutation)}</small>{/each}
{#each clause.outcomes as outcome, outcomeIndex}
<div class="proposal-outcome"><small>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'}` : ''}</small>{#each outcome.variants as variant}<small>VARIANT {variant.key} · VALUE {formatMechanicValue(variant.value)}{variant.expression !== null ? ` · EXPRESSION ${formatMechanicValue(variant.expression)}` : ''}</small>{/each}</div>
{/each}
</article>
{:else}<p>NO CLAUSES</p>{/each}
</section>
</div>
{:else}<p class="proposal-structured-empty">NO IMMUTABLE MODEL REVISION IS SELECTED.</p>{/if}
</section>
{/snippet}
{#snippet projectionBlock(state: MechanicPreviewState)}
<section class="proposal-mechanic-section proposal-projection-preview">
<header><span>PUBLIC PROJECTION</span><b>{formatMechanicToken(state.projection.status)}</b></header>
{#if state.projection.variants.length}
<p class="proposal-projection-meta">PROJECTION SOURCE · {state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : 'AUTHORED FALLBACK'}</p>
{#each state.projection.variants as variant}<p class="proposal-projection-variant"><b>{variant.key}</b><span>STATUS {formatMechanicToken(variant.status)} · DISPLAY SOURCE {formatMechanicToken(variant.displaySource)}</span><strong>{variant.text || 'NO PUBLIC TEXT'}</strong></p>{/each}
{:else}<p class="proposal-structured-empty">NO PUBLIC PROJECTION AVAILABLE.</p>{/if}
</section>
{/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)}
<section class="proposal-mechanic-section attachment-preview">
<header><span>{heading}</span><b>{attachments.length} ATTACHMENTS</b></header>
{#each attachments as attachment, attachmentIndex}
<article>
<header><span>ATTACHMENT {attachmentIndex + 1} · {attachment.attachmentId}</span><b>RANK {shown(attachment.rank)} · ORDER {shown(attachment.effectOrder)}</b></header>
<p>NODE ID · {shown(attachment.nodeId)} · COMBAT EFFECT ID · {shown(attachment.effectId)}</p>
{@render mechanicBlocks(attachment.mechanic, 'ATTACHMENT MECHANIC')}
</article>
{:else}<p class="proposal-structured-empty">NO ATTACHMENTS</p>{/each}
</section>
{/snippet}
<section class="proposal-change-preview" aria-label="Proposed data comparison">
<div class="proposal-preview-head">
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
@@ -281,6 +353,42 @@
</div>
</article>
</div>
{:else if phase2OwnerPreview}
<div class="proposal-compare-grid phase2-owner-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {phase2OwnerPreview.family.toUpperCase()}</span><b>{phase2HasCurrentTarget ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if phase2HasCurrentTarget}
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('current', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>LIVE</b></header>{#each ownerRelationshipRows('current', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.currentMechanic, 'MECHANIC MODEL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)) as effect}<small>{effectSummary(effect)}</small>{/each}<small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.currentAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT' : 'NODE ATTACHMENTS')}
{/if}
{:else}<div class="proposal-empty-state"><b>NO LIVE {phase2OwnerPreview.family.toUpperCase()} RECORD</b><span>The proposed owner state will be created on approval.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {phase2OwnerPreview.family.toUpperCase()}</span><b>AFTER</b></header>
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('proposed', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>PROPOSED</b></header>{#each ownerRelationshipRows('proposed', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if phase2OwnerPreview.selectionIntent}<p class="proposal-selection-intent">MODEL REQUEST · {formatMechanicToken(phase2OwnerPreview.selectionIntent)}</p>{/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'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.proposedAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT AFTER APPROVAL' : 'NODE ATTACHMENTS AFTER APPROVAL')}
{/if}
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
@@ -292,35 +400,6 @@
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
</article>
</div>
{:else if module === 'calculations'}
<div class="proposal-compare-grid calculation-proposal-preview">
<article class="proposal-editor-preview before">
@@ -333,7 +412,7 @@
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
@@ -352,12 +431,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
@@ -385,18 +458,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
@@ -411,19 +472,94 @@
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each entries as entry}
{@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''}
{#if !isPreviewFieldHidden(entry.name)}
<div>
<span>{label(entry.name)}</span>
{#if entry.kind === 'file'}
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
{:else if jsonValue(entry.value)}
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{entry.value}</small>
{:else if jsonValue(safeValue)}
<strong>{Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{safeValue}</small>
{:else}
<strong>{shown(entry.value)}</strong>
<strong>{shown(safeValue)}</strong>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
</section>
<style>
.phase2-owner-preview { align-items: start; }
.owner-identity-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-relationships,
.proposal-mechanic-section { display: grid; gap: 5px; padding: 0 8px 8px; }
.proposal-relationships > header,
.proposal-mechanic-section > header,
.proposal-source-variants article > header,
.attachment-preview article > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 29px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-relationships > header span,
.proposal-relationships > header b,
.proposal-mechanic-section > header span,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header span,
.proposal-source-variants article > header b,
.attachment-preview article > header span,
.attachment-preview article > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-relationships > header b,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header b,
.attachment-preview article > header b { color: var(--shd-orange); }
.proposal-relationships p,
.proposal-selection-intent,
.proposal-mechanic-section > p,
.proposal-mechanic-section > article > p,
.proposal-source-variants article > small,
.attachment-preview article > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-source-template { display: grid; gap: 4px; padding: 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity { display: grid; gap: 5px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.proposal-source-identity > div { display: grid; gap: 4px; min-width: 0; padding: 7px 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-identity p { margin: 0; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-template span,
.proposal-model-summary span,
.proposal-projection-meta,
.proposal-projection-variant span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-template p,
.proposal-materialized-text { margin: 0; color: var(--shd-text); font-size: var(--shd-type-body); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-variants,
.proposal-graph-summary { display: grid; gap: 5px; }
.proposal-source-variants article { display: grid; gap: 4px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-source-variants article > small { background: #111415; }
.proposal-model-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.proposal-model-summary > div { display: grid; gap: 4px; min-width: 0; padding: 7px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-model-summary strong { color: var(--shd-text); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-graph-summary > section { display: grid; gap: 4px; }
.proposal-graph-summary > section > header { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-graph-summary > section > header span,
.proposal-graph-summary > section > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-graph-summary > section > header b { color: var(--shd-orange); }
.proposal-graph-summary > section > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause { display: grid; gap: 3px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-clause > p,
.proposal-clause > small,
.proposal-outcome > small { margin: 0; padding: 5px 6px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause > p { color: var(--shd-text); }
.proposal-outcome { display: grid; gap: 3px; margin-left: 8px; }
.proposal-projection-preview { gap: 5px; }
.proposal-projection-meta { margin: 0; padding: 0 2px; }
.proposal-projection-variant { display: grid; grid-template-columns: 42px 1fr; gap: 5px; margin: 0; padding: 6px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-projection-variant > b { color: var(--shd-orange); font-size: var(--shd-type-telemetry); }
.proposal-projection-variant > strong { grid-column: 1 / -1; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; }
.attachment-preview { padding-top: 0; }
.attachment-preview > article { display: grid; gap: 5px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.attachment-preview > article .proposal-mechanic-section { padding-right: 0; padding-left: 0; }
.proposal-structured-empty { margin: 0; padding: 8px; border: 1px dashed var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); text-align: center; }
@media (max-width: 800px) {
.owner-identity-fields, .proposal-model-summary, .proposal-source-identity { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-model-summary > div:last-child { grid-column: span 2; }
}
</style>
@@ -0,0 +1,355 @@
<script lang="ts">
import type {
MechanicAuthoredSlot,
MechanicAuthoredVariant,
} from '$lib/calculations/mechanic-authored-description';
export type AuthoredVariantLabel = { key: string; label: string };
let {
template = $bindable(),
variants = $bindable(),
variantLabels,
idPrefix = 'authored-description',
disabled = false,
}: {
template: string;
variants: MechanicAuthoredVariant[];
variantLabels: AuthoredVariantLabel[];
idPrefix?: string;
disabled?: boolean;
} = $props();
const tokenPattern = /\{([^{}]+)\}/g;
const allowedNamedTokens = new Set(['duration', 'max_stacks']);
const tokens = $derived.by(() => {
const ordered: string[] = [];
for (const match of template.matchAll(tokenPattern)) {
if (!ordered.includes(match[1])) ordered.push(match[1]);
}
return ordered;
});
const invalidTokens = $derived(
tokens.filter(
(token) =>
!allowedNamedTokens.has(token) && !/^[1-9]\d*$/.test(token),
),
);
function materialized(
sourceTemplate: string,
slots: MechanicAuthoredSlot[],
) {
const values = new Map(slots.map((slot) => [slot.key, slot.value]));
return sourceTemplate.replace(tokenPattern, (placeholder, token: string) =>
values.get(token) || placeholder,
);
}
function reconcileVariants(
sourceTemplate: string,
sourceVariants: MechanicAuthoredVariant[],
) {
return variantLabels.map((requiredVariant) => {
const existing = sourceVariants.find(
(variant) => variant.key === requiredVariant.key,
);
const existingSlots = new Map(
(existing?.slots ?? []).map((slot) => [slot.key, slot]),
);
const slots = tokens.map((token, order) => ({
...(existingSlots.get(token) ?? { key: token, value: '', order }),
key: token,
order,
}));
return {
key: requiredVariant.key,
materialized: materialized(sourceTemplate, slots),
slots,
};
});
}
function updateTemplate(value: string) {
template = value;
variants = reconcileVariants(value, variants);
}
function updateSlot(variantKey: string, token: string, value: string) {
variants = variants.map((variant) => {
if (variant.key !== variantKey) return variant;
const slots = tokens.map((currentToken, order) => {
const existing = variant.slots.find(
(slot) => slot.key === currentToken,
);
return {
...(existing ?? { key: currentToken, value: '', order }),
key: currentToken,
value: currentToken === token ? value : (existing?.value ?? ''),
order,
};
});
return {
...variant,
slots,
materialized: materialized(template, slots),
};
});
}
function slotValue(variantKey: string, token: string) {
return (
variants
.find((variant) => variant.key === variantKey)
?.slots.find((slot) => slot.key === token)?.value ?? ''
);
}
function preview(variantKey: string) {
return (
variants.find((variant) => variant.key === variantKey)?.materialized ??
template
);
}
</script>
<section class="authored-editor" aria-labelledby={`${idPrefix}-title`}>
<header>
<div>
<p class="eyebrow">PUBLIC FALLBACK SOURCE</p>
<h4 id={`${idPrefix}-title`}>AUTHORED DESCRIPTION</h4>
<p>Define exact public prose independently from combat modeling.</p>
</div>
<span class="variant-count">
{variantLabels.length} {variantLabels.length === 1 ? 'VARIANT' : 'VARIANTS'}
</span>
</header>
<label class="field template-field">
SHARED DESCRIPTION TEMPLATE
<textarea
id={`${idPrefix}-template`}
value={template}
oninput={(event) => updateTemplate(event.currentTarget.value)}
placeholder={'After {1} hits, gain {2}% Weapon Damage for {duration} seconds.'}
{disabled}
required
></textarea>
<small>
Use numbered tokens such as <code>{'{1}'}</code>, plus
<code>{'{duration}'}</code> and <code>{'{max_stacks}'}</code>. Keep fixed
units and Attribute wording in the template. Enter only the numbers that
change by variant below; the combat model assigns Attribute semantics.
</small>
</label>
{#if invalidTokens.length}
<p class="token-error" role="alert">
Unsupported {invalidTokens.length === 1 ? 'token' : 'tokens'}:
{invalidTokens.map((token) => `{${token}}`).join(', ')}
</p>
{/if}
{#if tokens.length}
<div class="matrix-scroll">
<table>
<thead>
<tr>
<th scope="col">AUTHORED VALUE</th>
{#each variantLabels as variant}
<th scope="col">{variant.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each tokens as token}
<tr>
<th scope="row"><code>{`{${token}}`}</code></th>
{#each variantLabels as variant}
<td>
<label class="sr-only" for={`${idPrefix}-${variant.key}-${token}`}>
{variant.label} value for {`{${token}}`}
</label>
<input
id={`${idPrefix}-${variant.key}-${token}`}
type="number"
step="any"
inputmode="decimal"
value={slotValue(variant.key, token)}
oninput={(event) =>
updateSlot(variant.key, token, event.currentTarget.value)}
{disabled}
required
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="empty-tokens">
Add a supported token to the shared template to define variant values.
</p>
{/if}
<div class="previews">
{#each variantLabels as variant}
<article>
<span>{variant.label} EXACT PREVIEW</span>
<p>{preview(variant.key) || 'Description preview'}</p>
</article>
{/each}
</div>
</section>
<style>
.authored-editor {
border: 1px solid var(--border, #333b44);
background: color-mix(in srgb, var(--surface, #12171d) 94%, transparent);
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--border, #333b44);
}
header h4,
header p {
margin: 0;
}
header h4 {
margin-top: 0.2rem;
}
header > div > p:last-child {
margin-top: 0.35rem;
color: var(--muted, #97a3af);
}
.variant-count {
flex: 0 0 auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.template-field {
margin: 1rem 1.1rem 0;
}
.template-field code,
th code {
color: var(--info, #6cb9ed);
}
.matrix-scroll {
margin: 1rem 1.1rem;
overflow-x: auto;
}
table {
width: 100%;
min-width: 34rem;
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--border, #333b44);
padding: 0.55rem;
text-align: left;
}
thead th {
background: var(--surface-dark, #0d1217);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
letter-spacing: 0.08em;
}
tbody th {
width: 10rem;
background: color-mix(in srgb, var(--surface, #12171d) 80%, black);
}
td input {
width: 100%;
min-width: 8rem;
}
.previews {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
padding: 0 1.1rem 1.1rem;
}
.previews article {
padding: 0.8rem 0.9rem;
border-left: 3px solid var(--info, #6cb9ed);
background: var(--surface-dark, #0d1217);
}
.previews span {
color: var(--info, #6cb9ed);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.previews p {
margin: 0.4rem 0 0;
white-space: pre-wrap;
}
.token-error,
.empty-tokens {
margin: 1rem 1.1rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.token-error {
border-color: var(--danger, #b7524c);
color: var(--danger-light, #ff8b84);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 40rem) {
header {
align-items: stretch;
flex-direction: column;
}
.variant-count {
align-self: flex-start;
}
}
</style>
File diff suppressed because it is too large Load Diff
+680 -26
View File
@@ -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,22 +149,164 @@ 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<SerializedEntry[]> {
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<SerializedEntry[]> {
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)
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: Buffer.from(await value.arrayBuffer()).toString('base64'),
value: bytes.toString('base64'),
fileName: value.name,
type: value.type,
});
@@ -126,35 +314,482 @@ async function serialize(form: FormData): Promise<SerializedEntry[]> {
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<string, unknown>,
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<string, unknown>,
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<string, unknown>;
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<Phase2ApprovalResult> {
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<string, unknown>, 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<string, unknown>;
});
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<string, string[]>();
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<string>();
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(
const serializedTextValues = (
payload: string,
): Array<Extract<SerializedEntry, { kind: 'text' }>> | null => {
try {
return parseSerializedProposalEntries(payload).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
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,10 +907,12 @@ 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();
const createProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
await (
await database.prepare(`INSERT INTO data_change_proposals
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(
@@ -276,11 +927,11 @@ export async function queueDataProposal(
user.id,
now,
);
const text = serializedTextValues(payload)
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
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(
@@ -289,7 +940,7 @@ export async function queueDataProposal(
);
for (const dependency of dependencies) {
await (
await database.prepare(
await tx.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
@@ -307,9 +958,12 @@ export async function queueDataProposal(
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNote: contributorNote || null,
contributorNotePresent: Boolean(contributorNote),
},
database: tx,
});
});
await createProposal.immediate();
await notifyOversightReview({
category: 'data_changes',
itemId: id,
+23
View File
@@ -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();
}
});
});
+13 -1
View File
@@ -4,6 +4,18 @@ import { resolve } from 'node:path';
let databasePromise: Promise<Database> | undefined;
export async function configureDatabase(database: Database): Promise<Database> {
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<Database> {
env.TDR_DATABASE_PATH || './data/shd-build-development.db';
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
databasePromise = connect(resolve(configuredPath)).then(configureDatabase);
}
return databasePromise;
+93 -8
View File
@@ -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,10 +380,25 @@ export async function saveGameDataIcon(
entityId: string,
output: Buffer,
actorId: string,
journal?: MediaMutationJournal,
): Promise<string> {
const key = storageKey(type, entityId);
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(storagePath(key), output, { flag: 'w', mode: 0o600 });
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)
@@ -393,19 +414,59 @@ export async function saveGameDataIcon(
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<void> {
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=?',
@@ -416,12 +477,27 @@ export async function deleteGameDataIcon(
'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) => {
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<void> {
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(
+4 -4
View File
@@ -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();
},
);
+258 -30
View File
@@ -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<string, unknown>;
type CurrentRenderPathLock = {
current: ManagedImageRow | null;
filePath: string;
release?: () => void;
};
async function lockCurrentGameDataRender(
database: Database,
sourceType: string,
entityId: string,
initialStorageKey: string,
journal?: MediaMutationJournal,
): Promise<CurrentRenderPathLock> {
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,13 +296,53 @@ export async function applyGameDataRender(
if (!mutation.file && !mutation.remove) return existing ?? null;
if (mutation.remove) {
if (!existing) return null;
if (typeof existing.storage_key !== 'string') {
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;
}
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 ??
(await processManagedImage(mutation.file!, false, false, true));
@@ -242,12 +364,35 @@ 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 },
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=?`)
@@ -259,16 +404,48 @@ export async function applyGameDataRender(
processed.width,
processed.height,
now,
existing.id,
activeLock.current.id,
);
return (
const result =
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(existing.id)) ?? null
);
).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 = await saveManagedImageFile(id, processed.output);
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
@@ -293,14 +470,31 @@ export async function applyGameDataRender(
now,
now,
);
return (
const result =
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(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?.();
}
}
export async function deleteManagedImage(database: Database, id: string) {
export async function deleteManagedImage(
database: Database,
id: string,
): Promise<{
image: Record<string, unknown>;
sourceIcon: Record<string, unknown> | null;
} | null> {
const image = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id);
@@ -310,12 +504,42 @@ export async function deleteManagedImage(database: Database, id: string) {
'Managed image is missing its storage key.',
);
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 {
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 image.source_entity_type === 'string'
? image.source_entity_type
typeof current.source_entity_type === 'string'
? current.source_entity_type
: null;
const sourceId =
typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
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.',
@@ -341,33 +565,37 @@ export async function deleteManagedImage(database: Database, id: string) {
)
).get(sourceType, sourceId)) ?? null)
: null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
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.',
);
}
await database.exec('BEGIN');
try {
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, image.storage_key);
).run(sourceType, sourceId, current.storage_key);
}
await (
await database.prepare(
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
)
).run(id, image.storage_key);
).run(id, current.storage_key);
await rm(filePath, { force: true });
deleted = { image: current, sourceIcon };
}
await database.exec('COMMIT');
committed = true;
} catch (error) {
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) {
+311
View File
@@ -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<string, unknown>;
type VariantRow = Record<string, unknown>;
type SlotRow = Record<string, unknown>;
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<MechanicAuthoredSourceRecord | null> {
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<MechanicAuthoredSourceRecord | null> {
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<MechanicAuthoredSourceRecord> {
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 }
: {}),
};
}
+634
View File
@@ -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<string, unknown>;
type ClauseDraft = ConditionalMechanicDraftV1['clauses'][number];
type StoredVariant = { key: string; label: string };
const parseJson = <T>(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<Record<string, number>>(
row.duration_variants_json,
'pool duration variants',
),
}),
}),
);
const outcomesByClause = new Map<string, Row[]>();
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<string, Row[]>();
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<NonNullable<ClauseDraft['if']>>(
clause.conditions_json,
'clause conditions',
),
}),
reducer: parseJson<ClauseDraft['reducer']>(
clause.reducer_json,
'clause reducer',
),
...(clause.weight_expression_json === null
? {}
: {
weight: parseJson<NonNullable<ClauseDraft['weight']>>(
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<ClauseDraft['mutations']>(
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<MechanicModelRevisionRecord> {
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<StoredVariant[]>(
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<MechanicModelRevisionRecord | null> {
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<MechanicModelRevisionRecord | null> {
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<string, string>();
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,
};
}
@@ -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')]),
);
});
});
+340
View File
@@ -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<string, unknown>;
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<ReturnType<typeof saveMechanicAuthoredSourceInTransaction>>;
selectedRevision: MechanicModelRevisionRecord | null;
revisionCreated: boolean;
projection: Awaited<
ReturnType<typeof refreshMechanicPublicProjectionInTransaction>
>;
};
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<string, unknown>;
} 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<string>;
allowedRecipeKeys?: ReadonlySet<string>;
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<PersistedMechanicOwnerState> {
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,
),
};
}
+598
View File
@@ -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,
);
});
});
@@ -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<MechanicPublicProjection> {
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<string, string>();
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<MechanicPublicVariantRecord[]> {
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<string, string>();
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<string>();
const rowsByKey = new Map<string, MechanicPublicVariantRecord>();
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<string, string>();
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,
})),
};
}
+709
View File
@@ -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<void>((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<void>((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<never>((_, 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();
});
});
+172
View File
@@ -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<MediaFileSnapshot> {
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<void> {
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<void>;
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<string, Promise<void>>();
export async function acquireMediaPathLock(
filePath: string,
): Promise<ReleaseMediaPathLock> {
const key = path.resolve(filePath);
const previous = mediaPathQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const next = new Promise<void>((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<void> | null = null;
private pathLocks = new Map<string, Promise<ReleaseMediaPathLock>>();
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<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.');
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<void> {
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<void> {
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();
}
}
}
+319 -129
View File
@@ -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<typeof createNotification>[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<string, unknown>,
) {
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,11 +288,23 @@ 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 === '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)
@@ -348,7 +332,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
? await specializationProposalSnapshot(
database,
proposal,
)
: null,
})),
);
@@ -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,18 +400,26 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const correctProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await database.prepare(
await tx.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[];
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;
@@ -427,11 +428,34 @@ export const actions: Actions = {
}> = [];
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 fail(400, {
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,
@@ -443,51 +467,83 @@ export const actions: Actions = {
}
}
if (!changes.length)
return fail(400, { moderationError: 'No proposal fields were changed.' });
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 oldDependencies = (
await (
await database.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 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 database.prepare(
'UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?',
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);
const dependencies = await refreshProposalDependencies(id, payload);
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: {
summary: proposal.summary,
summaryLength: Array.from(String(proposal.summary)).length,
dependencies: oldDependencies,
fields: changes.map((change) => ({
index: change.index,
name: change.name,
value: change.before,
changed: true,
valueLength: Array.from(change.before).length,
})),
},
after: {
summary,
summaryLength: Array.from(summary).length,
dependencies,
fields: changes.map((change) => ({
index: change.index,
name: change.name,
value: change.after,
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,6 +566,76 @@ 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
@@ -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,7 +729,8 @@ export const actions: Actions = {
before: { status: 'pending' },
after: { status: 'failed', responseStatus: response.status },
});
await createNotification({
await notifySafely(
{
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
@@ -565,7 +738,9 @@ export const actions: Actions = {
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,21 +793,21 @@ export const actions: Actions = {
moderationError: 'Add a brief reason for rejecting this proposal.',
});
const database = await getDatabase();
const rejectProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await database.prepare(
await tx.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.',
});
if (!proposal) throw new ProposalNotPendingError();
const now = new Date().toISOString();
await (
await database.prepare(
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
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',
@@ -639,13 +816,24 @@ export const actions: Actions = {
before: { status: 'pending' },
after: {
status: 'rejected',
note,
module: proposal.module,
action: proposal.action,
proposedBy: proposal.proposed_by,
},
database: tx,
});
await createNotification({
return proposal as Record<string, unknown>;
});
let proposal: Record<string, unknown>;
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',
@@ -655,7 +843,9 @@ export const actions: Actions = {
.trim()} was not approved. Oversight reason: ${note}`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
},
'contributor of rejected proposal',
);
return { moderationSaved: true, moderationToken: randomUUID() };
},
};
+58 -17
View File
@@ -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<any>(null);
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
let correctionEntries = $state<Array<{ index: number; name: string; value: string; kind: string; fileName?: string }>>([]);
let correctionEntries = $state<CorrectionEntry[]>([]);
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<EvidenceEntry[]>((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 @@
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
{#if reviewing.contributor_note}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{reviewing.contributor_note}</p></div>{/if}
{#if renderBoundedPlainText(reviewing.contributor_note, MAX_CONTRIBUTOR_NOTE_CHARS)}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{renderBoundedPlainText(reviewing.contributor_note, MAX_CONTRIBUTOR_NOTE_CHARS)}</p></div>{/if}
{#if reviewing.payloadValid === false}
<div class="proposal-empty-state error" role="status">
<strong>PROPOSAL EVIDENCE UNAVAILABLE</strong>
<span>The stored submission payload is invalid and cannot be previewed or corrected. Approval remains available for canonical validation; rejection remains available for contributor revision.</span>
</div>
{:else}
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
<details>
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
<summary>SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
</details>
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
{/if}
{#if renderBoundedPlainText(reviewing.review_note, MAX_REVIEW_NOTE_CHARS)}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{renderBoundedPlainText(reviewing.review_note, MAX_REVIEW_NOTE_CHARS)}</p></div>{/if}
{#if reviewing.status === 'pending'}
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>FIX MINOR ERRORS</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>
{#if decision === 'correct'}
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} disabled={reviewing.payloadValid === false} title={reviewing.payloadValid === false ? 'Correction is unavailable because the stored payload is invalid.' : undefined} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>{reviewing.payloadValid === false ? 'UNAVAILABLE' : 'FIX MINOR ERRORS'}</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>
{#if decision === 'correct' && reviewing.payloadValid === true}
<PendingForm action="?/correct">
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/><input type="hidden" name="corrections" value={correctionPayload()}/>
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text').length} EDITABLE FIELDS</b></div>
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES AND REDACTED FIELDS REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text' && entry.editable).length} EDITABLE FIELDS</b></div>
<div class="proposal-correction-grid">
{#each correctionEntries as entry}
{#if entry.kind === 'text'}
{#if entry.kind === 'text' && entry.editable}
<label class="field"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea bind:value={entry.value}></textarea>{:else}<input bind:value={entry.value}/>{/if}</label>
{:else if entry.kind === 'text'}
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea readonly aria-label={`${operationLabel(entry.name)} safe preview`} value={entry.value}></textarea>{:else}<input readonly aria-label={`${operationLabel(entry.name)} safe preview`} value={entry.value}/>{/if}<small>SAFE PREVIEW DIFFERS FROM STORED VALUE · PROTECTED METADATA IS IMMUTABLE</small></div>
{:else}
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
{/if}
@@ -99,16 +139,17 @@
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CORRECTIONS →'}</button></div>
{/snippet}
</PendingForm>
{:else}
{:else if decision === 'approve' || decision === 'reject'}
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/>
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for the audit trail…' : 'Explain what the contributor should revise…'}></textarea></label>
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for this proposal…' : 'Explain what the contributor should revise…'}></textarea></label>
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
<div class="moderation-warning" class:rejecting={decision === 'reject'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL WILL RUN VALIDATION AND MUTATE THE LIVE BUILDER DATA SET.' : 'REJECTION WILL PRESERVE THIS REQUEST AND YOUR REASON IN THE AUDIT TRAIL.'}</span></div>
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM & DEPLOY →' : 'CONFIRM REJECTION →'}</button></div>
{/snippet}
</PendingForm>
{:else}<div class="proposal-empty-state error" role="status"><strong>CORRECTION UNAVAILABLE</strong><span>This stored proposal cannot be corrected until its payload is valid.</span></div>
{/if}
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
correctionFieldIsEditable,
correctionPayloadForSubmission,
MAX_CONTRIBUTOR_NOTE_CHARS,
MAX_REVIEW_NOTE_CHARS,
renderBoundedPlainText,
type CorrectionEntry,
} from './data-moderation.helpers.js';
const entry = (overrides: Partial<CorrectionEntry> = {}): CorrectionEntry => ({
index: 0,
name: 'name',
value: 'safe value',
initialValue: 'safe value',
kind: 'text',
editable: true,
...overrides,
});
describe('Data Oversight correction editor safety', () => {
it('locks a field when preview redaction changes its serialized value', () => {
expect(
correctionFieldIsEditable(
'authoredSource',
'{"rank":3}',
'{"rank":3,"sourceFingerprint":"protected"}',
),
).toBe(false);
expect(
correctionFieldIsEditable('name', 'safe value', 'safe value'),
).toBe(true);
});
it.each([
'id',
'_proposedEntityId',
'expectedTalentId',
'expectedOwnerUpdatedAt',
'ranksJson',
])(
'locks the protected %s identity field even when preview redaction is unchanged',
(fieldName) => {
expect(correctionFieldIsEditable(fieldName, '', '')).toBe(false);
},
);
it('does not submit changed protected identity entries from stale UI state', () => {
expect(
correctionPayloadForSubmission([
entry({ name: 'id', value: 'forged-id', editable: false }),
]),
).toBe('[]');
});
it('never submits an immutable redacted entry even if its displayed value changes', () => {
expect(
correctionPayloadForSubmission([
entry({
index: 4,
value: '{"rank":4}',
initialValue: '{"rank":3}',
editable: false,
}),
entry({ index: 5, value: 'corrected' }),
]),
).toBe('[{"index":5,"value":"corrected"}]');
});
});
describe('Data Oversight note renderer', () => {
it('keeps notes plain text and bounded without parsing structured values', () => {
const note = '<b>review rationale</b>';
expect(renderBoundedPlainText(note, MAX_REVIEW_NOTE_CHARS)).toBe(note);
expect(renderBoundedPlainText({ note }, MAX_REVIEW_NOTE_CHARS)).toBe('');
expect(
renderBoundedPlainText('x'.repeat(MAX_REVIEW_NOTE_CHARS + 1), MAX_REVIEW_NOTE_CHARS),
).toHaveLength(MAX_REVIEW_NOTE_CHARS);
expect(
renderBoundedPlainText(
'x'.repeat(MAX_CONTRIBUTOR_NOTE_CHARS + 1),
MAX_CONTRIBUTOR_NOTE_CHARS,
),
).toHaveLength(MAX_CONTRIBUTOR_NOTE_CHARS);
});
});
@@ -0,0 +1,57 @@
import type { ProposalPreviewEntry } from '$lib/components/ProposalChangePreview.helpers';
export const MAX_REVIEW_NOTE_CHARS = 500;
export const MAX_CONTRIBUTOR_NOTE_CHARS = 1_000;
export type CorrectionEntry = {
index: number;
name: string;
value: string;
/** The safe value shown in the editor before an operator makes a change. */
initialValue: string;
kind: ProposalPreviewEntry['kind'];
fileName?: string;
editable: boolean;
};
/**
* A correction is safe only when redaction left the serialized field unchanged.
* The serialized value is compared transiently and is never retained in the UI state.
*/
const IMMUTABLE_CORRECTION_FIELDS = new Set([
'id',
'_proposedEntityId',
'expectedTalentId',
'expectedOwnerUpdatedAt',
'ranksJson',
]);
export const correctionFieldIsEditable = (
fieldName: string,
safeValue: string,
serializedValue: string,
): boolean =>
!IMMUTABLE_CORRECTION_FIELDS.has(fieldName) && safeValue === serializedValue;
export const correctionPayloadForSubmission = (
entries: readonly CorrectionEntry[],
): string =>
JSON.stringify(
entries
.filter(
(entry) =>
entry.kind === 'text' &&
entry.editable &&
entry.value !== entry.initialValue,
)
.map(({ index, value }) => ({ index, value })),
);
/** Render separately stored notes as bounded plain text, never as structured data. */
export const renderBoundedPlainText = (
value: unknown,
maxCharacters: number,
): string =>
typeof value === 'string'
? Array.from(value).slice(0, maxCharacters).join('')
: '';
+119 -443
View File
@@ -1,4 +1,4 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { recordIsacAudit } from '$lib/server/audit';
import {
requireDataView,
requirePermission,
@@ -13,110 +13,38 @@ import {
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
ProtocolFormValidationError,
ProtocolNotFoundError,
loadProtocolTalentMechanic,
parseProtocolForm,
protocolSnapshot,
saveProtocolInTransaction,
} from './protocol-save';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from '$lib/server/os-protocol-profiles';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
// SvelteKit permits underscored exports from route modules. Keep this alias
// available to Data Oversight and transaction-focused tests without exposing a
// non-route page export.
export {
saveProtocolInTransaction as _saveProtocolInTransaction,
} from './protocol-save';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) => rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) =>
mechanicOutcomes(rule)
.filter((outcome) => outcome.descriptionSlot)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
const talent = await (
await database.prepare(
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id);
return {
record: await auditRecord('os_protocols', id, database),
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='os_protocol_render' AND source_entity_id=?",
)
).get(id),
),
primaryEffect:
(await (
await database.prepare(
'SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'os_protocol_talent',
String(talent.id),
)
: null,
};
}
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
return {
@@ -221,7 +149,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
profiles,
secondaryEffects,
talents,
talentEffects,
stats,
icons,
renders,
@@ -243,10 +170,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_talents',
)
).all(),
(
@@ -282,8 +208,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'primaryBaseValue',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -294,6 +222,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(effect) => effect.profile_id === profile.id,
),
}));
const talentMechanics = new Map(
await Promise.all(
talents.map(async (talent) => [
String(talent.id),
await loadProtocolTalentMechanic(database, String(talent.id)),
] as const),
),
);
const pendingProtocols = pending
.filter((item) => item.kind === 'protocol')
.filter((item) =>
@@ -334,14 +270,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
protocol.hasTalent === '1'
? {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
authoredSource: null,
expectedSourceFingerprint: null,
selectedRevision: null,
modelDraft: null,
}
: null,
}));
@@ -381,15 +313,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'os_protocol_talent',
String(talent.id),
['base'],
),
authoredSource:
talentMechanics.get(String(talent.id))?.authoredSource.source ??
null,
expectedSourceFingerprint:
talentMechanics.get(String(talent.id))?.authoredSource
.fingerprint ?? null,
selectedRevision:
talentMechanics.get(String(talent.id))?.selectedRevision ?? null,
publicProjection:
talentMechanics.get(String(talent.id))?.projection ?? [],
}
: null,
};
@@ -532,348 +465,47 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { protocolError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { protocolError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
const journal = new MediaMutationJournal();
try {
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
} catch (error) {
return fail(400, {
protocolError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
});
}
if (name.length < 2 || name.length > 80)
return fail(400, {
protocolError: 'Protocol names must be 2–80 characters.',
});
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType))
return fail(400, {
protocolError: 'Choose a valid quality and primary attribute type.',
});
if (
!primaryStatId ||
!Number.isFinite(primaryBaseValue) ||
primaryBaseValue < 0
)
return fail(400, {
protocolError:
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
});
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const requiresCompleteMechanic =
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
if (
hasTalent &&
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
)
return fail(400, {
protocolError:
'The assigned Talent requires a name and description template.',
});
if (
requiresCompleteMechanic &&
(!conditionalRule?.clauses.length || !outcomes.length)
)
return fail(400, {
protocolError:
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
requiresCompleteMechanic &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
protocolError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
protocolError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{max_stacks}') &&
!maxStacks
)
return fail(400, {
protocolError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
return fail(400, {
protocolError:
'Configure the shared secondary profile for this quality and attribute type first.',
});
if (
await (
await database.prepare(
'SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, { protocolError: 'That OS Protocol already exists.' });
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
protocolError:
'Confirm removal of the existing Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
protocolError: 'That protocol talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((item) => item.attributeId)
.filter((value): value is string => Boolean(value));
const statIds = [...new Set([primaryStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
protocolError: 'One or more selected attributes are unavailable.',
});
const protocolId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
talentId = String(existingTalent?.id ?? randomUUID()),
now = new Date().toISOString();
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = id ? await protocolSnapshot(id, tx) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
if (id) {
await (
await tx.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(name, quality, attributeType, now, id);
} else
await (
await tx.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(protocolId, name, quality, attributeType, now, now);
await (
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
if (hasTalent && conditionalRule) {
await (
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await tx.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await tx.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
tx,
'os_protocol_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await tx.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
tx,
'os_protocol',
protocolId,
iconMutation,
operator.id,
);
await applyGameDataRender(
tx,
'os_protocol',
protocolId,
name,
renderMutation,
operator.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: await protocolSnapshot(protocolId, tx),
database: tx,
const input = await parseProtocolForm(tx, form, operator.id);
return saveProtocolInTransaction(tx, { ...input, journal });
});
return null;
});
const transactionResult = await saveProtocol.immediate();
if (transactionResult) return transactionResult;
const result = await saveProtocol.immediate();
journal.clear();
return {
protocolSaved: true,
protocolMode: id ? 'edit' : 'add',
protocolMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
await journal.rollback(error);
if (error instanceof ProtocolFormValidationError)
return fail(error.status, { protocolError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { protocolError: error.message });
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await protocolSnapshot(id);
if (!before.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const archiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await (
await getDatabase()
).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')
await tx.prepare(
'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
@@ -881,8 +513,52 @@ export const actions: Actions = {
action: 'os_protocol.archived',
entityId: id,
before,
after: await protocolSnapshot(id),
after: await protocolSnapshot(id, tx),
database: tx,
});
return { protocolArchived: true };
});
try {
await archiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolArchived: true, protocolId: id, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const unarchiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await tx.prepare(
'UPDATE os_protocols SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.unarchived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
try {
await unarchiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolUnarchived: true, protocolId: id, resultToken: randomUUID() };
},
};
+103 -17
View File
@@ -12,6 +12,8 @@
import ItemIcon from '$lib/components/ItemIcon.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
@@ -24,6 +26,11 @@
Health: 'Health'
});
const talentValueColumns = [{ key: 'base', variantKey: 'base', label: 'BASE' }];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE CURRENT MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'captured_only',
@@ -31,8 +38,28 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
type ProtocolAuthoredFormSource = Pick<MechanicAuthoredSource, 'version' | 'template' | 'variants'>;
const blankAuthoredSource = (): ProtocolAuthoredFormSource => ({
version: 1,
template: '',
variants: [{ key: 'base', materialized: '', slots: [] }]
});
const authoredPayload = (source: ProtocolAuthoredFormSource) => ({
version: source.version,
template: source.template,
variants: source.variants
});
const blankTalent = () => ({
id: '',
name: '',
authoredSource: blankAuthoredSource(),
expectedTalentId: '',
expectedSourceFingerprint: null,
selectedRevision: null,
mechanicSelectionIntent: 'clear',
modelDraft: blankRule()
});
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', expectedOwnerUpdatedAt: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
@@ -59,13 +86,19 @@
const profileFor = (quality: string, type: string) => data.profiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === type);
const profileEffect = (profile: any, role: string) => profile.effects.find((effect: any) => effect.effect_role === role);
const configuredProfile = () => profileFor(editing.quality, editing.primary_attribute_type)?.effects.length === 3;
const previewOutcomes = () => editing.talent.conditionalRule.clauses
const mechanicModeled = (talent: any) =>
Boolean(talent.publicProjection?.length) &&
talent.publicProjection.every((variant: any) => variant.status === 'modeled');
const previewOutcomes = () => editing.talent.modelDraft.clauses
.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
const authoredPreview = () => editing.talent.authoredSource.variants.find((variant: any) => variant.key === 'base')?.materialized ?? editing.talent.authoredSource.template;
const talentPreview = (upgrades: number) => {
if (editing.talent.mechanicSelectionIntent !== 'save') return authoredPreview();
return editing.talent.authoredSource.template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.modelDraft.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.modelDraft.pools.length ? Math.max(...editing.talent.modelDraft.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
@@ -76,6 +109,7 @@
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
: placeholder;
});
};
const editProfile = (profile: any) => {
profileEditing = {
...profile,
@@ -84,15 +118,34 @@
editing = null;
};
const edit = (protocol: any) => {
const source = protocol.talent?.authoredSource;
const selectedRevision = protocol.talent?.selectedRevision;
const talent = protocol.talent
? {
...protocol.talent,
authoredSource: source
? {
...source,
variants: source.variants.map((variant: any) => ({
...variant,
slots: variant.slots.map((slot: any) => ({ ...slot }))
}))
}
: blankAuthoredSource(),
expectedTalentId: String(protocol.talent.id ?? ''),
expectedSourceFingerprint: protocol.talent.expectedSourceFingerprint ?? null,
selectedRevision: selectedRevision ?? null,
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
modelDraft: selectedRevision?.draft ?? blankRule()
}
: blankTalent();
editing = {
...protocol,
expectedOwnerUpdatedAt: protocol.updated_at ?? '',
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
hasTalent: Boolean(protocol.talent),
talentOpen: false,
talent: protocol.talent ? {
...protocol.talent,
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
} : blankTalent()
talent
};
profileEditing = null;
talentUpgrade = 0;
@@ -108,6 +161,11 @@
handledResultToken = form.resultToken;
profileEditing = null;
}
if ((form?.protocolArchived || form?.protocolUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (editing?.id === form.protocolId) editing = null;
return;
}
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.protocolMode === 'add') {
@@ -169,8 +227,17 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={editing.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
<input type="hidden" name="expectedTalentId" value={editing.talent.expectedTalentId ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={editing.talent.expectedSourceFingerprint ?? ''}/>
{#if editing.hasTalent}
<input type="hidden" name="authoredSource" value={JSON.stringify(authoredPayload(editing.talent.authoredSource))}/>
<input type="hidden" name="mechanicSelectionIntent" value={editing.talent.mechanicSelectionIntent}/>
{#if editing.talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.modelDraft)} />
{/if}
{/if}
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
@@ -189,22 +256,41 @@
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>AUTHORED BASE VARIANT · {editing.talent.mechanicSelectionIntent.toUpperCase()} MODEL</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label></div>
<AuthoredDescriptionEditor
idPrefix={`protocol-talent-authored-${editing.talent.id || 'new'}`}
variantLabels={talentValueColumns}
bind:template={editing.talent.authoredSource.template}
bind:variants={editing.talent.authoredSource.variants}
/>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<SegmentedChoice
legend="MECHANIC PROGRAM"
name="mechanic-selection-choice"
options={mechanicSelectionOptions}
value={editing.talent.mechanicSelectionIntent}
onchange={(values) => { editing.talent.mechanicSelectionIntent = values[0]; }}
/>
{#if editing.talent.mechanicSelectionIntent === 'save' || editing.talent.selectedRevision}
<MechanicProgramEditor
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
disabled={editing.talent.mechanicSelectionIntent !== 'save'}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
sourceDescription: editing?.talent?.authoredSource?.template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
bind:rule={editing.talent.modelDraft}
/>
{/if}
{#if editing.talent.mechanicSelectionIntent !== 'save'}
<p class="console-note">{editing.talent.mechanicSelectionIntent === 'preserve' ? 'The selected model revision is preserved exactly. Choose SAVE MODEL to append a revision.' : 'No combat model will be selected. Complete authored prose is sufficient for this Talent.'}</p>
{/if}
</div>
</details>
{:else}
@@ -213,7 +299,7 @@
{/if}
</section>
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}{#if Number(editing.active)}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{:else}<button class="danger" formaction="?/unarchive" name="id" value={editing.id}>UNARCHIVE PROTOCOL</button>{/if}{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
</PendingForm>
</div>
{/if}
@@ -224,7 +310,7 @@
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {mechanicModeled(protocol.talent) || protocol.pending ? 'MODELED · ' : 'AUTHORED · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
@@ -58,13 +58,6 @@ vi.mock('$lib/server/managed-images', () => ({
readGameDataRenderMutation: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const request = (includeValues = true) => {
+647
View File
@@ -0,0 +1,647 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconMetadata,
} from '$lib/server/game-data-icons';
import { applyGameDataRender } from '$lib/server/managed-images';
import {
loadMechanicAuthoredSource,
mechanicAuthoredSourceSnapshot,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import {
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
ManagedImageValidationError,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
} from '$lib/server/mechanic-owner-mutation';
import { getOsProtocolProfileAttributeIds, type OsProtocolAttributeType } from '$lib/server/os-protocol-profiles';
import type { MediaMutationJournal } from '$lib/server/media-journal';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
export class ProtocolFormValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ProtocolFormValidationError';
this.status = status;
}
}
export class ProtocolTalentIdentityConflictError extends ProtocolFormValidationError {
constructor(expectedTalentId: string, currentTalentId: string) {
super(
409,
`The protocol Talent changed after this editor was loaded (expected ${expectedTalentId || 'none'}, found ${currentTalentId || 'none'}). Reload the latest definition and try again.`,
);
this.name = 'ProtocolTalentIdentityConflictError';
}
}
function parseExpectedTalentId(value: FormDataEntryValue | null): string {
if (value === null) return '';
if (
typeof value !== 'string' ||
value !== value.trim() ||
value.length > 200 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
)
throw new ProtocolFormValidationError(
400,
'The expected protocol Talent identity is invalid.',
);
return value;
}
/**
* Parses and validates the canonical OS Protocol form. Both the route action
* and Data Oversight replay use this function before calling the transaction
* save seam.
*/
export async function parseProtocolForm(
database: Database,
form: FormData,
actorId: string,
mutationContext?: ProtocolMutationContext,
): Promise<ProtocolSaveInput> {
const id = String(form.get('id') ?? '').trim();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
throw new ProtocolFormValidationError(
400,
error instanceof Error ? error.message : 'The expected owner version is invalid.',
);
}
const existingProtocol = id
? await (
await database.prepare('SELECT updated_at FROM os_protocols WHERE id=?')
).get(id)
: null;
if (id && !existingProtocol)
throw new ProtocolFormValidationError(404, 'OS Protocol not found.');
if (
id &&
expectedOwnerUpdatedAt !== String(existingProtocol?.updated_at ?? '')
)
throw new ProtocolFormValidationError(
409,
'The OS Protocol changed after this editor was loaded. Reload the latest definition and try again.',
);
const expectedTalentId = parseExpectedTalentId(form.get('expectedTalentId'));
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id)
: null;
const currentTalentId = String(existingTalent?.id ?? '');
// The child identity is the optimistic concurrency token for this owner.
if (expectedTalentId !== currentTalentId)
throw new ProtocolTalentIdentityConflictError(
expectedTalentId,
currentTalentId,
);
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
throw new ProtocolFormValidationError(400, error.message);
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
throw new ProtocolFormValidationError(400, error.message);
throw error;
}
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
if (name.length < 2 || name.length > 80)
throw new ProtocolFormValidationError(400, 'Protocol names must be 2–80 characters.');
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType))
throw new ProtocolFormValidationError(400, 'Choose a valid quality and primary attribute type.');
if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0)
throw new ProtocolFormValidationError(400, 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.');
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
throw new ProtocolFormValidationError(400, 'Configure the shared secondary profile for this quality and attribute type first.');
if (
await (
await database.prepare(
'SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
throw new ProtocolFormValidationError(409, 'That OS Protocol already exists.');
if (existingTalent && !hasTalent && form.get('confirmTalentRemoval') !== 'REMOVE')
throw new ProtocolFormValidationError(400, 'Confirm removal of the existing Talent and mechanic program.');
if (hasTalent && (talentName.length < 2 || talentName.length > 80))
throw new ProtocolFormValidationError(400, 'The assigned Talent requires a name.');
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
throw new ProtocolFormValidationError(409, 'That protocol talent name is already in use.');
const talentId = String(existingTalent?.id ?? randomUUID());
let authoredSource: MechanicAuthoredSource | null = null;
let expectedSourceFingerprint: string | null = null;
if (hasTalent || existingTalent)
try {
expectedSourceFingerprint = parseExpectedSourceFingerprint(
form.get('expectedSourceFingerprint'),
);
} catch (error) {
throw new ProtocolFormValidationError(
400,
error instanceof Error
? error.message
: 'The expected authored-source fingerprint is invalid.',
);
}
let selection: MechanicOwnerSelection = { intent: 'clear' };
let activeStats: Array<Record<string, unknown>> = [];
if (hasTalent) {
activeStats = await (
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
).all();
try {
authoredSource = parseMechanicAuthoredSourcePayload(
form.get('authoredSource'),
{ type: 'os_protocol_talent', id: talentId },
);
selection = parseMechanicSelectionIntent(
form.get('mechanicSelectionIntent'),
form.get('conditionalRule'),
{
variantKeys: ['base'],
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: talentName,
ownerType: 'os_protocol_talent',
},
);
const authoredAttributeIds = [
...new Set(
authoredSource.variants.flatMap((variant) =>
variant.slots
.map((slot) => slot.attributeId)
.filter((value): value is string => Boolean(value)),
),
),
];
if (authoredAttributeIds.some((attributeId) => !activeStats.some((row) => String(row.id) === attributeId)))
throw new Error('One or more authored Attributes are unavailable.');
} catch (error) {
throw new ProtocolFormValidationError(
400,
error instanceof Error ? error.message : 'The authored mechanic is invalid.',
);
}
}
const validStats = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id=?',
)
).get(primaryStatId);
if (Number(validStats?.count) !== 1)
throw new ProtocolFormValidationError(400, 'One or more selected attributes are unavailable.');
return {
id,
proposedEntityId: String(form.get('_proposedEntityId') ?? '').trim(),
name,
quality,
attributeType,
primaryStatId,
primaryBaseValue,
hasTalent,
talentName,
talentId,
expectedTalentId,
expectedOwnerUpdatedAt,
authoredSource,
expectedSourceFingerprint,
selection,
confirmTalentRemoval: form.get('confirmTalentRemoval') === 'REMOVE',
iconMutation,
renderMutation,
actorId,
mutationContext,
resolveAttributeLabel: (attributeId) =>
activeStats.find((row) => String(row.id) === attributeId)?.name as string | undefined,
};
}
const renderAuditMetadata = (render: Record<string, unknown> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
export async function loadProtocolTalentMechanic(
database: Database,
talentId: string,
) {
const authoredSource = await loadMechanicAuthoredSource(
database,
'os_protocol_talent',
talentId,
);
if (!authoredSource) return null;
const selectedRevision = await loadSelectedMechanicModelRevision(
database,
authoredSource.id,
);
return {
authoredSource,
selectedRevision,
projection: await loadMechanicPublicProjection(database, authoredSource.id),
};
}
function mechanicAuditSnapshot(
state: Awaited<ReturnType<typeof loadProtocolTalentMechanic>>,
) {
if (!state) return null;
return {
authoredSource: mechanicAuthoredSourceSnapshot(state.authoredSource),
selectedRevision: mechanicModelRevisionSnapshot(state.selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
state.selectedRevision,
state.projection,
),
};
}
export async function protocolSnapshot(
id: string,
suppliedDatabase?: Database,
) {
const database = suppliedDatabase ?? (await getDatabase());
const [record, talent] = await Promise.all([
auditRecord('os_protocols', id, database),
(
await database.prepare(
'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id),
]);
const [secondaryProfile, mechanic] = await Promise.all([
record
? (async () => {
const profile = await (
await database.prepare(
`SELECT * FROM os_protocol_secondary_profiles
WHERE quality=? AND primary_attribute_type=?`,
)
).get(record.quality, record.primary_attribute_type);
return profile
? {
record: profile,
effects: await (
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role',
)
).all(profile.id),
}
: null;
})()
: null,
talent
? loadProtocolTalentMechanic(database, String(talent.id))
: null,
]);
return {
record,
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='os_protocol_render' AND source_entity_id=?",
)
).get(id),
),
primaryEffect:
(await (
await database.prepare(
'SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?',
)
).get(id)) ?? null,
secondaryProfile,
talent: talent
? {
id: talent.id,
protocol_id: talent.protocol_id,
name: talent.name,
created_at: talent.created_at,
updated_at: talent.updated_at,
}
: null,
mechanic: mechanicAuditSnapshot(mechanic),
};
}
export type ProtocolMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
export type ProtocolSaveInput = {
id: string;
proposedEntityId: string;
name: string;
quality: string;
attributeType: string;
primaryStatId: string;
primaryBaseValue: number;
hasTalent: boolean;
talentName: string;
talentId: string;
expectedTalentId: string;
expectedOwnerUpdatedAt?: string | null;
authoredSource: MechanicAuthoredSource | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
confirmTalentRemoval: boolean;
iconMutation: { output: Buffer | null; remove: boolean };
renderMutation: {
file: File | null;
remove: boolean;
processed?: { output: Buffer; width: number; height: number };
};
actorId: string;
mutationContext?: ProtocolMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class ProtocolNotFoundError extends Error {
constructor() {
super('OS Protocol not found.');
this.name = 'ProtocolNotFoundError';
}
}
/**
* Persists one OS Protocol and its owned Phase 2 mechanic in an already-open
* transaction. The route action owns the transaction today; Data Oversight
* can call this function from its own atomic replay transaction later.
*/
export async function saveProtocolInTransaction(
database: Database,
input: ProtocolSaveInput,
): Promise<{ protocolId: string; mode: 'add' | 'edit' }> {
const protocolId = input.id || input.proposedEntityId || randomUUID();
const before = input.id ? await protocolSnapshot(input.id, database) : null;
if (input.id && !before?.record) throw new ProtocolNotFoundError();
if (
input.id &&
input.expectedOwnerUpdatedAt !== String(before?.record?.updated_at ?? '')
)
throw new ProtocolFormValidationError(
409,
'The OS Protocol changed after this editor was loaded. Reload the latest definition and try again.',
);
const existingTalent = input.id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(input.id)
: null;
const currentTalentId = String(existingTalent?.id ?? '');
// Recheck inside the write transaction before any owner or media writes.
if (input.expectedTalentId !== currentTalentId)
throw new ProtocolTalentIdentityConflictError(
input.expectedTalentId,
currentTalentId,
);
const talentId = String(existingTalent?.id ?? input.talentId);
const authoredSource = input.authoredSource;
if (
authoredSource &&
(authoredSource.owner.type !== 'os_protocol_talent' ||
authoredSource.owner.id !== talentId)
) {
throw new Error('The authored mechanic owner does not match the protocol Talent.');
}
if (input.hasTalent && !authoredSource)
throw new Error('The authored description is required for a Talent.');
if (existingTalent && !input.hasTalent && !input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing Talent and mechanic program.',
);
if (existingTalent && !input.hasTalent) {
const currentSource = await loadMechanicAuthoredSource(
database,
'os_protocol_talent',
talentId,
);
if (input.expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
currentSource?.fingerprint ?? null,
);
}
const now = new Date().toISOString();
if (input.id) {
await (
await database.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(
input.name,
input.quality,
input.attributeType,
now,
input.id,
);
} else {
await (
await database.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(
protocolId,
input.name,
input.quality,
input.attributeType,
now,
now,
);
}
await (
await database.prepare(`INSERT INTO os_protocol_primary_effects
(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET
stat_attribute_id=excluded.stat_attribute_id,
base_value=excluded.base_value`)
).run(randomUUID(), protocolId, input.primaryStatId, input.primaryBaseValue);
if (input.hasTalent) {
if (!authoredSource)
throw new Error('The authored description is required for a Talent.');
await (
await database.prepare(`INSERT INTO os_protocol_talents
(id,protocol_id,name,description_template,created_at,updated_at)
VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET
name=excluded.name,
updated_at=excluded.updated_at`)
).run(talentId, protocolId, input.talentName, '', now, now);
await persistMechanicOwnerStateInTransaction(database, {
source: authoredSource,
expectedSourceFingerprint: input.expectedSourceFingerprint,
selection: input.selection,
programName: input.talentName,
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.actorId,
resolveAttributeLabel: input.resolveAttributeLabel,
});
} else if (existingTalent) {
if (!input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing Talent and mechanic program.',
);
// Preserve the authored source, selection, projection, and immutable
// revisions as append-only history. Public readers start from the owner
// identity row, so removing that row cannot expose the detached mechanic.
await (
await database.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'os_protocol',
protocolId,
input.iconMutation,
input.actorId,
input.journal,
);
await applyGameDataRender(
database,
'os_protocol',
protocolId,
input.name,
input.renderMutation,
input.actorId,
input.journal,
);
await recordIsacAudit({
actorId: input.actorId,
category: 'game_data',
action: input.id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: {
...(await protocolSnapshot(protocolId, database)),
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { protocolId, mode: input.id ? 'edit' : 'add' };
}
export { MechanicSourceFingerprintConflictError };
File diff suppressed because it is too large Load Diff
+238 -49
View File
@@ -10,6 +10,7 @@ const capturedRule = {
const mocks = vi.hoisted(() => {
const events: string[] = [];
const applyIconMutation = vi.fn();
const database = {
exec: vi.fn(async (sql: string) => {
events.push(sql);
@@ -35,17 +36,34 @@ const mocks = vi.hoisted(() => {
);
return {
events,
applyIconMutation,
talentExists: false,
profileConfigured: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
parseConditionalRulePayload: vi.fn(),
loadMechanicAuthoredSource: vi.fn(async () => null),
mechanicAuthoredSourceSnapshot: vi.fn((value) => value),
loadSelectedMechanicModelRevision: vi.fn(async () => null),
mechanicModelRevisionSnapshot: vi.fn((value) => value),
loadMechanicPublicProjection: vi.fn(async () => []),
mechanicPublicProjectionSnapshot: vi.fn(() => null),
parseMechanicAuthoredSourcePayload: vi.fn(),
parseExpectedOwnerUpdatedAt: vi.fn(),
parseExpectedSourceFingerprint: vi.fn(),
parseMechanicSelectionIntent: vi.fn(),
persistMechanicOwnerStateInTransaction: vi.fn(async () => {
events.push('persist mechanic');
}),
MechanicSourceFingerprintConflictError: class extends Error {
constructor(
message = 'The authored mechanic changed after this editor was loaded.',
) {
super(message);
this.name = 'MechanicSourceFingerprintConflictError';
}
},
recordIsacAudit: vi.fn(async () => {
events.push('audit');
}),
saveConditionalRuleInTransaction: vi.fn(async () => {
events.push('save conditional rule');
}),
database,
};
});
@@ -81,7 +99,7 @@ vi.mock('$lib/server/os-protocol-profiles', () => ({
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
applyIconMutation: mocks.applyIconMutation,
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
@@ -96,11 +114,30 @@ vi.mock('$lib/server/managed-images', () => ({
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: mocks.parseConditionalRulePayload,
saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction,
vi.mock('$lib/server/mechanic-authored-sources', () => ({
loadMechanicAuthoredSource: mocks.loadMechanicAuthoredSource,
mechanicAuthoredSourceSnapshot: mocks.mechanicAuthoredSourceSnapshot,
MechanicSourceFingerprintConflictError:
mocks.MechanicSourceFingerprintConflictError,
}));
vi.mock('$lib/server/mechanic-model-revisions', () => ({
loadSelectedMechanicModelRevision: mocks.loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot: mocks.mechanicModelRevisionSnapshot,
}));
vi.mock('$lib/server/mechanic-public-projection', () => ({
loadMechanicPublicProjection: mocks.loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot: mocks.mechanicPublicProjectionSnapshot,
}));
vi.mock('$lib/server/mechanic-owner-mutation', () => ({
parseMechanicAuthoredSourcePayload: mocks.parseMechanicAuthoredSourcePayload,
parseExpectedOwnerUpdatedAt: mocks.parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint: mocks.parseExpectedSourceFingerprint,
parseMechanicSelectionIntent: mocks.parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction:
mocks.persistMechanicOwnerStateInTransaction,
}));
import { actions } from './+page.server';
@@ -113,6 +150,7 @@ const protocolRecord = {
quality: 'Standard',
primary_attribute_type: 'Firepower',
active: 1,
updated_at: '2026-08-02T12:00:00.000Z',
};
const primaryEffect = {
protocol_id: protocolId,
@@ -123,7 +161,25 @@ const talentRecord = {
id: talentId,
protocol_id: protocolId,
name: 'Escalation Loop',
description_template: 'Captured Talent',
description_template: '',
};
const authoredPayload = {
version: 1,
template: 'After {1} hits, gain {duration} seconds.',
variants: [
{
key: 'base',
materialized: 'After 10 hits, gain 10 seconds.',
slots: [
{ key: '1', value: '10', order: 0 },
{ key: 'duration', value: '10', order: 1 },
],
},
],
};
const authoredSource = {
...authoredPayload,
owner: { type: 'os_protocol_talent' as const, id: talentId },
};
const saveRequest = ({
@@ -131,11 +187,17 @@ const saveRequest = ({
hasTalent = false,
confirmRemoval = false,
includePrimaryValue = true,
selection = 'clear',
expectedTalentId = id && mocks.talentExists ? talentId : '',
expectedOwnerUpdatedAt = id ? protocolRecord.updated_at : '',
}: {
id?: string;
hasTalent?: boolean;
confirmRemoval?: boolean;
includePrimaryValue?: boolean;
selection?: 'preserve' | 'save' | 'clear';
expectedTalentId?: string;
expectedOwnerUpdatedAt?: string;
} = {}) => {
const form = new FormData();
form.set('id', id);
@@ -145,9 +207,14 @@ const saveRequest = ({
form.set('primaryStatId', 'weapon-damage');
if (includePrimaryValue) form.set('primaryBaseValue', '5');
form.set('hasTalent', hasTalent ? '1' : '0');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
if (hasTalent) {
form.set('talentName', 'Escalation Loop');
form.set('descriptionTemplate', 'Captured Talent');
form.set('authoredSource', JSON.stringify(authoredPayload));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', selection);
if (selection === 'save')
form.set('conditionalRule', JSON.stringify(capturedRule));
}
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
@@ -169,16 +236,25 @@ describe('OS Protocol optional Talent action', () => {
mocks.events.length = 0;
mocks.talentExists = false;
mocks.profileConfigured = true;
mocks.parseConditionalRulePayload.mockReturnValue(capturedRule);
mocks.conditionalRuleSnapshot.mockResolvedValue({
id: 'rule-1',
source_id: talentId,
});
mocks.parseMechanicAuthoredSourcePayload.mockImplementation(
(_payload, owner) => ({ ...authoredSource, owner }),
);
mocks.parseExpectedOwnerUpdatedAt.mockImplementation(
(raw: FormDataEntryValue | string | null, required: boolean) => {
const value = String(raw ?? '');
if (required && !value) throw new Error('The expected owner version is required.');
return value || null;
},
);
mocks.parseExpectedSourceFingerprint.mockReturnValue(null);
mocks.parseMechanicSelectionIntent.mockReturnValue({ intent: 'clear' });
mocks.auditRecord.mockResolvedValue(protocolRecord);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('FROM os_protocol_secondary_profiles'))
return mocks.profileConfigured ? { id: 'profile-1' } : null;
if (sql.includes('SELECT updated_at FROM os_protocols WHERE id='))
return { updated_at: protocolRecord.updated_at };
if (sql.includes('FROM os_protocols WHERE name=')) return null;
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
return mocks.talentExists ? talentRecord : null;
@@ -194,8 +270,6 @@ describe('OS Protocol optional Talent action', () => {
run: vi.fn(async () => {
if (sql.includes('INSERT INTO os_protocol_talents'))
mocks.talentExists = true;
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM os_protocol_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
@@ -212,8 +286,8 @@ describe('OS Protocol optional Talent action', () => {
protocolMode: 'add',
resultToken: expect.any(String),
});
expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled();
expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled();
expect(mocks.parseMechanicAuthoredSourcePayload).not.toHaveBeenCalled();
expect(mocks.persistMechanicOwnerStateInTransaction).not.toHaveBeenCalled();
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('INSERT INTO os_protocol_talents'),
@@ -225,7 +299,7 @@ describe('OS Protocol optional Talent action', () => {
category: 'game_data',
action: 'os_protocol.created',
before: null,
after: expect.objectContaining({ talent: null, conditional: null }),
after: expect.objectContaining({ talent: null, mechanic: null }),
}),
);
});
@@ -242,7 +316,8 @@ describe('OS Protocol optional Talent action', () => {
'Configure the shared secondary profile for this quality and attribute type first.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
@@ -250,35 +325,116 @@ describe('OS Protocol optional Talent action', () => {
const result = await runSave({ includePrimaryValue: false });
expect(result).toMatchObject({ status: 400 });
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('accepts an assigned CAPTURE ONLY Talent with no mechanic branches', async () => {
it('saves exact authored prose without selecting a model', async () => {
const result = await runSave({ hasTalent: true });
expect(result).toMatchObject({
protocolSaved: true,
protocolMode: 'add',
});
expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce();
expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith(
mocks.database,
'os_protocol_talent',
expect.any(String),
capturedRule,
expect(mocks.parseMechanicAuthoredSourcePayload).toHaveBeenCalledOnce();
expect(mocks.parseMechanicSelectionIntent).toHaveBeenCalledWith(
'clear',
null,
expect.objectContaining({ variantKeys: ['base'] }),
);
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({
source: expect.objectContaining({
owner: { type: 'os_protocol_talent', id: expect.any(String) },
template: authoredSource.template,
}),
selection: { intent: 'clear' },
}),
);
});
it('appends and selects a submitted model through the owner persistence seam', async () => {
mocks.parseMechanicSelectionIntent.mockReturnValue({
intent: 'save',
draft: capturedRule,
});
const result = await runSave({ hasTalent: true, selection: 'save' });
expect(result).toMatchObject({ protocolSaved: true, protocolMode: 'add' });
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({
selection: { intent: 'save', draft: capturedRule },
}),
);
});
it('preserves a selected revision while allowing authored prose to become stale', async () => {
mocks.parseMechanicSelectionIntent.mockReturnValue({ intent: 'preserve' });
const result = await runSave({
id: protocolId,
hasTalent: true,
selection: 'preserve',
});
expect(result).toMatchObject({ protocolSaved: true, protocolMode: 'edit' });
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({ selection: { intent: 'preserve' } }),
);
});
it('clears selection without deleting authored source or revision history', async () => {
mocks.parseMechanicSelectionIntent.mockReturnValue({ intent: 'clear' });
const result = await runSave({
id: protocolId,
hasTalent: true,
selection: 'clear',
});
expect(result).toMatchObject({ protocolSaved: true, protocolMode: 'edit' });
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({ selection: { intent: 'clear' } }),
);
});
it('rolls back the owner transaction on an authored-source fingerprint conflict', async () => {
mocks.parseExpectedSourceFingerprint.mockReturnValue('f'.repeat(64));
mocks.persistMechanicOwnerStateInTransaction.mockImplementationOnce(
async () => {
throw new mocks.MechanicSourceFingerprintConflictError();
},
);
const result = await runSave({ hasTalent: true });
expect(result).toMatchObject({
status: 409,
data: {
protocolError:
'The authored mechanic changed after this editor was loaded.',
},
});
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it.each(['ready', 'requires_assumptions'] as const)(
'rejects an empty %s mechanic program',
async (simulationReadiness) => {
mocks.parseConditionalRulePayload.mockReturnValue({
...capturedRule,
simulationReadiness,
mocks.parseMechanicSelectionIntent.mockImplementationOnce(() => {
throw new Error(
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
);
});
const result = await runSave({ hasTalent: true });
const result = await runSave({ hasTalent: true, selection: 'save' });
expect(result).toMatchObject({
status: 400,
@@ -287,11 +443,40 @@ describe('OS Protocol optional Talent action', () => {
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
},
);
it('rolls back the owner transaction when audit persistence fails', async () => {
mocks.recordIsacAudit.mockImplementationOnce(async () => {
mocks.events.push('audit failed');
throw new Error('audit unavailable');
});
await expect(runSave()).rejects.toThrow('audit unavailable');
expect(mocks.events).toEqual(['BEGIN', 'audit failed', 'ROLLBACK']);
});
it('rolls back direct-owner media when audit persistence fails', async () => {
let restored = 0;
mocks.applyIconMutation.mockImplementationOnce(
async (...args: unknown[]) => {
const journal = args[5] as {
register: (compensation: () => Promise<void>) => void;
};
journal.register(async () => {
restored += 1;
});
},
);
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit unavailable'));
await expect(runSave()).rejects.toThrow('audit unavailable');
expect(restored).toBe(1);
});
it('requires confirmation before removing an existing Talent', async () => {
mocks.talentExists = true;
@@ -304,11 +489,12 @@ describe('OS Protocol optional Talent action', () => {
'Confirm removal of the existing Talent and mechanic program.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes an existing Talent and its mechanic in the audited transaction', async () => {
it('removes an existing Talent while preserving immutable mechanic history', async () => {
mocks.talentExists = true;
const result = await runSave({
@@ -320,13 +506,12 @@ describe('OS Protocol optional Talent action', () => {
protocolSaved: true,
protocolMode: 'edit',
});
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.events).toEqual(['BEGIN', 'delete talent', 'audit', 'COMMIT']);
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('DELETE FROM mechanic_authored_sources'),
),
).toBe(false);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
@@ -334,10 +519,14 @@ describe('OS Protocol optional Talent action', () => {
action: 'os_protocol.updated',
entityId: protocolId,
before: expect.objectContaining({
talent: talentRecord,
conditional: expect.objectContaining({ source_id: talentId }),
talent: expect.objectContaining({
id: talentId,
protocol_id: protocolId,
name: 'Escalation Loop',
}),
after: expect.objectContaining({ talent: null, conditional: null }),
mechanic: null,
}),
after: expect.objectContaining({ talent: null, mechanic: null }),
}),
);
});
+92 -454
View File
@@ -6,20 +6,8 @@ import {
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import {
getPagination,
getRegistrySearch,
@@ -29,16 +17,28 @@ import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import {
SkillChipFormValidationError,
parseSkillChipForm,
_saveSkillChipInTransaction,
SkillChipNotFoundError,
loadTalent,
refreshAllFocusReadiness,
snapshot,
} from './skillchip-save';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
} from '$lib/server/mechanic-model-revisions';
import { loadMechanicPublicProjection } from '$lib/server/mechanic-public-projection';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_QUALITIES,
@@ -52,87 +52,7 @@ import {
replaceSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) => rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) =>
mechanicOutcomes(rule)
.filter((outcome) => outcome.descriptionSlot)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
async function snapshot(id: string) {
const database = await getDatabase();
const talent = await (
await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id);
return {
record: await auditRecord('skill_chip_sets', id),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
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,
};
}
export { _saveSkillChipInTransaction } from './skillchip-save';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
@@ -240,8 +160,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'twoPieceMax',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -266,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets,
twoPiece,
talents,
talentEffects,
stats,
abilities,
specializations,
@@ -285,10 +206,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents`,
)
).all(),
(
@@ -358,7 +279,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const currentPendingSets = pendingSets.filter(
(set) =>
Boolean(set.abilityId) &&
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
(String(set.hasTalent) !== '1' || Boolean(set.authoredSource)),
);
const pendingSetRows = requestedItemId
? currentPendingSets.filter((set) => set.id === requestedItemId)
@@ -387,16 +308,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
authoredSource: (() => {
try {
return JSON.parse(
String(set.conditionalRule ?? 'null'),
);
return JSON.parse(String(set.authoredSource ?? 'null'));
} catch {
return null;
}
})(),
expectedSourceFingerprint:
String(set.expectedSourceFingerprint ?? ''),
selectedRevision: null,
publicProjection: [],
mechanicSelectionIntent:
String(set.mechanicSelectionIntent ?? 'clear'),
}
: null,
}));
@@ -404,6 +328,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets.map(async (set) => {
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
const icon = icons.find((item) => item.entity_id === set.id);
const authoredSource = talent
? await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
String(talent.id),
)
: null;
const selectedRevision = authoredSource
? await loadSelectedMechanicModelRevision(database, authoredSource.id)
: null;
const publicProjection = authoredSource
? await loadMechanicPublicProjection(database, authoredSource.id)
: [];
return {
...set,
iconUrl: icon
@@ -422,15 +359,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'skill_chip_talent',
String(talent.id),
['minimum', 'maximum'],
),
authoredSource: authoredSource
? {
version: authoredSource.source.version,
template: authoredSource.source.template,
variants: authoredSource.source.variants,
}
: null,
expectedSourceFingerprint: authoredSource?.fingerprint ?? null,
selectedRevision,
publicProjection,
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
}
: null,
};
@@ -472,340 +411,31 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { skillChipError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { skillChipError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const abilityId = String(form.get('abilityId') ?? '');
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
const journal = new MediaMutationJournal();
try {
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
} catch (error) {
return fail(400, {
skillChipError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
const save = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseSkillChipForm(tx, form, operator.id);
return _saveSkillChipInTransaction(tx, { ...input, journal });
});
}
if (name.length < 2 || name.length > 80)
return fail(400, {
skillChipError: 'Skill Chip names must be 2–80 characters.',
});
if (!abilityId)
return fail(400, {
skillChipError: 'Assign this Skill Chip to a specialization ability.',
});
if (
!twoPieceStatId ||
!Number.isFinite(twoPieceMin) ||
!Number.isFinite(twoPieceMax) ||
twoPieceMax < twoPieceMin
)
return fail(400, {
skillChipError: 'Configure a valid 2-piece stat range.',
});
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
if (
hasTalent &&
(talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule?.clauses.length ||
!outcomes.length)
)
return fail(400, {
skillChipError:
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
});
if (
hasTalent &&
outcomes.some(
(outcome) =>
outcome.variants.maximum.value < outcome.variants.minimum.value,
)
)
return fail(400, {
skillChipError:
'Every talent outcome maximum must be at least its minimum.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
hasTalent &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
skillChipError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
hasTalent &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
skillChipError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
return fail(400, {
skillChipError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError: 'Choose an active specialization ability.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(abilityId, id || 'new')
)?.count ?? 0,
);
if (assignedCount >= 3) {
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
});
}
if (
await (
await database.prepare(
'SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, {
skillChipError: 'That Skill Chip set already exists.',
});
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
skillChipError:
'Confirm removal of the existing 3-piece Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
skillChipError: 'That Skill Chip talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
)
).get(...allStatIds);
if (Number(validStats?.count) !== allStatIds.length)
return fail(400, {
skillChipError: 'One or more selected stats are unavailable.',
});
const setId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
if (id && !before?.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
await database.exec('BEGIN');
try {
if (id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(name, abilityId, now, id);
} else
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, name, abilityId, now, now);
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
if (hasTalent && conditionalRule) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await database.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
iconMutation,
operator.id,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
name,
renderMutation,
operator.id,
);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: await snapshot(setId),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
const result = await save.immediate();
journal.clear();
return {
skillChipSaved: true,
skillChipMode: id ? 'edit' : 'add',
skillChipMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
await journal.rollback(error);
if (error instanceof SkillChipFormValidationError)
return fail(error.status, { skillChipError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { skillChipError: error.message });
if (error instanceof SkillChipNotFoundError)
return fail(404, { skillChipError: error.message });
throw error;
}
},
savePrimaryProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -936,7 +566,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -956,7 +586,7 @@ export const actions: Actions = {
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -964,7 +594,11 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
return {
skillChipArchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -975,7 +609,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -1021,7 +655,7 @@ export const actions: Actions = {
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -1029,6 +663,10 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
return {
skillChipUnarchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
};
+81 -37
View File
@@ -10,34 +10,58 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
let { data, form } = $props();
const talentValueColumns = [
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' }
];
const authoredVariantLabels = [
{ key: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', label: 'MAXIMUM' }
];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE CURRENT MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
const blankAuthoredVariants = (): MechanicAuthoredVariant[] => [
{ key: 'minimum', materialized: '', slots: [] },
{ key: 'maximum', materialized: '', slots: [] }
];
const blankAuthoredSource = () => ({ version: 1, template: '', variants: blankAuthoredVariants() });
const blankTalent = () => ({
id: '',
name: '',
authoredSource: blankAuthoredSource(),
expectedTalentId: '',
expectedSourceFingerprint: '',
selectedRevision: null,
mechanicSelectionIntent: 'clear',
modelDraft: blankRule()
});
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', expectedOwnerUpdatedAt: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const qualitySteps = [
{ value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' },
{ value: '2', label: '2 HIGH-END' },
{ value: '3', label: '3 HIGH-END' }
];
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const ability = (id: string) => data.abilities.find((item: any) => item.id === id);
const mechanicModeled = (talent: any) =>
Boolean(talent?.publicProjection?.length) &&
talent.publicProjection.every((variant: any) => variant.status === 'modeled');
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
@@ -45,32 +69,25 @@
const unit = stat(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const scaled = (minimum: number | string, maximum: number | string) => Number(minimum) + (Number(maximum) - Number(minimum)) * Number(highEndCount) / 3;
const previewOutcomes = () => editing.talent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = () => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const minimum = outcome?.variants?.minimum?.value;
const maximum = outcome?.variants?.maximum?.value;
return outcome && minimum !== '' && maximum !== '' && minimum !== null && maximum !== null && minimum !== undefined && maximum !== undefined
? format(scaled(minimum, maximum), outcome.attributeId)
: placeholder;
});
const scaled = (minimum: number | string, _maximum: number | string) => Number(minimum);
const edit = (item: any) => {
const selectedRevision = item.talent?.selectedRevision ?? null;
const source = item.talent?.authoredSource ?? blankAuthoredSource();
editing = {
...item,
expectedOwnerUpdatedAt: item.updated_at ?? '',
abilityId: String(item.specialization_ability_id),
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
hasTalent: Boolean(item.talent),
talentOpen: false,
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
talent: item.talent ? structuredClone({
...item.talent,
expectedTalentId: String(item.talent.id ?? ''),
authoredSource: source,
modelDraft: selectedRevision?.draft ?? blankRule(),
mechanicSelectionIntent: item.talent.mechanicSelectionIntent ?? (selectedRevision ? 'preserve' : 'clear')
}) : blankTalent()
};
highEndCount = '0';
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
@@ -78,6 +95,11 @@
if (requested) edit(requested);
openedRequestedItem = true;
}
if ((form?.skillChipArchived || form?.skillChipUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (editing?.id === form.skillChipId) editing = null;
return;
}
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.skillChipMode === 'add') {
@@ -85,7 +107,6 @@
renderResetToken += 1;
}
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
});
@@ -120,8 +141,17 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={editing.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<input type="hidden" name="expectedTalentId" value={editing.talent.expectedTalentId ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={editing.talent.expectedSourceFingerprint ?? ''}/>
{#if editing.hasTalent}
<input type="hidden" name="authoredSource" value={JSON.stringify(editing.talent.authoredSource)}/>
<input type="hidden" name="mechanicSelectionIntent" value={editing.talent.mechanicSelectionIntent}/>
{#if editing.talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.modelDraft)}/>
{/if}
{/if}
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
@@ -137,22 +167,36 @@
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>OPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>AUTHORED MINIMUM + MAXIMUM · {editing.talent.mechanicSelectionIntent.toUpperCase()} MODEL</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label></div>
<AuthoredDescriptionEditor
variantLabels={authoredVariantLabels}
idPrefix={`skill-chip-authored-${editing.talent.id || 'new'}`}
bind:template={editing.talent.authoredSource.template}
bind:variants={editing.talent.authoredSource.variants}
/>
<div class="mechanic-selection">
<SegmentedChoice legend="MECHANIC PROGRAM" name="mechanic-selection" options={mechanicSelectionOptions} value={editing.talent.mechanicSelectionIntent} onchange={(values) => { editing.talent.mechanicSelectionIntent = values[0]; }}/>
{#if editing.talent.mechanicSelectionIntent === 'save' || editing.talent.selectedRevision}
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
disabled={editing.talent.mechanicSelectionIntent !== 'save'}
aiContext={{
sourceKind: 'skill_chip_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
sourceDescription: editing?.talent?.authoredSource?.template ?? ''
}}
bind:rule={editing.talent.conditionalRule}
bind:rule={editing.talent.modelDraft}
/>
{/if}
{#if editing.talent.mechanicSelectionIntent !== 'save'}
<p class="console-note">{editing.talent.mechanicSelectionIntent === 'preserve' ? 'The selected model revision is preserved exactly. Choose SAVE MODEL to replace it.' : 'No combat model will be selected. Complete authored prose is sufficient for this Talent.'}</p>
{/if}
</div>
</div>
</details>
{:else}<p class="console-note">This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.</p>{#if editing.talent.id}<label class="skill-chip-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}{/if}
@@ -169,7 +213,7 @@
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? mechanicModeled(item.talent) ? 'MODELED · CURRENT REVISION' : item.talent.authoredSource ? 'AUTHORED FALLBACK' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
</div>
@@ -0,0 +1,834 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
const mocks = vi.hoisted(() => ({
database: null as Database | null,
applyIconMutation: vi.fn(),
applyGameDataRender: vi.fn(),
auditRecord: vi.fn(),
recordIsacAudit: vi.fn(),
refreshAbilityFocuses: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: mocks.applyIconMutation,
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: mocks.applyGameDataRender,
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshAbilityFocuses: mocks.refreshAbilityFocuses,
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'actor-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
import { actions, _saveSkillChipInTransaction } from './+page.server';
import { parseSkillChipForm } from './skillchip-save';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import { loadSelectedMechanicModelRevision } from '$lib/server/mechanic-model-revisions';
const source = (value = '10'): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'skill_chip_talent', id: 'talent-1' },
template: 'Gain {1}% Skill Damage.',
variants: [
{
key: 'minimum',
materialized: 'Gain 10% Skill Damage.',
slots: [
{
key: '1',
value: '10',
order: 0,
unit: '%',
attributeId: 'skill-damage',
},
],
},
{
key: 'maximum',
materialized: `Gain ${value}% Skill Damage.`,
slots: [
{ key: '1', value, order: 0, unit: '%', attributeId: 'skill-damage' },
],
},
],
});
const model = (value = 10): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'skill-hit',
label: 'Skill hit',
event: 'skill_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'skill-damage',
label: 'Skill Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'assigned_ability',
basis: 'flat',
attributeId: 'skill-damage',
perStack: false,
variants: {
minimum: { value },
maximum: { value: value + 5 },
},
},
],
},
],
});
const input = (
databaseSource: MechanicAuthoredSource,
options: Partial<Parameters<typeof _saveSkillChipInTransaction>[1]> = {},
): Parameters<typeof _saveSkillChipInTransaction>[1] => ({
id: '',
proposedEntityId: 'set-1',
name: 'Pulse Matrix',
abilityId: 'ability-1',
twoPieceStatId: 'skill-duration',
twoPieceMin: 5,
twoPieceMax: 10,
hasTalent: true,
talentName: 'Signal Cascade',
talentId: 'talent-1',
expectedTalentId: '',
authoredSource: databaseSource,
expectedSourceFingerprint: null,
selection: { intent: 'clear' },
confirmTalentRemoval: false,
iconMutation: { output: null, remove: false },
renderMutation: { file: null, remove: false },
actorId: 'actor-1',
resolveAttributeLabel: (attributeId) =>
attributeId === 'skill-damage' ? 'Skill Damage' : null,
...options,
});
const removeTalentRequest = (
expectedSourceFingerprint: string,
expectedTalentId: string,
expectedOwnerUpdatedAt: string,
) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
form.set('expectedSourceFingerprint', expectedSourceFingerprint);
form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
const talentSaveRequest = (
expectedTalentId: string,
expectedOwnerUpdatedAt: string,
) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '1');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
form.set('talentName', 'Signal Cascade');
form.set('authoredSource', JSON.stringify(source()));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', 'clear');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
async function applySchema(database: 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 database.exec(statement);
}
}
const count = async (table: string) =>
Number(
(
await (
await mocks.database!.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get()
)?.count ?? 0,
);
const ownerUpdatedAt = async (database: Database, id = 'set-1') =>
String(
(
await (await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?')).get(id)
)?.updated_at ?? '',
);
describe('Skill Chip Phase 2 owner persistence', () => {
let database: Database;
beforeEach(async () => {
vi.clearAllMocks();
database = await connect(':memory:');
mocks.database = database;
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await database.exec(`INSERT INTO users(id,username,role,created_at)
VALUES('actor-1','Operator','admin','now')`);
await database.exec(`INSERT INTO specializations(id,name,created_at,updated_at)
VALUES('specialization-1','Tactical','now','now')`);
await database.exec(`INSERT INTO specialization_abilities(id,specialization_id,name,description,created_at,updated_at)
VALUES('ability-1','specialization-1','Pulse','Pulse description','now','now'),
('ability-2','specialization-1','Pulse Alt','Pulse alt description','now','now')`);
await database.exec(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,created_at,updated_at)
VALUES('skill-duration','Skill Duration','Offensive','Skill','Additive','s','now','now'),
('skill-damage','Skill Damage','Offensive','Skill','Additive','%','now','now')`);
mocks.auditRecord.mockImplementation(
async (_table: string, id: string, supplied: Database) =>
(await (
await supplied.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get(id)) ?? null,
);
mocks.recordIsacAudit.mockResolvedValue(undefined);
});
afterEach(async () => {
await database.close();
mocks.database = null;
});
it('parses an absent expected Talent identity as blank in the direct parser', async () => {
const form = new FormData();
form.set('name', 'Direct Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
await expect(parseSkillChipForm(database, form, 'actor-1')).resolves.toMatchObject({
expectedTalentId: '',
});
});
it('archives and unarchives a Skill Chip with lifecycle result tokens', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { hasTalent: false, authoredSource: null }),
);
await database.exec('COMMIT');
const lifecycleRequest = (action: 'archive' | 'unarchive') => {
const form = new FormData();
form.set('id', 'set-1');
return new Request(`http://localhost/isac/skillchips?/${action}`, {
method: 'POST',
body: form,
});
};
const archived = await actions.archive?.({
cookies: {} as never,
request: lifecycleRequest('archive'),
} as never);
expect(archived).toMatchObject({
skillChipArchived: true,
skillChipId: 'set-1',
resultToken: expect.any(String),
});
expect(
await (
await database.prepare('SELECT active FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual({ active: 0 });
const unarchived = await actions.unarchive?.({
cookies: {} as never,
request: lifecycleRequest('unarchive'),
} as never);
expect(unarchived).toMatchObject({
skillChipUnarchived: true,
skillChipId: 'set-1',
resultToken: expect.any(String),
});
expect(
await (
await database.prepare('SELECT active FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual({ active: 1 });
});
it('accepts a prose-only action, returns a result token, and persists the canonical fields', async () => {
const payload = source();
delete (payload as unknown as { owner?: unknown }).owner;
const form = new FormData();
form.set('name', 'Action Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '1');
form.set('expectedTalentId', '');
form.set('talentName', 'Action Signal Cascade');
form.set('authoredSource', JSON.stringify(payload));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', 'clear');
const result = await actions.save?.({
cookies: {} as never,
request: new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
}),
} as never);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'add',
resultToken: expect.any(String),
});
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
});
it('saves prose-only authored source without legacy mechanic writes', async () => {
await database.exec('BEGIN IMMEDIATE');
const result = await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
expect(result).toEqual({ setId: 'set-1', mode: 'add' });
expect(
await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
),
).toMatchObject({
source: source(),
});
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
expect(await count('skill_chip_talent_effects')).toBe(0);
expect(
(
await (
await database.prepare(
'SELECT description_template FROM skill_chip_talents',
)
).get()
)?.description_template,
).toBe('');
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'skill_chip.created',
entityId: 'set-1',
before: null,
after: expect.objectContaining({
mechanic: expect.objectContaining({
authoredSource: expect.objectContaining({
owner: { type: 'skill_chip_talent', id: 'talent-1' },
}),
selectedRevision: null,
publicProjection: expect.objectContaining({
variants: expect.any(Array),
}),
}),
}),
}),
);
});
it('rejects a canonical authored-source owner mismatch before writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
authoredSource: {
...source(),
owner: { type: 'skill_chip_talent', id: 'client-supplied' },
},
}),
),
).rejects.toThrow(/owner does not match/);
await database.exec('ROLLBACK');
expect(await count('skill_chip_sets')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(0);
});
it('rechecks the active three-chip limit inside the write transaction', async () => {
await database.exec(`INSERT INTO skill_chip_sets
(id,name,specialization_ability_id,created_at,updated_at)
VALUES ('set-a','Set A','ability-1','now','now'),
('set-b','Set B','ability-1','now','now'),
('set-c','Set C','ability-1','now','now')`);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
hasTalent: false,
authoredSource: null,
talentName: '',
selection: { intent: 'clear' },
}),
),
).rejects.toThrow(/all three active Skill Chips/);
await database.exec('ROLLBACK');
expect(await count('skill_chip_sets')).toBe(3);
});
it('appends a revision, skips an identical retry, and clears selection without deleting history', async () => {
const first = input(source(), {
authoredSource: source(),
selection: { intent: 'save', draft: model() },
});
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const firstEditOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: firstEditOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
});
await database.exec('COMMIT');
expect(await count('mechanic_model_revisions')).toBe(1);
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toMatchObject({ revisionNumber: 1 });
const secondEditOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: secondEditOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'clear' },
});
await database.exec('COMMIT');
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toBeNull();
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('rejects a stale aggregate after scalar, relationship, and selected revision changes without writing', async () => {
const first = input(source(), {
selection: { intent: 'save', draft: model() },
});
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const staleOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
name: 'Concurrent Pulse Matrix',
abilityId: 'ability-2',
twoPieceMin: 6,
twoPieceMax: 12,
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'save', draft: model(20) },
});
await database.exec('COMMIT');
await (
await database.prepare('UPDATE skill_chip_sets SET updated_at=? WHERE id=?')
).run('2026-08-02T12:00:01.000Z', 'set-1');
const ownerBeforeStale = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const relationshipBeforeStale = await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get('set-1');
const selectedRevisionBeforeStale = await loadSelectedMechanicModelRevision(
database,
savedSource!.id,
);
expect(ownerBeforeStale).toMatchObject({
name: 'Concurrent Pulse Matrix',
specialization_ability_id: 'ability-2',
updated_at: '2026-08-02T12:00:01.000Z',
});
expect(relationshipBeforeStale).toMatchObject({
min_value: 6,
max_value: 12,
});
expect(selectedRevisionBeforeStale).toMatchObject({
revisionNumber: 2,
draft: model(20),
});
const revisionCountBeforeStale = await count('mechanic_model_revisions');
const auditCountBeforeStale = mocks.recordIsacAudit.mock.calls.length;
const iconCallsBeforeStale = mocks.applyIconMutation.mock.calls.length;
const renderCallsBeforeStale = mocks.applyGameDataRender.mock.calls.length;
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
name: 'Stale Pulse Matrix',
abilityId: 'ability-1',
twoPieceMin: 7,
twoPieceMax: 14,
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'save', draft: model(30) },
}),
).rejects.toMatchObject({
status: 409,
message: expect.stringMatching(/changed after this editor was loaded/),
});
await database.exec('ROLLBACK');
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(ownerBeforeStale);
expect(
await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get('set-1'),
).toEqual(relationshipBeforeStale);
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toEqual(selectedRevisionBeforeStale);
expect(await count('mechanic_model_revisions')).toBe(revisionCountBeforeStale);
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBeforeStale);
expect(mocks.applyIconMutation).toHaveBeenCalledTimes(iconCallsBeforeStale);
expect(mocks.applyGameDataRender).toHaveBeenCalledTimes(renderCallsBeforeStale);
});
it('rejects a stale checked Talent after it was removed without writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const talentBefore = await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1');
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE skill_chip_set_id=?')
).run('set-1');
const setBefore = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const auditCountBefore = mocks.recordIsacAudit.mock.calls.length;
const iconCallsBefore = mocks.applyIconMutation.mock.calls.length;
const renderCallsBefore = mocks.applyGameDataRender.mock.calls.length;
const stale = await actions.save?.({
cookies: {} as never,
request: talentSaveRequest(
String(talentBefore?.id),
expectedOwnerUpdatedAt,
),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(setBefore);
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM skill_chip_talents')
).get(),
).toEqual({ count: 0 });
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBefore);
expect(mocks.applyIconMutation).toHaveBeenCalledTimes(iconCallsBefore);
expect(mocks.applyGameDataRender).toHaveBeenCalledTimes(renderCallsBefore);
});
it('rejects a stale blank Talent after another Talent was added without writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { hasTalent: false, authoredSource: null }),
);
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec(`INSERT INTO skill_chip_talents
(id,skill_chip_set_id,name,description_template,created_at,updated_at)
VALUES('late-talent','set-1','Late Talent','','now','now')`);
const setBefore = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const talentBefore = await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1');
const auditCountBefore = mocks.recordIsacAudit.mock.calls.length;
const stale = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest('', '', expectedOwnerUpdatedAt),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(setBefore);
expect(
await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1'),
).toEqual(talentBefore);
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBefore);
expect(await count('game_data_icons')).toBe(0);
expect(await count('managed_images')).toBe(0);
});
it('rejects a direct save when the expected Talent identity is stale before writes', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE skill_chip_set_id=?')
).run('set-1');
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt,
}),
),
).rejects.toThrow(/Talent changed/);
await database.exec('ROLLBACK');
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM skill_chip_talents')
).get(),
).toEqual({ count: 0 });
});
it('rejects stale Talent removal before changing identity, history, audit, or media', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const talentBefore = await (
await database.prepare(
'SELECT id,skill_chip_set_id,name FROM skill_chip_talents',
)
).get();
const stale = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest(
'f'.repeat(64),
'talent-1',
expectedOwnerUpdatedAt,
),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare(
'SELECT id,skill_chip_set_id,name FROM skill_chip_talents',
)
).get(),
).toEqual(talentBefore);
expect(
await (
await database.prepare(
'SELECT source_fingerprint FROM mechanic_authored_sources WHERE owner_id=?',
)
).get('talent-1'),
).toEqual({ source_fingerprint: savedSource?.fingerprint });
expect(mocks.recordIsacAudit).toHaveBeenCalledOnce();
expect(await count('game_data_icons')).toBe(0);
expect(await count('managed_images')).toBe(0);
});
it('removes a current Talent while preserving authored and revision history', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { selection: { intent: 'save', draft: model() } }),
);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const result = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest(
String(savedSource?.fingerprint),
'talent-1',
expectedOwnerUpdatedAt,
),
} as never);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'edit',
});
expect(await count('skill_chip_talents')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(1);
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('refreshes both abilities and rolls back a stale source or audit failure', async () => {
const first = input(source());
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const staleSourceOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
abilityId: 'ability-2',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleSourceOwnerUpdatedAt,
expectedSourceFingerprint: 'f'.repeat(64),
selection: { intent: 'preserve' },
}),
).rejects.toBeInstanceOf(MechanicSourceFingerprintConflictError);
await database.exec('ROLLBACK');
expect(await count('mechanic_model_revisions')).toBe(0);
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit failed'));
const auditFailureOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
abilityId: 'ability-2',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: auditFailureOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
}),
).rejects.toThrow('audit failed');
await database.exec('ROLLBACK');
expect(mocks.refreshAbilityFocuses).toHaveBeenCalledWith(
database,
'ability-1',
);
expect(mocks.refreshAbilityFocuses).toHaveBeenCalledWith(
database,
'ability-2',
);
expect(
(
await (
await database.prepare(
'SELECT specialization_ability_id FROM skill_chip_sets WHERE id=?',
)
).get('set-1')
)?.specialization_ability_id,
).toBe('ability-1');
});
});
@@ -0,0 +1,545 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import {
applyIconMutation,
gameDataIconMetadata,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
readGameDataRenderMutation,
ManagedImageValidationError,
processManagedImage,
} from '$lib/server/managed-images';
import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
mechanicAuthoredSourceSnapshot,
} from '$lib/server/mechanic-authored-sources';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { MediaMutationJournal } from '$lib/server/media-journal';
export class SkillChipFormValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'SkillChipFormValidationError';
this.status = status;
}
}
export class SkillChipTalentIdentityConflictError extends SkillChipFormValidationError {
constructor(expectedTalentId: string, currentTalentId: string) {
super(
409,
`The Skill Chip Talent changed after this editor was loaded (expected ${expectedTalentId || 'none'}, found ${currentTalentId || 'none'}). Reload the latest definition and try again.`,
);
this.name = 'SkillChipTalentIdentityConflictError';
}
}
function parseExpectedTalentId(value: FormDataEntryValue | null): string {
if (value === null) return '';
if (
typeof value !== 'string' ||
value !== value.trim() ||
value.length > 200 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
)
throw new SkillChipFormValidationError(
400,
'The expected Skill Chip Talent identity is invalid.',
);
return value;
}
/** Parses the canonical Skill Chip save form for direct saves and replay. */
export async function parseSkillChipForm(
database: Database,
form: FormData,
actorId: string,
mutationContext?: SkillChipMutationContext,
): Promise<SkillChipSaveInput> {
const id = String(form.get('id') ?? '').trim();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
throw new SkillChipFormValidationError(
400,
error instanceof Error ? error.message : 'The expected owner version is invalid.',
);
}
const existingSet = id
? await (
await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?')
).get(id)
: null;
if (id && !existingSet)
throw new SkillChipFormValidationError(404, 'Skill Chip set not found.');
if (id && expectedOwnerUpdatedAt !== String(existingSet?.updated_at ?? ''))
throw new SkillChipFormValidationError(
409,
'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.',
);
const expectedTalentId = parseExpectedTalentId(form.get('expectedTalentId'));
const existingTalent = id ? await loadTalent(database, id) : null;
const currentTalentId = String(existingTalent?.id ?? '');
// The child identity is the optimistic concurrency token for this owner.
if (expectedTalentId !== currentTalentId)
throw new SkillChipTalentIdentityConflictError(
expectedTalentId,
currentTalentId,
);
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
throw new SkillChipFormValidationError(400, error.message);
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
throw new SkillChipFormValidationError(400, error.message);
throw error;
}
const proposedEntityId = String(form.get('_proposedEntityId') ?? '').trim();
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const abilityId = String(form.get('abilityId') ?? '').trim();
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '').trim();
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
const talentId = String(existingTalent?.id ?? randomUUID());
let expectedSourceFingerprint: string | null = null;
if (hasTalent || existingTalent)
try {
expectedSourceFingerprint = parseExpectedSourceFingerprint(
form.get('expectedSourceFingerprint'),
);
} catch (error) {
throw new SkillChipFormValidationError(
400,
error instanceof Error
? error.message
: 'The expected authored-source fingerprint is invalid.',
);
}
const invalid = (status: number, message: string): never => {
throw new SkillChipFormValidationError(status, message);
};
if (name.length < 2 || name.length > 80)
invalid(400, 'Skill Chip names must be 2–80 characters.');
if (!abilityId)
invalid(400, 'Assign this Skill Chip to a specialization ability.');
if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin)
invalid(400, 'Configure a valid 2-piece stat range.');
if (existingTalent && !hasTalent && form.get('confirmTalentRemoval') !== 'REMOVE')
invalid(400, 'Confirm removal of the existing 3-piece Talent and mechanic program.');
let authoredSource: MechanicAuthoredSource | null = null;
let selection: MechanicOwnerSelection = { intent: 'clear' };
let activeStats: Array<Record<string, unknown>> = [];
if (hasTalent) {
if (talentName.length < 2 || talentName.length > 80)
invalid(400, 'Talent names must be 2–80 characters.');
activeStats = await (await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')).all();
try {
authoredSource = parseMechanicAuthoredSourcePayload(
form.get('authoredSource'),
{ type: 'skill_chip_talent', id: talentId },
);
selection = parseMechanicSelectionIntent(
form.get('mechanicSelectionIntent'),
form.get('conditionalRule'),
{
variantKeys: ['minimum', 'maximum'],
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: talentName,
ownerType: 'skill_chip_talent',
},
);
const authoredAttributeIds = [
...new Set(authoredSource.variants.flatMap((variant) =>
variant.slots.map((slot) => slot.attributeId).filter((value): value is string => Boolean(value)),
)),
];
if (authoredAttributeIds.some((attributeId) => !activeStats.some((row) => String(row.id) === attributeId)))
throw new Error('One or more authored Attributes are unavailable.');
} catch (error) {
invalid(400, error instanceof Error ? error.message : 'The authored Talent source is invalid.');
}
}
if (!(await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)))
invalid(400, 'Choose an active specialization ability.');
const assignedCount = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)).get(abilityId, id || 'new'))?.count ?? 0);
if (assignedCount >= 3)
invalid(409, 'That ability already has all three active Skill Chips assigned.');
if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new'))
invalid(409, 'That Skill Chip set already exists.');
if (hasTalent && await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, talentId))
invalid(409, 'That Skill Chip talent name is already in use.');
const validStats = await (await database.prepare('SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id=?')).get(twoPieceStatId);
if (Number(validStats?.count) !== 1)
invalid(400, 'One or more selected stats are unavailable.');
return {
id,
proposedEntityId,
name,
abilityId,
twoPieceStatId,
twoPieceMin,
twoPieceMax,
hasTalent,
talentName,
talentId,
expectedTalentId,
expectedOwnerUpdatedAt,
authoredSource,
expectedSourceFingerprint,
selection,
confirmTalentRemoval: form.get('confirmTalentRemoval') === 'REMOVE',
iconMutation,
renderMutation,
actorId,
mutationContext,
resolveAttributeLabel: (attributeId) =>
activeStats.find((row) => String(row.id) === attributeId)?.name as string | undefined,
};
}
const renderAuditMetadata = (
render: Record<string, unknown> | null | undefined,
) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
export async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
export async function loadTalent(database: Database, setId: string) {
return (
(await (
await database.prepare(
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents WHERE skill_chip_set_id=?`,
)
).get(setId)) ?? null
);
}
async function mechanicStateSnapshot(
database: Database,
talentId: string | null,
) {
if (!talentId) return null;
const authored = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
talentId,
);
if (!authored) return null;
const [selectedRevision, projection] = await Promise.all([
loadSelectedMechanicModelRevision(database, authored.id),
loadMechanicPublicProjection(database, authored.id),
]);
return {
authoredSource: mechanicAuthoredSourceSnapshot(authored),
selectedRevision: mechanicModelRevisionSnapshot(selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
selectedRevision,
projection,
),
};
}
export async function snapshot(database: Database, id: string) {
const talent = await loadTalent(database, id);
const mechanic = await mechanicStateSnapshot(
database,
talent ? String(talent.id) : null,
);
return {
record: await auditRecord('skill_chip_sets', id, database),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
mechanic,
};
}
export type SkillChipMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
export type SkillChipSaveInput = {
id: string;
proposedEntityId: string;
name: string;
abilityId: string;
twoPieceStatId: string;
twoPieceMin: number;
twoPieceMax: number;
hasTalent: boolean;
talentName: string;
talentId: string;
expectedTalentId: string;
expectedOwnerUpdatedAt?: string | null;
authoredSource: MechanicAuthoredSource | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
confirmTalentRemoval: boolean;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
actorId: string;
mutationContext?: SkillChipMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class SkillChipNotFoundError extends Error {
constructor() {
super('Skill Chip set not found.');
this.name = 'SkillChipNotFoundError';
}
}
/**
* Persists a Skill Chip owner and all of its dependent state in the caller's
* active transaction. Keeping the transaction outside this function lets Data
* Oversight replay the exact same canonical save path.
*/
export async function saveSkillChipInTransaction(
database: Database,
input: SkillChipSaveInput,
): Promise<{ setId: string; mode: 'add' | 'edit' }> {
const setId = input.id || input.proposedEntityId || randomUUID();
const before = input.id ? await snapshot(database, input.id) : null;
if (input.id && !before?.record) throw new SkillChipNotFoundError();
if (
input.id &&
input.expectedOwnerUpdatedAt !== String(before?.record?.updated_at ?? '')
)
throw new SkillChipFormValidationError(
409,
'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.',
);
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
const existingTalent = input.id ? await loadTalent(database, input.id) : null;
const currentTalentId = String(existingTalent?.id ?? '');
// Recheck inside the write transaction before any owner or media writes.
if (input.expectedTalentId !== currentTalentId)
throw new SkillChipTalentIdentityConflictError(
input.expectedTalentId,
currentTalentId,
);
const talentId = String(existingTalent?.id ?? input.talentId);
if (input.hasTalent && !input.authoredSource)
throw new Error('The authored description is required for a Talent.');
if (
input.authoredSource &&
(input.authoredSource.owner.type !== 'skill_chip_talent' ||
input.authoredSource.owner.id !== talentId)
) {
throw new Error('The authored mechanic owner does not match the Skill Chip Talent.');
}
if (existingTalent && !input.hasTalent && !input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing 3-piece Talent and mechanic program.',
);
if (existingTalent && !input.hasTalent) {
const currentSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
talentId,
);
if (input.expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
currentSource?.fingerprint ?? null,
);
}
// The parser checks this for immediate form feedback, but this query on the
// write transaction is authoritative for canonical callers and retries.
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(input.abilityId, setId)
)?.count ?? 0,
);
if (assignedCount >= 3)
throw new Error('That ability already has all three active Skill Chips assigned.');
const now = new Date().toISOString();
if (input.id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(input.name, input.abilityId, now, input.id);
} else {
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, input.name, input.abilityId, now, now);
}
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(
randomUUID(),
setId,
input.twoPieceStatId,
input.twoPieceMin,
input.twoPieceMax,
);
if (input.hasTalent && input.authoredSource) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,'',?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at`)
).run(talentId, setId, input.talentName, now, now);
await persistMechanicOwnerStateInTransaction(database, {
source: input.authoredSource,
expectedSourceFingerprint: input.expectedSourceFingerprint,
selection: input.selection,
programName: input.talentName,
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.actorId,
resolveAttributeLabel: input.resolveAttributeLabel,
});
} else if (existingTalent) {
// Preserve the authored source, selection, projection, and immutable
// revisions as append-only history. Public readers start from the Talent
// identity row, so the detached mechanic cannot leak after this deletion.
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
input.iconMutation,
input.actorId,
input.journal,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
input.name,
input.renderMutation,
input.actorId,
input.journal,
);
await refreshAbilityFocuses(database, input.abilityId);
if (previousAbilityId && previousAbilityId !== input.abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
const after = await snapshot(database, setId);
await recordIsacAudit({
actorId: input.actorId,
category: 'game_data',
action: input.id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: {
...after,
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { setId, mode: input.id ? 'edit' : 'add' };
}
/** Public for Data Oversight replay; SvelteKit page exports require an underscore prefix. */
export const _saveSkillChipInTransaction = saveSkillChipInTransaction;
@@ -6,7 +6,6 @@ const mocks = vi.hoisted(() => {
events,
talentExists: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
recordIsacAudit: vi.fn(async () => {
events.push('audit');
@@ -16,6 +15,7 @@ const mocks = vi.hoisted(() => {
events.push(sql);
}),
prepare: vi.fn(),
transactionAsync: vi.fn(),
},
};
});
@@ -63,13 +63,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const beforeRecord = {
@@ -77,13 +70,13 @@ const beforeRecord = {
name: 'Pulse Matrix',
specialization_ability_id: 'ability-1',
active: 1,
updated_at: '2026-08-02T11:00:00.000Z',
};
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
const existingTalent = {
id: 'talent-1',
skill_chip_set_id: 'set-1',
name: 'Signal Cascade',
description_template: 'Existing Talent',
};
const twoPiece = {
skill_chip_set_id: 'set-1',
@@ -91,17 +84,6 @@ const twoPiece = {
min_value: 5,
max_value: 10,
};
const talentEffects = [
{
id: 'effect-1',
talent_id: 'talent-1',
stat_attribute_id: 'skill-intensity',
min_value: 1,
max_value: 2,
sort_order: 1,
},
];
const conditional = { id: 'rule-1', source_id: 'talent-1' };
const saveRequest = (confirmRemoval = false) => {
const form = new FormData();
@@ -112,6 +94,8 @@ const saveRequest = (confirmRemoval = false) => {
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
form.set('expectedOwnerUpdatedAt', beforeRecord.updated_at);
form.set('expectedTalentId', 'talent-1');
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
@@ -128,14 +112,31 @@ const runSave = (confirmRemoval = false) =>
describe('Skill Chip Talent removal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.transactionAsync.mockImplementation(
(callback: (transaction: unknown) => Promise<unknown>) => {
const run = vi.fn(async () => {
mocks.events.push('BEGIN IMMEDIATE');
try {
const result = await callback(mocks.database);
mocks.events.push('COMMIT');
return result;
} catch (error) {
mocks.events.push('ROLLBACK');
throw error;
}
});
return Object.assign(run, { immediate: run });
},
);
mocks.events.length = 0;
mocks.talentExists = true;
mocks.auditRecord
.mockResolvedValueOnce(beforeRecord)
.mockResolvedValueOnce(afterRecord);
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('SELECT updated_at FROM skill_chip_sets'))
return { updated_at: beforeRecord.updated_at };
if (sql.includes('FROM specialization_abilities'))
return { id: 'ability-1' };
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
@@ -149,14 +150,8 @@ describe('Skill Chip Talent removal', () => {
if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_talent_effects'))
return talentEffects;
return [];
}),
all: vi.fn(async () => []),
run: vi.fn(async () => {
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM skill_chip_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
@@ -181,7 +176,7 @@ describe('Skill Chip Talent removal', () => {
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes the Talent and mechanic in one audited transaction after confirmation', async () => {
it('removes the Talent while preserving immutable mechanic history', async () => {
const result = await runSave(true);
expect(result).toMatchObject({
@@ -189,13 +184,19 @@ describe('Skill Chip Talent removal', () => {
skillChipMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'BEGIN IMMEDIATE',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.events).not.toContain('delete conditional rule');
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('DELETE FROM mechanic_authored_sources'),
),
).toBe(false);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
@@ -207,8 +208,7 @@ describe('Skill Chip Talent removal', () => {
render: null,
twoPiece,
talent: existingTalent,
talentEffects,
conditional,
mechanic: null,
},
after: {
record: afterRecord,
@@ -216,8 +216,8 @@ describe('Skill Chip Talent removal', () => {
render: null,
twoPiece,
talent: null,
talentEffects: [],
conditional: null,
mechanic: null,
mutationContext: { origin: 'isac' },
},
database: mocks.database,
});
File diff suppressed because it is too large Load Diff
+103 -12
View File
@@ -12,7 +12,9 @@
import GameIcon from '$lib/components/GameIcon.svelte';
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
let { data, form } = $props();
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
@@ -56,11 +58,19 @@
specializationId: string;
name: string;
descriptionTemplate: string;
expectedOwnerUpdatedAt: string;
expectedAttachmentFingerprints: Record<string, string>;
active: number;
};
type RankCombatEffect = {
/** Stable client identity for a newly added attachment before the server allocates its ID. */
editorKey: string;
id: string;
effectId: string;
conditionalRule: ConditionalMechanicDraftV1;
authoredSource: MechanicAuthoredSource;
expectedSourceFingerprint: string | null;
selectionIntent: 'preserve' | 'save' | 'clear';
modelDraft: ConditionalMechanicDraftV1;
};
type RankEditor = {
rank: number;
@@ -74,6 +84,8 @@
focusId: string;
name: string;
descriptionTemplate: string;
expectedOwnerUpdatedAt: string;
expectedAttachmentFingerprints: Record<string, string>;
rowNumber: number | string;
columnNumber: number | string;
maxRank: string;
@@ -93,6 +105,12 @@
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }];
const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }];
const authoredVariantLabels = [{ key: 'base', label: 'BASE' }];
const modelSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '', renderUrl: '' });
const blankFocus = (specializationId = ''): FocusEditor => ({
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
@@ -107,15 +125,25 @@
effects: [], active: 1, iconUrl: '', renderUrl: ''
});
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
id: '', specializationId, name: '', descriptionTemplate: '', expectedOwnerUpdatedAt: '', expectedAttachmentFingerprints: {}, active: 1
});
const blankConditionalRule = (): 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-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
});
const blankRankCombatEffect = (): RankCombatEffect => ({
editorKey: crypto.randomUUID(),
id: '',
effectId: '',
conditionalRule: blankConditionalRule()
authoredSource: {
version: 1,
owner: { type: 'specialization_tree_rank', id: '' },
template: '',
variants: [{ key: 'base', materialized: '', slots: [] }]
},
expectedSourceFingerprint: null,
selectionIntent: 'clear',
modelDraft: blankConditionalRule()
});
const blankRank = (rank: number): RankEditor => ({
rank,
@@ -127,7 +155,7 @@
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
});
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
id: '', focusId, name: '', descriptionTemplate: '', rowNumber,
id: '', focusId, name: '', descriptionTemplate: '', expectedOwnerUpdatedAt: '', expectedAttachmentFingerprints: {}, rowNumber,
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: '', renderUrl: ''
});
const normalizedEffect = (effect: any): StructuredEffect => ({
@@ -278,6 +306,8 @@
specializationId: item.specialization_id,
name: item.name,
descriptionTemplate: item.description_template,
expectedOwnerUpdatedAt: item.updated_at ?? '',
expectedAttachmentFingerprints: { ...(item.expectedAttachmentFingerprints ?? {}) },
active: Number(item.active)
};
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
@@ -288,6 +318,8 @@
focusId: item.focus_id,
name: item.name,
descriptionTemplate: item.description_template,
expectedOwnerUpdatedAt: item.updated_at ?? '',
expectedAttachmentFingerprints: { ...(item.expectedAttachmentFingerprints ?? {}) },
rowNumber: item.row_number,
columnNumber: item.column_number,
maxRank: String(item.max_rank),
@@ -296,8 +328,13 @@
description: rank.description,
effects: (rank.effects ?? []).map(normalizedEffect),
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
editorKey: String(effect.id ?? '') || crypto.randomUUID(),
id: String(effect.id ?? ''),
effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''),
conditionalRule: effect.conditionalRule ?? blankConditionalRule()
authoredSource: effect.authoredSource,
expectedSourceFingerprint: effect.expectedSourceFingerprint ?? null,
selectionIntent: effect.selectionIntent ?? (effect.selectedRevision ? 'preserve' : 'clear'),
modelDraft: effect.modelDraft ?? blankConditionalRule()
})),
costs: { ...(rank.costs ?? {}) }
})),
@@ -321,6 +358,37 @@
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index);
};
const reorderRankCombatEffect = (rank: RankEditor, index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= rank.combatEffects.length) return;
const effects = [...rank.combatEffects];
[effects[index], effects[targetIndex]] = [effects[targetIndex], effects[index]];
rank.combatEffects = effects;
};
const rankCombatEffectEditorKey = (effect: RankCombatEffect) =>
`${nodeEditing?.id || 'new-node'}-${effect.id || effect.editorKey}`;
const setModelIntent = (effect: RankCombatEffect, intent: 'preserve' | 'save' | 'clear') => {
effect.selectionIntent = intent;
if (intent === 'save' && !effect.modelDraft) effect.modelDraft = blankConditionalRule();
};
const selectRankCombatEffect = (effect: RankCombatEffect, effectId: string) => {
effect.effectId = effectId;
const template = String(combatEffectById(effectId)?.description_template ?? '');
const tokens = [...new Set([...template.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]))];
const existing = effect.authoredSource.variants.find((variant) => variant.key === 'base');
const existingSlots = new Map((existing?.slots ?? []).map((slot) => [slot.key, slot]));
const slots = tokens.map((key, order) => ({
...(existingSlots.get(key) ?? { key, value: '', order }),
key,
order
}));
const values = new Map(slots.map((slot) => [slot.key, slot.value]));
effect.authoredSource = {
...effect.authoredSource,
template,
variants: [{ key: 'base', materialized: template.replace(/\{([^{}]+)\}/g, (placeholder, key: string) => values.get(key) || placeholder), slots }]
};
};
const openAbilityConfiguration = () => {
if (!selectedFocus) return;
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
@@ -567,6 +635,8 @@
<div class="editor-title"><div><p class="eyebrow">{combatEffectEditing.id ? 'MODIFY COMBAT EFFECT' : 'NEW COMBAT EFFECT'}</p><h3>{combatEffectEditing.id ? combatEffectEditing.name : 'SHARED EFFECT DEFINITION'}</h3></div><button aria-label="Close combat effect editor" onclick={() => combatEffectEditing = null}>×</button></div>
<PendingForm action="?/saveCombatEffect">
<input type="hidden" name="id" value={combatEffectEditing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={combatEffectEditing.expectedOwnerUpdatedAt}/>
<input type="hidden" name="expectedAttachmentFingerprints" value={JSON.stringify(combatEffectEditing.expectedAttachmentFingerprints)}/>
{#if combatEffectEditing.id}
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
{:else}
@@ -574,9 +644,9 @@
{/if}
<div class="form-grid combat-effect-definition-grid">
<label class="field isac-record-name">NAME<input name="name" bind:value={combatEffectEditing.name} maxlength="100" required placeholder="Relentless"/></label>
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {maxStacks} stacks.'}></textarea></label>
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {max_stacks} stacks.'}></textarea></label>
</div>
<p class="configuration-note">Use {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.</p>
<p class="configuration-note">Use {`{duration}`}, {`{max_stacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.</p>
{#if form?.combatEffectError}<p class="error">{form.combatEffectError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
</PendingForm>
@@ -588,6 +658,8 @@
<div class="editor-title"><div><p class="eyebrow">{nodeEditing.id ? 'MODIFY TREE NODE' : 'NEW TREE NODE'}</p><h3>{nodeEditing.id ? nodeEditing.name : 'UPGRADE DEFINITION'}</h3></div><button aria-label="Close tree node editor" onclick={() => nodeEditing = null}>×</button></div>
<PendingForm action="?/saveNode" enctype="multipart/form-data">
<input type="hidden" name="id" value={nodeEditing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={nodeEditing.expectedOwnerUpdatedAt}/>
<input type="hidden" name="expectedAttachmentFingerprints" value={JSON.stringify(nodeEditing.expectedAttachmentFingerprints)}/>
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
<div class="isac-record-identity specialization-record-identity node-record-identity">
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
@@ -618,21 +690,40 @@
<StructuredEffectEditor bind:effects={rank.effects} attributes={data.attributes} abilities={abilityList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} idPrefix={`node-rank-${rank.rank}`} addLabel={`ADD RANK ${rank.rank} EFFECT`}/>
<div class="rank-combat-effects">
<div class="selection-head"><div><span>CONDITIONAL + STACKING EFFECTS</span><small>BUILDER-CONTROLLABLE COMBAT MODEL</small></div><button type="button" class="secondary" onclick={() => addRankCombatEffect(rank)}>ADD CONDITIONAL EFFECT +</button></div>
{#each rank.combatEffects as combatEffect, combatIndex}
{#each rank.combatEffects as combatEffect, combatIndex (rankCombatEffectEditorKey(combatEffect))}
<section class="rank-combat-effect">
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></header>
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><div class="rank-combat-effect-actions"><button type="button" class="secondary" disabled={combatIndex === 0} onclick={() => reorderRankCombatEffect(rank, combatIndex, -1)} aria-label={`Move effect ${combatIndex + 1} up`}>↑ UP</button><button type="button" class="secondary" disabled={combatIndex === rank.combatEffects.length - 1} onclick={() => reorderRankCombatEffect(rank, combatIndex, 1)} aria-label={`Move effect ${combatIndex + 1} down`}>↓ DOWN</button><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></div></header>
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatEffect.id || combatEffect.editorKey}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId} onvaluechange={(value) => selectRankCombatEffect(combatEffect, value)}/></div>
{#key rankCombatEffectEditorKey(combatEffect)}
<AuthoredDescriptionEditor
variantLabels={authoredVariantLabels}
idPrefix={`specialization-rank-${rank.rank}-authored-${nodeEditing.id || 'new-node'}-${combatEffect.id || combatEffect.editorKey}`}
bind:template={combatEffect.authoredSource.template}
bind:variants={combatEffect.authoredSource.variants}
/>
{/key}
<SegmentedChoice
legend="COMBAT MODEL"
name={`specialization-rank-${rank.rank}-model-${combatEffect.id || combatEffect.editorKey}`}
options={modelSelectionOptions}
value={combatEffect.selectionIntent}
onchange={(values) => setModelIntent(combatEffect, values[0] as 'preserve' | 'save' | 'clear')}
/>
{#if combatEffect.selectionIntent === 'save'}
{#key rankCombatEffectEditorKey(combatEffect)}
<MechanicProgramEditor
attributes={data.attributes}
valueColumns={conditionalValueColumns}
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
idPrefix={`specialization-rank-${rank.rank}-conditional-${nodeEditing.id || 'new-node'}-${combatEffect.id || combatEffect.editorKey}`}
aiContext={{
sourceKind: 'specialization_tree_rank',
sourceName: combatEffectById(combatEffect.effectId)?.name ?? `${nodeEditing?.name ?? 'Tree node'} · Rank ${rank.rank}`,
sourceDescription: rank?.description ?? ''
}}
bind:rule={combatEffect.conditionalRule}
bind:rule={combatEffect.modelDraft}
/>
{/key}
{/if}
</section>
{/each}
</div>
@@ -64,13 +64,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocuses: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const saveRequest = (compatibleAbilityId: string) => {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+202 -645
View File
@@ -6,67 +6,67 @@ import {
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import type { Actions, PageServerLoad } from './$types';
import {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import { loadSelectedMechanicModelRevision } from '$lib/server/mechanic-model-revisions';
import { loadMechanicPublicProjection } from '$lib/server/mechanic-public-projection';
import {
missingTalentDescriptionSlots,
numberedTalentOutcomes,
} from '$lib/calculations/talent-description';
readTalentFormMedia,
saveTalentFromFormInTransaction,
type SaveTalentInTransactionResult,
type TalentValidationError,
} from './talent-save';
type SharedEffect = {
slot: number;
statId?: string;
valueI: number;
valueII?: number;
// SvelteKit permits underscored exports from route modules. Keep the
// transaction seam available to Data Oversight and focused persistence tests.
export { saveTalentInTransaction as _saveTalentInTransaction } from './talent-save';
const displayCategory = (
category: unknown,
scope: unknown,
slot: unknown,
): string => {
if (category === 'Vest') return 'Body Armor';
if (category === 'Weapon' && scope === 'Exotic') return 'Exotic Weapon';
if (category === 'Weapon' && Number(slot) === 1) return 'Weapon Talent 1';
if (category === 'Weapon' && Number(slot) === 2) return 'Weapon Talent 2';
if (category === 'Weapon') return 'Weapon · Slot Unassigned';
return String(category ?? '');
};
type TalentErrorArea =
'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
type TalentValidationError = { area: TalentErrorArea; message: string };
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
async function loadTalentMechanicState(database: Database, talentId: string) {
const authoredSource = await loadMechanicAuthoredSource(
database,
'talent',
talentId,
);
if (!authoredSource)
return { authoredSource: null, selectedRevision: null, projection: [] };
const [selectedRevision, projection] = await Promise.all([
loadSelectedMechanicModelRevision(database, authoredSource.id),
loadMechanicPublicProjection(database, authoredSource.id),
]);
return { authoredSource, selectedRevision, projection };
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
@@ -97,12 +97,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
desc: 'talents.category COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC',
},
tier1: {
asc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='I'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC",
desc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='I'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC",
asc: `COALESCE((SELECT variant.behavior_text FROM mechanic_public_variants variant
JOIN mechanic_authored_sources source ON source.id=variant.source_id
WHERE source.owner_type='talent' AND source.owner_id=talents.id AND variant.variant_key='I'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC`,
desc: `COALESCE((SELECT variant.behavior_text FROM mechanic_public_variants variant
JOIN mechanic_authored_sources source ON source.id=variant.source_id
WHERE source.owner_type='talent' AND source.owner_id=talents.id AND variant.variant_key='I'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC`,
},
tier2: {
asc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='II'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC",
desc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='II'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC",
asc: `COALESCE((SELECT variant.behavior_text FROM mechanic_public_variants variant
JOIN mechanic_authored_sources source ON source.id=variant.source_id
WHERE source.owner_type='talent' AND source.owner_id=talents.id AND variant.variant_key='II'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC`,
desc: `COALESCE((SELECT variant.behavior_text FROM mechanic_public_variants variant
JOIN mechanic_authored_sources source ON source.id=variant.source_id
WHERE source.owner_type='talent' AND source.owner_id=talents.id AND variant.variant_key='II'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC`
},
state: {
asc: 'talents.active ASC,talents.name COLLATE NOCASE ASC',
@@ -145,41 +153,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)
).get(...args);
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
const [
talents,
tiers,
effects,
stats,
templates,
weaponScopes,
weaponSlots,
exoticOwners,
icons,
renders,
] = await Promise.all([
const [talents, stats, weaponScopes, weaponSlots, exoticOwners, icons, renders] =
await Promise.all([
(
await db.prepare(
`SELECT * FROM talents ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
)
).all(...args, pagination.perPage, pagination.offset),
(
await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')
).all(),
(
await db.prepare(
'SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects',
)
).all(),
(
await db.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await db.prepare(
'SELECT talent_id,template FROM talent_description_templates',
)
).all(),
(
await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')
).all(),
@@ -225,9 +210,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
kind: 'talent',
fields: [
'category',
'descriptionTemplate',
'conditionalRule',
'tierIIDocumented',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -258,104 +243,145 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()),
)
.map((item) => ({
.map((item) => {
let authoredSource: {
version: number;
template: string;
variants: Array<{ key: string; materialized: string }>;
} | null = null;
try {
const parsed = JSON.parse(String(item.authoredSource ?? 'null')) as {
version?: unknown;
template?: unknown;
variants?: unknown;
};
if (
typeof parsed.version === 'number' &&
typeof parsed.template === 'string' &&
Array.isArray(parsed.variants)
) {
authoredSource = {
version: parsed.version,
template: parsed.template,
variants: parsed.variants.filter(
(variant): variant is { key: string; materialized: string } =>
typeof variant === 'object' &&
variant !== null &&
typeof variant.key === 'string' &&
typeof variant.materialized === 'string',
),
};
}
} catch {
// Keep malformed pending payloads visible without making the registry fail.
}
const tierIIDocumented =
item.category !== 'Exotic Weapon' ||
authoredSource?.variants.some((variant) => variant.key === 'II') === true;
return {
...item,
iconUrl: '',
renderUrl: '',
cooldown_seconds: item.cooldownSeconds || null,
duration_seconds: item.durationSeconds || null,
max_stacks: item.maxStacks || null,
descriptionTemplate: item.descriptionTemplate,
conditionalRule: item.conditionalRule ?? null,
cooldown_seconds: null,
duration_seconds: null,
max_stacks: null,
authoredSource,
expectedSourceFingerprint:
String(item.expectedSourceFingerprint ?? '').trim() || null,
selectedRevision: null,
publicProjection: null,
mechanicStatus: 'PENDING',
ownerWeaponId: null,
ownerWeaponName: null,
tierIIDocumented:
item.category !== 'Exotic Weapon' ||
String(item.tierIIDocumented ?? '') === '1',
tiers: (item.category === 'Exotic Weapon' &&
String(item.tierIIDocumented ?? '') !== '1'
? ['I']
: ['I', 'II']
).map((tier) => ({
tier,
description: String(
item.descriptionTemplate || 'PENDING TALENT CONFIGURATION',
),
tierIIDocumented,
tiers: (authoredSource?.variants ?? [])
.filter((variant) =>
variant.key === 'I' || (variant.key === 'II' && tierIIDocumented),
)
.map((variant) => ({
tier: variant.key,
description: variant.materialized,
})),
}));
const configuredTalents = await Promise.all(
talents.map(async (talent) => {
const talentTiers = tiers.filter((tier) => tier.talent_id === talent.id);
};
});
const mechanicStates = await Promise.all(
talents.map((talent) => loadTalentMechanicState(db, String(talent.id))),
);
const configuredTalents = talents.map((talent, index) => {
const talentId = String(talent.id);
const state = mechanicStates[index];
const weaponScope = weaponScopes.find(
(scope) => scope.talent_id === talent.id,
(scope) => String(scope.talent_id) === talentId,
)?.scope;
const isExoticTalent =
talent.category === 'Weapon' && weaponScope === 'Exotic';
const weaponSlot = weaponSlots.find(
(slot) => String(slot.talent_id) === talentId,
)?.slot;
const category = displayCategory(talent.category, weaponScope, weaponSlot);
const isExoticTalent = category === 'Exotic Weapon';
const tierIIDocumented =
!isExoticTalent || talentTiers.some((tier) => tier.tier === 'II');
!isExoticTalent ||
(state.authoredSource?.source.owner.type === 'talent' &&
state.authoredSource.source.owner.hasTierII === true);
const projectedVariants = state.projection.length
? state.projection.map((variant) => ({
tier: variant.variantKey,
description: variant.behaviorText,
}))
: (state.authoredSource?.source.variants ?? []).map((variant) => ({
tier: variant.key,
description: variant.materialized,
}));
const mechanicStatus = state.selectedRevision
? state.projection.length > 0 &&
state.projection.every((variant) => variant.status === 'modeled')
? 'MODELED'
: state.selectedRevision.simulationReadiness === 'captured_only' &&
state.selectedRevision.draft.clauses.length === 0
? 'CONFIG REQUIRED'
: 'STALE'
: 'NOT MODELED';
return {
...talent,
category,
iconUrl: (() => {
const icon = icons.find((item) => item.entity_id === talent.id);
const icon = icons.find((item) => String(item.entity_id) === talentId);
return icon
? gameDataIconUrl('talent', String(talent.id), icon.updated_at)
? gameDataIconUrl('talent', talentId, icon.updated_at)
: '';
})(),
renderUrl: (() => {
const render = renders.find(
(item) => item.source_entity_id === talent.id,
(item) => String(item.source_entity_id) === talentId,
);
return render
? managedImageUrl(String(render.id), render.updated_at)
: '';
})(),
category:
talent.category === 'Vest'
? 'Body Armor'
: talent.category === 'Weapon' &&
weaponScopes.find((scope) => scope.talent_id === talent.id)
?.scope === 'Exotic'
? 'Exotic Weapon'
: talent.category === 'Weapon' &&
Number(
weaponSlots.find((slot) => slot.talent_id === talent.id)
?.slot,
) === 1
? 'Weapon Talent 1'
: talent.category === 'Weapon' &&
Number(
weaponSlots.find((slot) => slot.talent_id === talent.id)
?.slot,
) === 2
? 'Weapon Talent 2'
: talent.category === 'Weapon'
? 'Weapon · Slot Unassigned'
: talent.category,
descriptionTemplate:
templates.find((item) => item.talent_id === talent.id)?.template ??
'',
conditionalRule: await loadConditionalRuleDraft(
db,
'talent',
String(talent.id),
tierIIDocumented ? ['I', 'II'] : ['I'],
),
ownerWeaponId:
exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)
?.id ?? null,
ownerWeaponName:
exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)
?.name ?? null,
authoredSource: state.authoredSource
? {
version: state.authoredSource.source.version,
template: state.authoredSource.source.template,
variants: state.authoredSource.source.variants,
}
: null,
expectedSourceFingerprint: state.authoredSource?.fingerprint ?? null,
selectedRevision: state.selectedRevision,
publicProjection: state.projection,
mechanicStatus,
tierIIDocumented,
tiers: talentTiers.map((tier) => ({
...tier,
effects: effects.filter(
(effect) => effect.talent_tier_id === tier.id,
ownerWeaponId:
exoticOwners.find(
(weapon) => String(weapon.exotic_talent_id) === talentId,
)?.id ?? null,
ownerWeaponName:
exoticOwners.find(
(weapon) => String(weapon.exotic_talent_id) === talentId,
)?.name ?? null,
tiers: projectedVariants.filter((variant) =>
variant.tier === 'II' ? tierIIDocumented : variant.tier === 'I',
),
})),
};
}),
);
});
return {
talents: [...pendingTalents, ...configuredTalents],
stats: [...pendingStats, ...stats],
@@ -377,53 +403,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
};
const displayValue = (value: number, unit: unknown) => {
const normalizedUnit = unit === 'flat' ? '' : String(unit ?? '');
return normalizedUnit === 'Shots'
? `${value} ${normalizedUnit}`
: `${value}${normalizedUnit}`;
};
const optionalPositiveNumber = (form: FormData, name: string) => {
const raw = String(form.get(name) ?? '').trim();
return raw ? Number(raw) : null;
};
const renderMechanicValue = (
value: number,
singular: string,
plural = `${singular}s`,
) => `${value} ${value === 1 ? singular : plural}`;
const renderDescription = (
template: string,
effects: SharedEffect[],
units: Map<string, unknown>,
tier: 'I' | 'II',
durationSeconds: number | null,
maxStacks: number | null,
) =>
template.replace(
/\{(\d+|duration|max_stacks)\}/g,
(placeholder, expression) => {
if (expression === 'duration')
return durationSeconds === null
? placeholder
: renderMechanicValue(durationSeconds, 'second');
if (expression === 'max_stacks')
return maxStacks === null
? placeholder
: renderMechanicValue(maxStacks, 'stack');
const effect = effects.find((item) => item.slot === Number(expression));
return effect
? displayValue(
tier === 'I' ? effect.valueI : Number(effect.valueII),
effect.statId ? units.get(effect.statId) : undefined,
)
: placeholder;
},
);
export const actions: Actions = {
saveTalent: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -431,8 +410,7 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const talentMode = id ? 'edit' : 'add';
const talentMode = String(form.get('id') ?? '').trim() ? 'edit' : 'add';
const reject = (status: number, errors: TalentValidationError[]) =>
fail(status, {
talentError: errors[0]?.message ?? 'The talent could not be saved.',
@@ -440,472 +418,51 @@ export const actions: Actions = {
talentMode,
resultToken: randomUUID(),
});
let iconMutation;
let media: Awaited<ReturnType<typeof readTalentFormMedia>>;
try {
iconMutation = await readIconMutation(form);
media = await readTalentFormMedia(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
if (
error instanceof GameDataIconValidationError ||
error instanceof ManagedImageValidationError
)
return reject(400, [{ area: 'icon', message: error.message }]);
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
const database = await getDatabase();
const journal = new MediaMutationJournal();
let result: SaveTalentInTransactionResult;
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
const save = database.transactionAsync(async (transaction) =>
saveTalentFromFormInTransaction(transaction as unknown as Database, {
form,
operatorId: operator.id,
proposedEntityId: String(form.get('_proposedEntityId') ?? '').trim() || undefined,
...media,
journal,
}),
);
result = await save.immediate();
} catch (error) {
if (error instanceof ManagedImageValidationError)
return reject(400, [{ area: 'icon', message: error.message }]);
throw error;
}
const name = String(form.get('name') ?? '').trim(),
category = String(form.get('category') ?? '');
const isExotic = category === 'Exotic Weapon';
const tierIIDocumented =
!isExotic || String(form.get('tierIIDocumented') ?? '') === '1';
const descriptionTemplate = String(
form.get('descriptionTemplate') ?? '',
).trim();
const db = await getDatabase();
const conditionalRuleRaw = form.get('conditionalRule');
let conditionalRule;
try {
const activeStats = await (
await db.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
tierIIDocumented
? [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' },
]
: [{ key: 'tierI', variantKey: 'I' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
} catch (error) {
return reject(400, [
{
area: 'mechanic',
message:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
},
]);
}
const cooldownSeconds =
conditionalRule.clauses.find((clause) => clause.cooldownSeconds)
?.cooldownSeconds ?? null;
const durationForTier = (tier: 'I' | 'II') => {
const pool = conditionalRule.pools.find(
(item) =>
item.expiresAfterSeconds !== undefined ||
item.expiresAfterSecondsVariants?.[tier] !== undefined,
);
return (
pool?.expiresAfterSecondsVariants?.[tier] ??
pool?.expiresAfterSeconds ??
null
);
};
const durationSeconds = durationForTier('I');
const stackPools = conditionalRule.pools.filter(
(pool) => pool.stateKind === 'stack',
);
const maxStacks = stackPools.length
? Math.max(...stackPools.map((pool) => pool.max ?? 1))
: null;
const descriptionOutcomes = numberedTalentOutcomes(conditionalRule);
const sharedEffects: SharedEffect[] = descriptionOutcomes.map((effect) => ({
slot: Number(effect.descriptionSlot),
...(effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value,
...(tierIIDocumented ? { valueII: effect.variants.II.value } : {}),
}));
const validationErrors: TalentValidationError[] = [];
const requiresCompleteMechanic =
isExotic || conditionalRule.simulationReadiness !== 'captured_only';
if (
name.length < 2 ||
![
'Weapon Talent 1',
'Weapon Talent 2',
'Exotic Weapon',
'Backpack',
'Body Armor',
].includes(category)
) {
validationErrors.push({
area: 'identity',
message: 'Provide a valid talent name and category.',
});
}
if (!descriptionTemplate)
validationErrors.push({
area: 'description',
message: 'Enter a shared description template.',
});
if (requiresCompleteMechanic && !conditionalRule.clauses.length) {
validationErrors.push({
area: 'mechanic',
message: isExotic
? 'Exotic talents require at least one modeled Tier I mechanic branch.'
: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.',
});
}
if (requiresCompleteMechanic && !sharedEffects.length) {
validationErrors.push({
area: 'outcomes',
message: isExotic
? 'Exotic talents require at least one numbered Tier I value outcome.'
: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.',
});
}
if (
sharedEffects.some(
(effect) =>
!Number.isFinite(effect.valueI) ||
(tierIIDocumented && !Number.isFinite(effect.valueII)),
)
) {
validationErrors.push({
area: 'outcomes',
message: tierIIDocumented
? 'Every numbered outcome needs valid Tier I and Tier II values.'
: 'Every numbered outcome needs a valid Tier I value.',
});
}
const missingSlots = missingTalentDescriptionSlots(
descriptionTemplate,
descriptionOutcomes,
);
if (requiresCompleteMechanic && missingSlots.length) {
validationErrors.push({
area: 'description',
message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.`,
});
validationErrors.push({
area: 'outcomes',
message:
'Add or renumber outcomes to match every numbered description placeholder.',
});
}
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{duration}') &&
(durationForTier('I') === null ||
(tierIIDocumented && durationForTier('II') === null))
) {
validationErrors.push({
area: 'description',
message: 'The description uses {duration}, but no duration is defined.',
});
validationErrors.push({
area: 'mechanic',
message:
'Define a duration for the {duration} description placeholder.',
});
}
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{max_stacks}') &&
maxStacks === null
) {
validationErrors.push({
area: 'description',
message:
'The description uses {max_stacks}, but no maximum stack count is defined.',
});
validationErrors.push({
area: 'mechanic',
message:
'Define a pool maximum for the {max_stacks} description placeholder.',
});
}
const ownerWeapon = id
? await (
await db.prepare(
'SELECT id,name FROM weapons WHERE exotic_talent_id=?',
)
).get(id)
: null;
if (ownerWeapon && !isExotic) {
validationErrors.push({
area: 'identity',
message: `This talent is owned by ${String(ownerWeapon.name)} and must remain an Exotic Weapon talent.`,
});
}
if (validationErrors.length) return reject(400, validationErrors);
const storedCategory =
category === 'Body Armor'
? 'Vest'
: category.startsWith('Weapon Talent') || isExotic
? 'Weapon'
: category;
if (
await (
await db.prepare(
'SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?',
)
).get(name, storedCategory, id || 'new')
) {
await journal.rollback(error);
if (error instanceof MechanicSourceFingerprintConflictError)
return reject(409, [
{
area: 'identity',
message: 'That talent already exists in this category.',
area: 'description',
message: error.message,
},
]);
}
const statIds = conditionalRule.clauses
.flatMap((clause) => clause.outcomes)
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const statRows = statIds.length
? await (
await db.prepare(
`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).all(...statIds)
: [];
if (statRows.length !== new Set(statIds).size) {
return reject(400, [
{
area: 'outcomes',
message: 'One or more selected attributes are unavailable.',
},
]);
}
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
now = new Date().toISOString();
const before = id
? {
record: await auditRecord('talents', id),
icon: await gameDataIconMetadata(db, 'talent', id),
template: await (
await db.prepare(
'SELECT template FROM talent_description_templates WHERE talent_id=?',
)
).get(id),
tiers: await (
await db.prepare(
'SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier',
)
).all(id),
weaponScope: await (
await db.prepare(
'SELECT scope FROM talent_weapon_scopes WHERE talent_id=?',
)
).get(id),
weaponSlot: await (
await db.prepare(
'SELECT slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(id),
ownerWeapon,
conditional: await conditionalRuleSnapshot(db, 'talent', id),
render: renderAuditMetadata(
await (
await db.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='talent_render' AND source_entity_id=?",
)
).get(id),
),
}
: null;
await db.exec('BEGIN');
try {
if (id)
await (
await db.prepare(
'UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?',
)
).run(
name,
storedCategory,
cooldownSeconds,
durationSeconds,
maxStacks,
now,
id,
);
else
await (
await db.prepare(
'INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
talentId,
name,
storedCategory,
cooldownSeconds,
durationSeconds,
maxStacks,
now,
now,
);
if (storedCategory === 'Weapon') {
await (
await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)
).run(talentId, isExotic ? 'Exotic' : 'Standard');
if (isExotic) {
await (
await db.prepare(
'DELETE FROM weapon_talent_slots WHERE talent_id=?',
)
).run(talentId);
} else {
await (
await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)
).run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
}
} else {
await (
await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')
).run(talentId);
await (
await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')
).run(talentId);
}
await (
await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)
).run(talentId, descriptionTemplate, now);
if (!tierIIDocumented) {
await (
await db.prepare(
"DELETE FROM talent_tiers WHERE talent_id=? AND tier='II'",
)
).run(talentId);
}
for (const tier of (tierIIDocumented ? ['I', 'II'] : ['I']) as Array<
'I' | 'II'
>) {
const description = renderDescription(
descriptionTemplate,
sharedEffects,
units,
tier,
durationForTier(tier),
maxStacks,
);
await (
await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)
).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (
await db.prepare(
'SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?',
)
).get(talentId, tier);
const tierId = String(tierRow?.id);
await (
await db.prepare(
'DELETE FROM talent_tier_effects WHERE talent_tier_id=?',
)
).run(tierId);
const storedLegacyAttributes = new Set<string>();
for (const outcome of descriptionOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
// Conditional mechanics can apply the same attribute in several
// mutually exclusive branches. The legacy projection has one row
// per attribute and is not the runtime source for modeled talents.
if (storedLegacyAttributes.has(outcome.attributeId)) continue;
storedLegacyAttributes.add(outcome.attributeId);
await (
await db.prepare(
'INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
tierId,
outcome.attributeId,
tier === 'I' ? outcome.variants.I.value : outcome.variants.II.value,
now,
);
}
}
await saveConditionalRuleInTransaction(
db,
'talent',
talentId,
conditionalRule,
);
await applyIconMutation(
db,
'talent',
talentId,
iconMutation,
operator.id,
);
const render = renderAuditMetadata(
await applyGameDataRender(
db,
'talent',
talentId,
name,
renderMutation,
operator.id,
),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'talent.updated' : 'talent.created',
entityId: talentId,
before,
after: {
record: await auditRecord('talents', talentId),
icon: await gameDataIconMetadata(db, 'talent', talentId),
category,
tierIIDocumented,
weaponScope: await (
await db.prepare(
'SELECT scope FROM talent_weapon_scopes WHERE talent_id=?',
)
).get(talentId),
weaponSlot: await (
await db.prepare(
'SELECT slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(talentId),
ownerWeapon: await (
await db.prepare(
'SELECT id,name FROM weapons WHERE exotic_talent_id=?',
)
).get(talentId),
tiers: await (
await db.prepare(
'SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier',
)
).all(talentId),
descriptionTemplate,
conditional: await conditionalRuleSnapshot(db, 'talent', talentId),
render,
},
});
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
if (!result.ok) {
await journal.rollback();
return reject(result.status, result.errors);
}
journal.clear();
return {
talentSaved: true,
talentMode: id ? 'edit' : 'add',
talentMode: result.talentMode,
resultToken: randomUUID(),
};
},
+66 -64
View File
@@ -8,15 +8,23 @@
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
import { tick } from 'svelte';
let { data, form } = $props();
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const tierIIDocumentationOptions = [
{ value: 'documented', label: 'DOCUMENTED' },
{ value: 'not_documented', label: 'NOT YET DOCUMENTED' }
];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
let talent = $state<any>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
@@ -38,7 +46,7 @@
const areaLabel = (area: TalentErrorArea) => ({
icon: 'Talent icon',
identity: 'Talent identity',
description: 'Description template',
description: 'Authored description',
mechanic: 'Mechanic program',
outcomes: 'Mechanic outcomes'
}[area]);
@@ -51,25 +59,46 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const blankVariants = (hasTierII = true): MechanicAuthoredVariant[] => [
{ key: 'I', materialized: '', slots: [] },
...(hasTierII ? [{ key: 'II', materialized: '', slots: [] }] : [])
];
const variantLabels = (hasTierII: boolean) => [
{ key: 'I', label: 'TIER I' },
...(hasTierII ? [{ key: 'II', label: 'TIER II' }] : [])
];
const reconcileTierVariants = (hasTierII: boolean) => {
if (!talent) return;
const existing = new Map((talent.authoredVariants ?? []).map((variant: MechanicAuthoredVariant) => [variant.key, variant]));
talent.authoredVariants = variantLabels(hasTierII).map((variant) => existing.get(variant.key) ?? { key: variant.key, materialized: talent.authoredTemplate ?? '', slots: [] });
};
const blankTalent = () => ({
id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1',
authoredTemplate: '', authoredVariants: blankVariants(), expectedOwnerUpdatedAt: '', expectedSourceFingerprint: '',
tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(),
mechanicSelectionIntent: 'clear', modelingOpen: false
});
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II');
const authored = row.authoredSource;
const hasTierII = row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented);
talent = {
...row,
descriptionTemplate: row.descriptionTemplate || one.description,
tierIDescription: one.description,
tierIIDescription: two.description,
tierIIDocumented: row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented),
tierIIDocumentation: row.category === 'Exotic Weapon' && !row.tierIIDocumented ? 'not_documented' : 'documented',
conditionalRule: row.conditionalRule ?? blankRule(),
modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
authoredTemplate: authored?.template ?? '',
authoredVariants: authored?.variants ?? blankVariants(hasTierII),
expectedOwnerUpdatedAt: row.updated_at ?? '',
expectedSourceFingerprint: row.expectedSourceFingerprint ?? '',
tierIIDocumented: hasTierII,
tierIIDocumentation: hasTierII ? 'documented' : 'not_documented',
conditionalRule: row.selectedRevision?.draft ?? blankRule(),
mechanicSelectionIntent: 'preserve',
modelingOpen: Boolean(row.selectedRevision?.draft?.clauses?.length)
};
};
const setTierIIDocumented = (documented: boolean) => {
if (!talent) return;
talent.tierIIDocumented = documented;
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
reconcileTierVariants(documented);
talent.conditionalRule = {
...talent.conditionalRule,
pools: talent.conditionalRule.pools.map((pool: any) => {
@@ -87,11 +116,7 @@
outcomes: clause.outcomes.map((outcome: any) => ({
...outcome,
variants: documented
? {
...outcome.variants,
I: outcome.variants.I,
II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 })
}
? { ...outcome.variants, I: outcome.variants.I, II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 }) }
: { I: outcome.variants.I }
}))
}))
@@ -100,40 +125,13 @@
const setCategory = (category: string) => {
if (!talent) return;
talent.category = category;
if (category !== 'Exotic Weapon' && !talent.tierIIDocumented) setTierIIDocumented(true);
if (category !== 'Exotic Weapon') setTierIIDocumented(true);
};
const valueColumns = () => talent?.category === 'Exotic Weapon' && !talent.tierIIDocumented
? [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }]
: [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }, { key: 'tierII', variantKey: 'II', label: 'TIER II' }];
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description;
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
};
const displayValue = (effect: any, tier: 'I' | 'II') => {
const stat = data.stats.find((item) => item.id === effect.attributeId);
const value = effect.variants?.[tier]?.value ?? '';
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`;
const preview = (tier: 'I' | 'II') => {
if (tier === 'II' && talent.category === 'Exotic Weapon' && !talent.tierIIDocumented) return 'NOT YET DOCUMENTED';
return talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const durationPool = talent.conditionalRule.pools.find((pool: any) =>
pool.expiresAfterSeconds !== undefined || pool.expiresAfterSecondsVariants?.[tier] !== undefined);
const duration = durationPool?.expiresAfterSecondsVariants?.[tier] ?? durationPool?.expiresAfterSeconds;
const stackPools = talent.conditionalRule.pools.filter((pool: any) => pool.stateKind === 'stack');
const maxStacks = stackPools.length ? Math.max(...stackPools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes)
.find((outcome: any) => outcome.descriptionSlot === Number(expression));
return effect ? displayValue(effect, tier) : placeholder;
});
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
@@ -164,16 +162,18 @@
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
<input type="hidden" name="id" value={talent.id}/>
<input type="hidden" name="authoredSource" value={JSON.stringify({version: 1, template: talent.authoredTemplate, variants: talent.authoredVariants})}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={talent.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={talent.expectedSourceFingerprint ?? ''}/>
<input type="hidden" name="tierIIDocumented" value={talent.tierIIDocumented ? '1' : '0'}/>
{#if talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
{/if}
{#if talentErrors.length}
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
<ul>
{#each talentErrors as error}
<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>
{/each}
</ul>
<ul>{#each talentErrors as error}<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>{/each}</ul>
</div>
{/if}
<div class="talent-icon-identity">
@@ -191,26 +191,31 @@
{#if talent.category === 'Exotic Weapon'}
<div class="form-grid">
<SegmentedChoice legend="TIER II DOCUMENTATION" name="tierIIDocumentation" options={tierIIDocumentationOptions} bind:value={talent.tierIIDocumentation} onchange={(values) => setTierIIDocumented(values[0] === 'documented')} required/>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II values will be modeled and saved.' : 'Tier I is required. Tier II will remain absent until its game values are documented.'}</p>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II authored values are saved.' : 'Tier I is required. Tier II remains absent until its game values are documented.'}</p>
</div>
{/if}
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<div id="talent-description" class:validation-area-error={hasErrors(['description'])}>
<AuthoredDescriptionEditor
bind:template={talent.authoredTemplate}
bind:variants={talent.authoredVariants}
variantLabels={variantLabels(talent.tierIIDocumented)}
idPrefix={`talent-${talent.id || 'new'}`}
/>
{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}
</div>
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
<summary>
<span><b>COMBAT MODELING</b><small>{talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY · MODEL LATER' : `${talent.conditionalRule.clauses.length} CONFIGURED ${talent.conditionalRule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}`}</small></span>
<strong>{talent.modelingOpen ? 'COLLAPSE' : 'EXPAND'}</strong>
</summary>
<div class="modeling-content">
<SegmentedChoice legend="COMBAT MODEL SELECTION" name="mechanicSelectionIntent" options={mechanicSelectionOptions} bind:value={talent.mechanicSelectionIntent} onchange={(values) => talent.mechanicSelectionIntent = values[0]} required/>
<p class="console-note">{talent.mechanicSelectionIntent === 'preserve' ? 'Keep the selected immutable revision, if one exists.' : talent.mechanicSelectionIntent === 'clear' ? 'Remove the selected revision. Revision history remains available.' : 'Append and select a new immutable revision from the draft below.'}</p>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={valueColumns()}
valueColumns={variantLabels(talent.tierIIDocumented).map((variant) => ({key: variant.key === 'I' ? 'tierI' : 'tierII', variantKey: variant.key, label: variant.label}))}
idPrefix={`talent-${talent.id || 'new'}`}
aiContext={{
sourceKind: 'talent',
sourceName: talent?.name ?? '',
sourceDescription: talent?.descriptionTemplate ?? ''
}}
aiContext={{sourceKind: 'talent', sourceName: talent.name ?? '', sourceDescription: talent.authoredTemplate ?? ''}}
bind:rule={talent.conditionalRule}
/>
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
@@ -222,9 +227,6 @@
{/if}
{/snippet}
{#if talent && !talent.id}
{@render talentEditor()}
{/if}
{#if talent && !talent.id}{@render talentEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.mechanicStatus}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
@@ -0,0 +1,636 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import {
saveTalentFromFormInTransaction,
saveTalentInTransaction,
} from './talent-save';
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(async () => null),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({
file: null,
remove: false,
})),
}));
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 = (secondTier = '8') => ({
version: 1,
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: 'damage' },
{ key: 'duration', value: '5', order: 2 },
],
},
{
key: 'II',
materialized: `After 8 hits, gain ${secondTier}% weapon damage for 7 seconds.`,
slots: [
{ key: '1', value: '8', order: 0 },
{
key: '2',
value: secondTier,
order: 1,
unit: '%',
attributeId: 'damage',
},
{ key: 'duration', value: '7', order: 2 },
],
},
],
});
const draft = (value = 15) => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'weapon-hit',
label: 'Weapon hit',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'damage',
label: 'Damage',
phase: 'after_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
basis: 'flat',
attributeId: 'damage',
variants: { I: { value }, II: { value: value + 5 } },
},
],
},
],
});
const formFor = ({
id = '',
authored = source(),
expected = '',
expectedOwnerUpdatedAt = '',
intent = 'preserve',
model = draft(),
category = 'Weapon Talent 1',
tierIIDocumented = true,
}: {
id?: string;
authored?: ReturnType<typeof source>;
expected?: string;
expectedOwnerUpdatedAt?: string;
intent?: 'preserve' | 'save' | 'clear';
model?: ReturnType<typeof draft>;
category?: string;
tierIIDocumented?: boolean;
} = {}) => {
const form = new FormData();
form.set('id', id);
form.set('name', 'Escalation Loop');
form.set('category', category);
form.set('tierIIDocumented', tierIIDocumented ? '1' : '0');
form.set('authoredSource', JSON.stringify(authored));
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedSourceFingerprint', expected);
form.set('mechanicSelectionIntent', intent);
if (intent === 'save') form.set('conditionalRule', JSON.stringify(model));
return form;
};
const media = () => ({
iconMutation: { output: null, remove: false },
renderMutation: { file: null, remove: false },
});
const save = async (form: FormData) => {
const operation = database.transactionAsync(async (transaction) =>
saveTalentFromFormInTransaction(transaction as unknown as Database, {
form,
operatorId: 'actor-1',
...media(),
}),
);
return operation.immediate();
};
const count = async (table: string, where = '') =>
Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM ${table} ${where}`,
)
).get()
)?.count ?? 0,
);
const sourceFingerprint = async (id = 'talent-1') =>
String(
(
await (
await database.prepare(
'SELECT source_fingerprint FROM mechanic_authored_sources WHERE owner_id=?',
)
).get(id)
)?.source_fingerprint,
);
const ownerUpdatedAt = async (id: string) =>
String(
(
await (
await database.prepare('SELECT updated_at FROM talents WHERE id=?')
).get(id)
)?.updated_at,
);
describe('Talent Phase 2 owner action', () => {
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('damage','Weapon Damage','Offensive','Weapon','Additive','%','now','now')`);
});
afterEach(async () => {
await database.close();
});
it('saves complete authored prose without a model and audits source evidence', async () => {
const result = await save(formFor({ id: '' }));
expect(result).toEqual({
ok: true,
talentId: expect.any(String),
talentMode: 'add',
});
expect(await count('mechanic_authored_sources')).toBe(1);
expect(
await (
await database.prepare(
'SELECT owner_id,owner_type,talent_scope,talent_has_tier_ii FROM mechanic_authored_sources',
)
).get(),
).toMatchObject({
owner_id: (result as { talentId: string }).talentId,
owner_type: 'talent',
talent_scope: 'standard',
talent_has_tier_ii: 1,
});
expect(await count('mechanic_public_variants')).toBe(2);
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('talent_description_templates')).toBe(0);
expect(await count('talent_tiers')).toBe(0);
expect(await count('talent_tier_effects')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
const id = (result as { talentId: string }).talentId;
const audit = await (
await database.prepare(
'SELECT actor_id,entity_type,entity_id,action,after_json,created_at FROM audit_log WHERE entity_id=?',
)
).get(id);
expect(audit).toMatchObject({
actor_id: 'actor-1',
entity_type: 'game_data',
entity_id: id,
action: 'talent.created',
created_at: expect.any(String),
});
const after = JSON.parse(String(audit?.after_json));
expect(after).toMatchObject({
authoredSource: { template: source().template },
selectedRevision: null,
readiness: null,
scenarioInputs: [],
graph: null,
publicProjection: { variants: [{ key: 'I' }, { key: 'II' }] },
relationships: {
weaponScope: { scope: 'Standard' },
weaponSlot: { slot: 1 },
ownerWeapon: null,
gearTalents: [],
weaponTalentOptions: [],
},
ownerState: {
authoredSource: { owner: { type: 'talent', scope: 'standard' } },
selectedRevision: null,
},
});
});
it('accepts prose-only Exotic Talents without a Tier II variant', async () => {
const authored = source();
authored.variants = [authored.variants[0]];
const result = await save(
formFor({
authored,
category: 'Exotic Weapon',
tierIIDocumented: false,
intent: 'clear',
}),
);
expect(result).toMatchObject({ ok: true, talentMode: 'add' });
const id = (result as { talentId: string }).talentId;
expect(
await (
await database.prepare(
'SELECT talent_scope,talent_has_tier_ii FROM mechanic_authored_sources WHERE owner_id=?',
)
).get(id),
).toEqual({ talent_scope: 'exotic', talent_has_tier_ii: 0 });
expect(
await (
await database.prepare(
'SELECT variant_key FROM mechanic_authored_variants WHERE source_id=(SELECT id FROM mechanic_authored_sources WHERE owner_id=?)',
)
).all(id),
).toEqual([{ variant_key: 'I' }]);
expect(await count('mechanic_model_revisions')).toBe(0);
});
it('rejects authored Attribute metadata that is not active before writing', async () => {
const authored = source();
authored.variants[0].slots[1].attributeId = 'retired-stat';
const result = await save(formFor({ authored }));
expect(result).toMatchObject({
ok: false,
status: 400,
errors: [
{ area: 'outcomes', message: expect.stringMatching(/unavailable/) },
],
});
expect(await count('talents')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(0);
expect(await count('audit_log')).toBe(0);
});
it('uses the replay callable with a server-owned Talent ID', async () => {
await database.exec('BEGIN IMMEDIATE');
const result = await saveTalentInTransaction(database, {
id: '',
proposedEntityId: 'proposal-talent-1',
name: 'Callable Talent',
category: 'Weapon Talent 2',
authoredSource: {
...source(),
version: 1 as const,
owner: { type: 'talent', id: 'client-supplied', scope: 'exotic' },
},
expectedSourceFingerprint: null,
selection: { intent: 'preserve' },
tierIIDocumented: true,
...media(),
actorId: 'actor-1',
});
await database.exec('COMMIT');
expect(result).toEqual({ talentId: 'proposal-talent-1', mode: 'add' });
expect(result.talentId).not.toBe('client-supplied');
expect(
await (
await database.prepare(
'SELECT owner_id,talent_scope FROM mechanic_authored_sources',
)
).get(),
).toMatchObject({ owner_id: result.talentId, talent_scope: 'standard' });
});
it('appends immutable revisions, skips an identical retry, preserves stale state, and clears without deleting history', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
let fingerprint = await sourceFingerprint(id);
let expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const appended = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'save',
}),
);
expect(appended).toMatchObject({ ok: true, talentMode: 'edit' });
expect(await count('mechanic_model_revisions')).toBe(1);
expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const retry = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'save',
}),
);
expect(retry).toMatchObject({ ok: true });
expect(await count('mechanic_model_revisions')).toBe(1);
expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const changed = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
authored: source('25'),
intent: 'preserve',
}),
);
expect(changed).toMatchObject({ ok: true });
fingerprint = await sourceFingerprint(id);
expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
expect(await count('mechanic_model_revisions')).toBe(1);
expect(
await (
await database.prepare(
'SELECT status,display_source FROM mechanic_public_variants WHERE source_id=(SELECT id FROM mechanic_authored_sources WHERE owner_id=?)',
)
).all(id),
).toMatchObject([
{ status: 'not_modeled', display_source: 'authored' },
{ status: 'not_modeled', display_source: 'authored' },
]);
const cleared = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'clear',
}),
);
expect(cleared).toMatchObject({ ok: true });
expect(await count('mechanic_model_revisions')).toBe(1);
expect(await count('mechanic_model_selections')).toBe(0);
});
it('keeps category relationships valid instead of leaving stale assignments', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const fingerprint = await sourceFingerprint(id);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
await database.exec(`INSERT INTO gear(
id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at
) VALUES('gear-1','Vest','Body Armor',0,1,1,1,1,'now','now')`);
await (
await database.prepare(
'INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)',
)
).run('gear-1', id);
const result = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
category: 'Backpack',
}),
);
expect(result).toMatchObject({
ok: false,
status: 400,
errors: [{ area: 'identity', message: expect.stringMatching(/gear/) }],
});
expect(
await (
await database.prepare('SELECT category FROM talents WHERE id=?')
).get(id),
).toEqual({ category: 'Weapon' });
expect(
await (
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
).toEqual([{ gear_id: 'gear-1', talent_id: id }]);
});
it('rejects a stale fingerprint before writing anything', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const beforeName = await (
await database.prepare('SELECT name FROM talents WHERE id=?')
).get(id);
const auditsBefore = await count('audit_log');
const result = await save(
formFor({
id,
expected: 'f'.repeat(64),
expectedOwnerUpdatedAt,
authored: source('30'),
intent: 'clear',
}),
);
expect(result).toMatchObject({
ok: false,
status: 409,
errors: [{ area: 'description' }],
});
expect(await count('audit_log')).toBe(auditsBefore);
expect(await count('mechanic_model_selections')).toBe(0);
expect(
await (
await database.prepare('SELECT name FROM talents WHERE id=?')
).get(id),
).toEqual(beforeName);
expect(await sourceFingerprint(id)).not.toBe('f'.repeat(64));
});
it('rejects a stale aggregate after an owner scalar mutation without writing', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const fingerprint = await sourceFingerprint(id);
await (
await database.prepare(
'UPDATE talents SET name=?,updated_at=? WHERE id=?',
)
).run('Concurrent Talent Edit', 'owner-scalar-version', id);
const ownerBefore = await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id);
const auditsBefore = await count('audit_log');
const stale = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
authored: source('30'),
intent: 'clear',
}),
);
expect(stale).toMatchObject({
ok: false,
status: 409,
errors: [
{ area: 'identity', message: expect.stringMatching(/changed after/) },
],
});
expect(
await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id),
).toEqual(ownerBefore);
expect(await sourceFingerprint(id)).toBe(fingerprint);
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('audit_log')).toBe(auditsBefore);
});
it('rejects a stale aggregate after a relationship mutation without writing', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const fingerprint = await sourceFingerprint(id);
await (
await database.prepare(
'UPDATE weapon_talent_slots SET slot=? WHERE talent_id=?',
)
).run(2, id);
await (
await database.prepare('UPDATE talents SET updated_at=? WHERE id=?')
).run('owner-relationship-version', id);
const ownerBefore = await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id);
const relationshipBefore = await (
await database.prepare(
'SELECT talent_id,slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(id);
const auditsBefore = await count('audit_log');
const stale = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
category: 'Weapon Talent 1',
authored: source('30'),
intent: 'clear',
}),
);
expect(stale).toMatchObject({
ok: false,
status: 409,
errors: [
{ area: 'identity', message: expect.stringMatching(/changed after/) },
],
});
expect(
await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id),
).toEqual(ownerBefore);
expect(
await (
await database.prepare(
'SELECT talent_id,slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(id),
).toEqual(relationshipBefore);
expect(await sourceFingerprint(id)).toBe(fingerprint);
expect(await count('audit_log')).toBe(auditsBefore);
});
it('rejects a stale aggregate after a revision mutation without writing', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const fingerprint = await sourceFingerprint(id);
const appended = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'save',
}),
);
expect(appended).toMatchObject({ ok: true, talentMode: 'edit' });
const ownerBefore = await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id);
const revisionsBefore = await count('mechanic_model_revisions');
const auditsBefore = await count('audit_log');
const stale = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
authored: source('30'),
intent: 'clear',
}),
);
expect(stale).toMatchObject({
ok: false,
status: 409,
errors: [
{ area: 'identity', message: expect.stringMatching(/changed after/) },
],
});
expect(
await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id),
).toEqual(ownerBefore);
expect(await count('mechanic_model_revisions')).toBe(revisionsBefore);
expect(await count('audit_log')).toBe(auditsBefore);
expect(await sourceFingerprint(id)).toBe(fingerprint);
});
});
+559
View File
@@ -0,0 +1,559 @@
import { randomUUID } from 'node:crypto';
import type { Database } from '@tursodatabase/database';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconMetadata,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
mechanicAuthoredSourceSnapshot,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
mechanicOwnerStateSnapshot,
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import type { MediaMutationJournal } from '$lib/server/media-journal';
/** Reads and processes the canonical Talent media fields for direct saves/replay. */
export async function readTalentFormMedia(form: FormData) {
const iconMutation = await readIconMutation(form);
const renderMutation = await readGameDataRenderMutation(form);
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
return { iconMutation, renderMutation };
}
type TalentErrorArea =
| 'icon'
| 'identity'
| 'description'
| 'mechanic'
| 'outcomes';
export type TalentValidationError = {
area: TalentErrorArea;
message: string;
};
type TalentCategory =
| 'Weapon Talent 1'
| 'Weapon Talent 2'
| 'Exotic Weapon'
| 'Backpack'
| 'Body Armor';
const talentCategories: readonly TalentCategory[] = [
'Weapon Talent 1',
'Weapon Talent 2',
'Exotic Weapon',
'Backpack',
'Body Armor',
];
type TalentMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
type TalentFormInput = {
form: FormData;
operatorId: string;
mutationContext?: TalentMutationContext;
journal?: MediaMutationJournal;
proposedEntityId?: string;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
type SaveTalentFailure = {
ok: false;
status: number;
errors: TalentValidationError[];
};
type SaveTalentSuccess = {
ok: true;
talentId: string;
talentMode: 'add' | 'edit';
};
export type SaveTalentInTransactionResult =
| SaveTalentFailure
| SaveTalentSuccess;
export type TalentSaveInput = {
id: string;
/** Server-generated draft ID used when replaying a new proposal. */
proposedEntityId?: string;
name: string;
category: string;
authoredSource: MechanicAuthoredSource;
expectedOwnerUpdatedAt?: string | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
tierIIDocumented?: boolean;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
actorId: string;
mutationContext?: TalentMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class TalentNotFoundError extends Error {
constructor() {
super('Talent not found.');
this.name = 'TalentNotFoundError';
}
}
const failure = (
status: number,
errors: TalentValidationError[],
): SaveTalentFailure => ({ ok: false, status, errors });
const renderAuditMetadata = (render: Record<string, unknown> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
async function loadTalentMechanicState(database: Database, talentId: string) {
const authoredSource = await loadMechanicAuthoredSource(database, 'talent', talentId);
if (!authoredSource)
return { authoredSource: null, selectedRevision: null, projection: [] };
const [selectedRevision, projection] = await Promise.all([
loadSelectedMechanicModelRevision(database, authoredSource.id),
loadMechanicPublicProjection(database, authoredSource.id),
]);
return { authoredSource, selectedRevision, projection };
}
export async function talentOwnerAuditSnapshot(database: Database, talentId: string) {
const [record, icon, render, relationships, state] = await Promise.all([
auditRecord('talents', talentId, database),
gameDataIconMetadata(database, 'talent', talentId),
(
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='talent_render' AND source_entity_id=?",
)
).get(talentId),
(async () => ({
weaponScope:
(await (
await database.prepare(
'SELECT scope FROM talent_weapon_scopes WHERE talent_id=?',
)
).get(talentId)) ?? null,
weaponSlot:
(await (
await database.prepare(
'SELECT slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(talentId)) ?? null,
ownerWeapon:
(await (
await database.prepare(
'SELECT id,name FROM weapons WHERE exotic_talent_id=?',
)
).get(talentId)) ?? null,
gearTalents: await (
await database.prepare(
`SELECT link.gear_id,gear.slot
FROM gear_talents link JOIN gear ON gear.id=link.gear_id
WHERE link.talent_id=? ORDER BY link.gear_id`,
)
).all(talentId),
weaponTalentOptions: await (
await database.prepare(
`SELECT weapon_id,talent_slot
FROM weapon_talent_options WHERE talent_id=?
ORDER BY weapon_id,talent_slot`,
)
).all(talentId),
}))(),
loadTalentMechanicState(database, talentId),
]);
const selectedRevision = mechanicModelRevisionSnapshot(state.selectedRevision);
const publicProjection = state.authoredSource
? mechanicPublicProjectionSnapshot(state.selectedRevision, state.projection)
: null;
return {
record,
relationships,
icon,
render: renderAuditMetadata(render),
authoredSource: mechanicAuthoredSourceSnapshot(state.authoredSource),
selectedRevision,
readiness: state.selectedRevision?.simulationReadiness ?? null,
scenarioInputs: state.selectedRevision?.draft.scenarioInputs ?? [],
graph: state.selectedRevision
? {
pools: state.selectedRevision.draft.pools,
clauses: state.selectedRevision.draft.clauses,
}
: null,
publicProjection,
};
}
/** Persists a Talent in the caller's already-open transaction. */
export async function saveTalentFromFormInTransaction(
database: Database,
input: TalentFormInput,
): Promise<SaveTalentInTransactionResult> {
const id = String(input.form.get('id') ?? '').trim();
const talentMode = id ? 'edit' : 'add';
const existing = id
? ((await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(id)) ?? null)
: null;
if (id && !existing)
return failure(404, [{ area: 'identity', message: 'Talent not found.' }]);
const name = String(input.form.get('name') ?? '').normalize('NFKC').trim();
const category = String(input.form.get('category') ?? '') as TalentCategory;
const isExotic = category === 'Exotic Weapon';
const tierIIDocumented =
!isExotic || String(input.form.get('tierIIDocumented') ?? '') === '1';
const talentId = id || input.proposedEntityId?.trim() || randomUUID();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
input.form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
return failure(400, [{
area: 'identity',
message: error instanceof Error ? error.message : 'The expected owner version is invalid.',
}]);
}
if (id && expectedOwnerUpdatedAt !== String(existing?.updated_at ?? ''))
return failure(409, [{
area: 'identity',
message: 'The Talent changed after this editor was loaded. Reload the latest definition and try again.',
}]);
const owner = {
type: 'talent' as const,
id: talentId,
scope: isExotic ? ('exotic' as const) : ('standard' as const),
...(tierIIDocumented ? { hasTierII: true } : {}),
};
const errors: TalentValidationError[] = [];
if (name.length < 2 || name.length > 80 || !talentCategories.includes(category))
errors.push({ area: 'identity', message: 'Provide a valid talent name and category.' });
let authoredSource;
let expectedSourceFingerprint: string | null;
try {
authoredSource = parseMechanicAuthoredSourcePayload(
input.form.get('authoredSource'),
owner,
);
expectedSourceFingerprint = parseExpectedSourceFingerprint(
input.form.get('expectedSourceFingerprint'),
);
} catch (error) {
return failure(400, [{
area: 'description',
message: error instanceof Error ? error.message : 'The authored description is invalid.',
}]);
}
const activeStats = await (
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
).all();
const activeIds = new Set(activeStats.map((row) => String(row.id)));
const authoredAttributeIds = [
...new Set(
authoredSource.variants.flatMap((variant) =>
variant.slots
.map((slot) => slot.attributeId)
.filter((value): value is string => Boolean(value)),
),
),
];
if (authoredAttributeIds.some((attributeId) => !activeIds.has(attributeId)))
errors.push({
area: 'outcomes',
message: 'One or more authored Attributes are unavailable.',
});
let selection: MechanicOwnerSelection;
try {
selection = parseMechanicSelectionIntent(
input.form.get('mechanicSelectionIntent'),
input.form.get('conditionalRule'),
{
variantKeys: authoredSource.variants.map((variant) => variant.key),
allowedAttributeIds: activeIds,
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: name || 'Talent mechanic',
},
);
} catch (error) {
return failure(400, [{
area: 'mechanic',
message: error instanceof Error ? error.message : 'The mechanic selection is invalid.',
}]);
}
const ownerWeapon = id
? await (await database.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(id)
: null;
if (ownerWeapon && !isExotic)
errors.push({
area: 'identity',
message: `This talent is owned by ${String(ownerWeapon.name)} and must remain an Exotic Weapon talent.`,
});
if (id) {
const [gearLinks, weaponLinks] = await Promise.all([
(
await database.prepare(
`SELECT link.gear_id,gear.slot
FROM gear_talents link JOIN gear ON gear.id=link.gear_id
WHERE link.talent_id=?`,
)
).all(id),
(
await database.prepare(
'SELECT weapon_id,talent_slot FROM weapon_talent_options WHERE talent_id=?',
)
).all(id),
]);
const expectedGearSlots =
category === 'Backpack'
? new Set(['Backpack'])
: category === 'Body Armor'
? new Set(['Body Armor', 'Vest'])
: null;
if (expectedGearSlots === null && gearLinks.length) {
errors.push({
area: 'identity',
message: 'This talent is assigned to gear and must remain a gear talent.',
});
} else if (
expectedGearSlots &&
gearLinks.some((link) => !expectedGearSlots.has(String(link.slot)))
) {
errors.push({
area: 'identity',
message: 'This talent is assigned to a different gear slot.',
});
}
const expectedWeaponSlot =
category === 'Weapon Talent 1'
? 1
: category === 'Weapon Talent 2'
? 2
: null;
if (expectedWeaponSlot === null && weaponLinks.length) {
errors.push({
area: 'identity',
message: 'This talent is assigned to weapons and must remain a standard weapon talent.',
});
} else if (
expectedWeaponSlot !== null &&
weaponLinks.some((link) => Number(link.talent_slot) !== expectedWeaponSlot)
) {
errors.push({
area: 'identity',
message: 'This talent is assigned to a different weapon talent slot.',
});
}
}
if (errors.length) return failure(400, errors);
const currentSource = await loadMechanicAuthoredSource(database, 'talent', talentId);
if (expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
return failure(409, [{
area: 'description',
message: 'The authored mechanic changed after this editor was loaded.',
}]);
const storedCategory =
category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon') || isExotic ? 'Weapon' : category;
if (
await (
await database.prepare(
'SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?',
)
).get(name, storedCategory, id || 'new')
)
return failure(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
const now = new Date().toISOString();
const before = id ? await talentOwnerAuditSnapshot(database, talentId) : null;
if (id) {
await (
await database.prepare(
'UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?',
)
).run(name, storedCategory, now, talentId);
} else {
await (
await database.prepare(
'INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(talentId, name, storedCategory, now, now);
}
if (storedCategory === 'Weapon') {
await (
await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)
).run(talentId, isExotic ? 'Exotic' : 'Standard');
if (isExotic)
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
else
await (
await database.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)
).run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
} else {
await (await database.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
}
const labels = new Map(activeStats.map((stat) => [String(stat.id), String(stat.name)]));
const persisted = await persistMechanicOwnerStateInTransaction(database, {
source: authoredSource,
expectedSourceFingerprint,
selection,
programName: name || 'Talent mechanic',
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.operatorId,
resolveAttributeLabel: input.resolveAttributeLabel ?? ((id) => labels.get(id) ?? null),
});
await applyIconMutation(
database,
'talent',
talentId,
input.iconMutation,
input.operatorId,
input.journal,
);
await applyGameDataRender(
database,
'talent',
talentId,
name,
input.renderMutation,
input.operatorId,
input.journal,
);
const after = await talentOwnerAuditSnapshot(database, talentId);
await recordIsacAudit({
actorId: input.operatorId,
category: 'game_data',
action: id ? 'talent.updated' : 'talent.created',
entityId: talentId,
before,
after: {
...after,
category,
tierIIDocumented,
ownerState: mechanicOwnerStateSnapshot(persisted),
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { ok: true, talentId, talentMode };
}
/**
* Replay-safe canonical callable. The supplied database must be the active
* transaction owned by the caller. Route uploads and permission checks stay
* at the route boundary, while this callable shares the exact save path.
*/
export async function saveTalentInTransaction(
database: Database,
input: TalentSaveInput,
): Promise<{ talentId: string; mode: 'add' | 'edit' }> {
const form = new FormData();
form.set('id', input.id);
form.set('name', input.name);
form.set('category', input.category);
const tierIIDocumented =
input.tierIIDocumented ??
(input.category !== 'Exotic Weapon' ||
(input.authoredSource.owner.type === 'talent' &&
input.authoredSource.owner.hasTierII === true) ||
input.authoredSource.variants.some((variant) => variant.key === 'II'));
form.set('tierIIDocumented', tierIIDocumented ? '1' : '0');
form.set('authoredSource', JSON.stringify({
version: input.authoredSource.version,
template: input.authoredSource.template,
variants: input.authoredSource.variants,
}));
if (input.expectedOwnerUpdatedAt)
form.set('expectedOwnerUpdatedAt', input.expectedOwnerUpdatedAt);
if (input.expectedSourceFingerprint)
form.set('expectedSourceFingerprint', input.expectedSourceFingerprint);
form.set('mechanicSelectionIntent', input.selection.intent);
if (input.selection.intent === 'save')
form.set('conditionalRule', JSON.stringify(input.selection.draft));
const result = await saveTalentFromFormInTransaction(database, {
form,
operatorId: input.actorId,
proposedEntityId: input.proposedEntityId,
iconMutation: input.iconMutation,
renderMutation: input.renderMutation,
mutationContext: input.mutationContext,
journal: input.journal,
resolveAttributeLabel: input.resolveAttributeLabel,
});
if (result.ok) return { talentId: result.talentId, mode: result.talentMode };
if (result.status === 404) throw new TalentNotFoundError();
if (
result.status === 409 &&
input.id &&
result.errors[0]?.area === 'description'
) {
const current = await loadMechanicAuthoredSource(database, 'talent', input.id);
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
current?.fingerprint ?? null,
);
}
throw new Error(result.errors[0]?.message ?? 'The talent could not be saved.');
}
@@ -5,6 +5,7 @@ import {
} from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
getPagination,
@@ -429,6 +430,7 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const journal = new MediaMutationJournal();
await database.exec('BEGIN IMMEDIATE');
try {
const beforeIcon = await gameDataIconMetadata(
@@ -439,6 +441,7 @@ export const actions: Actions = {
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
await database.exec('ROLLBACK');
journal.clear();
return fail(409, {
weaponModSlotIconError:
'This slot icon changed after the editor opened. Review the latest icon and try again.',
@@ -446,6 +449,7 @@ export const actions: Actions = {
}
if (mutation.remove && !beforeIcon) {
await database.exec('ROLLBACK');
journal.clear();
return fail(400, {
weaponModSlotIconError: 'This slot does not have an icon to remove.',
});
@@ -456,6 +460,7 @@ export const actions: Actions = {
definition.id,
mutation,
operator.id,
journal,
);
const afterIcon = await gameDataIconMetadata(
database,
@@ -472,8 +477,10 @@ export const actions: Actions = {
database,
});
await database.exec('COMMIT');
journal.clear();
} catch (error) {
await database.exec('ROLLBACK');
await journal.rollback(error);
throw error;
}
return {
@@ -100,6 +100,7 @@ describe('Weapon Mod shared slot icon action', () => {
definition.id,
{ output: Buffer.from('icon'), remove: false },
'operator-1',
expect.anything(),
);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',