feat: persist authored mechanic revisions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+25
-6
@@ -8,6 +8,7 @@ import {
|
||||
} from '$lib/server/site-access';
|
||||
import {
|
||||
DATA_MODULES,
|
||||
ProposalPayloadValidationError,
|
||||
proposalActionResult,
|
||||
queueDataProposal,
|
||||
shouldModerateDataMutation,
|
||||
@@ -82,12 +83,30 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
);
|
||||
}
|
||||
const submittedForm = await event.request.formData();
|
||||
const proposal = await queueDataProposal(
|
||||
user,
|
||||
module,
|
||||
action,
|
||||
submittedForm,
|
||||
);
|
||||
let proposal: Awaited<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 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -5,6 +5,37 @@ import type { AuthUser } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { GameDataIconValidationError } from '$lib/server/game-data-icons';
|
||||
import { ManagedImageValidationError } from '$lib/server/managed-images';
|
||||
import { MediaMutationJournal } from '$lib/server/media-journal';
|
||||
|
||||
import {
|
||||
readTalentFormMedia,
|
||||
saveTalentFromFormInTransaction,
|
||||
TalentNotFoundError,
|
||||
type SaveTalentInTransactionResult,
|
||||
} from '../../routes/isac/talents/talent-save';
|
||||
import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources';
|
||||
import {
|
||||
parseProtocolForm,
|
||||
ProtocolFormValidationError,
|
||||
ProtocolNotFoundError,
|
||||
saveProtocolInTransaction,
|
||||
} from '../../routes/isac/protocols/protocol-save';
|
||||
import {
|
||||
_saveSkillChipInTransaction,
|
||||
parseSkillChipForm,
|
||||
SkillChipFormValidationError,
|
||||
SkillChipNotFoundError,
|
||||
} from '../../routes/isac/skillchips/skillchip-save';
|
||||
import {
|
||||
parseSpecializationCombatEffectForm,
|
||||
parseSpecializationNodeForm,
|
||||
saveSpecializationCombatEffectInTransaction,
|
||||
saveSpecializationNodeInTransaction,
|
||||
SpecializationFormValidationError,
|
||||
} from '../../routes/isac/specializations/specialization-save';
|
||||
|
||||
export const DATA_MODULES = [
|
||||
'configuration',
|
||||
@@ -93,7 +124,22 @@ export function proposalActionResult(
|
||||
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
|
||||
}
|
||||
|
||||
type SerializedEntry =
|
||||
export const MAX_PROPOSAL_ENTRIES = 300;
|
||||
export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000;
|
||||
export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024;
|
||||
const MAX_PROPOSAL_FILE_BASE64_CHARS =
|
||||
4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3);
|
||||
export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200;
|
||||
export const MAX_PROPOSAL_FILE_NAME_CHARS = 255;
|
||||
export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255;
|
||||
|
||||
const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
||||
const PROPOSAL_BASE64_PATTERN =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
||||
|
||||
export type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| {
|
||||
name: string;
|
||||
@@ -103,58 +149,647 @@ type SerializedEntry =
|
||||
type: string;
|
||||
};
|
||||
|
||||
type PendingDefinition = {
|
||||
export class ProposalPayloadValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProposalPayloadValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PendingDefinition = {
|
||||
module: DataModule;
|
||||
action: string;
|
||||
kind: string;
|
||||
fields?: string[];
|
||||
};
|
||||
|
||||
async function serialize(form: FormData): Promise<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)
|
||||
entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type,
|
||||
});
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_PROPOSAL_ENTRIES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
|
||||
);
|
||||
validateProposalFieldName(name);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
|
||||
throw new ProposalPayloadValidationError(
|
||||
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
|
||||
);
|
||||
textBytes += Buffer.byteLength(value, 'utf8');
|
||||
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal text exceeds the safe size limit.',
|
||||
);
|
||||
entries.push({ name, kind: 'text', value });
|
||||
continue;
|
||||
}
|
||||
|
||||
validateProposalFileMetadata(value.name, value.type);
|
||||
if (value.size === 0) continue;
|
||||
if (value.size > MAX_PROPOSAL_FILE_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'Proposal files exceed the safe per-file size limit.',
|
||||
);
|
||||
if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal files exceed the safe size limit.',
|
||||
);
|
||||
const bytes = Buffer.from(await value.arrayBuffer());
|
||||
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'Proposal files exceed the safe per-file size limit.',
|
||||
);
|
||||
fileBytes += bytes.length;
|
||||
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
|
||||
throw new ProposalPayloadValidationError(
|
||||
'The aggregate proposal files exceed the safe size limit.',
|
||||
);
|
||||
entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: bytes.toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function parseSerializedProposalEntries(
|
||||
payload: string,
|
||||
): SerializedEntry[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
validateSerializedProposalEntries(parsed as SerializedEntry[]);
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalPayloadValidationError) throw error;
|
||||
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
|
||||
}
|
||||
return parsed as SerializedEntry[];
|
||||
}
|
||||
|
||||
export function deserializeProposal(payload: string): FormData {
|
||||
const entries = parseSerializedProposalEntries(payload);
|
||||
const form = new FormData();
|
||||
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'text') form.append(entry.name, entry.value);
|
||||
else
|
||||
form.append(
|
||||
entry.name,
|
||||
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
|
||||
new Blob([decodeSerializedProposalFile(entry.value)], {
|
||||
type: entry.type,
|
||||
}),
|
||||
entry.fileName,
|
||||
);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export class ProposalNotPendingError extends Error {
|
||||
constructor() {
|
||||
super('That pending proposal no longer exists.');
|
||||
this.name = 'ProposalNotPendingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProposalDependenciesBlockedError extends Error {
|
||||
readonly dependencies: string;
|
||||
|
||||
constructor(dependencies: string) {
|
||||
super(`Approve this proposal's dependencies first: ${dependencies}.`);
|
||||
this.name = 'ProposalDependenciesBlockedError';
|
||||
this.dependencies = dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProposalDependencyCycleError extends Error {
|
||||
readonly proposalId: string;
|
||||
readonly dependencyId: string;
|
||||
|
||||
constructor(proposalId: string, dependencyId: string) {
|
||||
super(
|
||||
`Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`,
|
||||
);
|
||||
this.name = 'ProposalDependencyCycleError';
|
||||
this.proposalId = proposalId;
|
||||
this.dependencyId = dependencyId;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProposalCanonicalValidationError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ProposalCanonicalValidationError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const safeValidationMessage = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message && message.length <= 500
|
||||
? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim()
|
||||
: 'The proposal did not pass current canonical validation.';
|
||||
};
|
||||
|
||||
function canonicalFailure(
|
||||
error: unknown,
|
||||
): ProposalCanonicalValidationError | null {
|
||||
if (error instanceof ProposalCanonicalValidationError)
|
||||
return new ProposalCanonicalValidationError(
|
||||
error.status,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (error instanceof ProposalPayloadValidationError)
|
||||
return new ProposalCanonicalValidationError(
|
||||
400,
|
||||
'The stored proposal payload is invalid.',
|
||||
);
|
||||
if (error instanceof MechanicSourceFingerprintConflictError)
|
||||
return new ProposalCanonicalValidationError(
|
||||
409,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof GameDataIconValidationError ||
|
||||
error instanceof ManagedImageValidationError
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
400,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof ProtocolFormValidationError ||
|
||||
error instanceof SkillChipFormValidationError ||
|
||||
error instanceof SpecializationFormValidationError
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
error.status,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof ProtocolNotFoundError ||
|
||||
error instanceof TalentNotFoundError ||
|
||||
error instanceof SkillChipNotFoundError
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
404,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
/no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test(
|
||||
error.message,
|
||||
)
|
||||
)
|
||||
return new ProposalCanonicalValidationError(
|
||||
409,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([
|
||||
'The specialization tree node no longer exists.',
|
||||
'The specialization combat effect no longer exists.',
|
||||
]);
|
||||
|
||||
const SPECIALIZATION_CONFLICT_ERRORS = new Set([
|
||||
'The node identity does not match the supplied record.',
|
||||
'A saved tree node cannot be moved to another focus.',
|
||||
'A combat-effect attachment was submitted more than once.',
|
||||
'A submitted combat-effect attachment does not belong to this node.',
|
||||
'A new combat-effect attachment reused an existing ID.',
|
||||
'The combat-effect identity does not match the supplied record.',
|
||||
'A saved combat effect cannot be moved to another specialization.',
|
||||
]);
|
||||
|
||||
function classifySpecializationTransactionError(error: unknown) {
|
||||
if (error instanceof SpecializationFormValidationError) return error;
|
||||
if (error instanceof MechanicSourceFingerprintConflictError) return error;
|
||||
if (!(error instanceof Error)) return null;
|
||||
const message = error.message.trim();
|
||||
if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message))
|
||||
return new SpecializationFormValidationError(
|
||||
404,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
if (SPECIALIZATION_CONFLICT_ERRORS.has(message))
|
||||
return new SpecializationFormValidationError(
|
||||
409,
|
||||
safeValidationMessage(error),
|
||||
);
|
||||
const isKnownValidationMessage =
|
||||
message === 'Every conditional effect requires an authored description.' ||
|
||||
message ===
|
||||
'The authored mechanic owner does not match its attachment.' ||
|
||||
message === 'One or more conditional effects are unavailable for this specialization.' ||
|
||||
message === 'One or more authored description Attributes are unavailable.' ||
|
||||
message.startsWith('The authored template ') ||
|
||||
message.startsWith('Clause ') ||
|
||||
message.startsWith('Conditional mechanic ');
|
||||
return isKnownValidationMessage
|
||||
? new SpecializationFormValidationError(400, safeValidationMessage(error))
|
||||
: null;
|
||||
}
|
||||
|
||||
function enforcePhase2ProposalIdentity(
|
||||
proposal: Record<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(
|
||||
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
|
||||
entry.kind === 'text',
|
||||
);
|
||||
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,58 +907,63 @@ export async function queueDataProposal(
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
|
||||
const now = new Date().toISOString();
|
||||
const payload = JSON.stringify(await serialize(form));
|
||||
const payload = JSON.stringify(await serializeProposal(form));
|
||||
const database = await getDatabase();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO data_change_proposals
|
||||
const createProposal = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
await (
|
||||
await tx.prepare(`INSERT INTO data_change_proposals
|
||||
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
|
||||
).run(
|
||||
id,
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
summary,
|
||||
contributorNote || null,
|
||||
payload,
|
||||
user.id,
|
||||
now,
|
||||
);
|
||||
const text = serializedTextValues(payload)
|
||||
.map((entry) => entry.value)
|
||||
.join('\n');
|
||||
const candidates = await (
|
||||
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
||||
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
|
||||
).all();
|
||||
const dependencies = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
|
||||
);
|
||||
for (const dependency of dependencies) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
|
||||
)
|
||||
).run(id, dependency.id);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after: {
|
||||
).run(
|
||||
id,
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
dependencies: dependencies.map((item) => item.id),
|
||||
summary,
|
||||
contributorNote: contributorNote || null,
|
||||
},
|
||||
contributorNote || null,
|
||||
payload,
|
||||
user.id,
|
||||
now,
|
||||
);
|
||||
const text = (serializedTextValues(payload) ?? [])
|
||||
.map((entry) => entry.value)
|
||||
.join('\n');
|
||||
const candidates = await (
|
||||
await tx.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
||||
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
|
||||
).all();
|
||||
const dependencies = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
|
||||
);
|
||||
for (const dependency of dependencies) {
|
||||
await (
|
||||
await tx.prepare(
|
||||
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
|
||||
)
|
||||
).run(id, dependency.id);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after: {
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
dependencies: dependencies.map((item) => item.id),
|
||||
summary,
|
||||
contributorNotePresent: Boolean(contributorNote),
|
||||
},
|
||||
database: tx,
|
||||
});
|
||||
});
|
||||
await createProposal.immediate();
|
||||
await notifyOversightReview({
|
||||
category: 'data_changes',
|
||||
itemId: id,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
removeBackgroundPreview,
|
||||
} from './remove-background';
|
||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||
import {
|
||||
acquireMediaPathLock,
|
||||
captureMediaFile,
|
||||
restoreMediaFile,
|
||||
type MediaMutationJournal,
|
||||
} from './media-journal';
|
||||
|
||||
export const GAME_ICON_TYPES = [
|
||||
'talent',
|
||||
@@ -374,54 +380,124 @@ export async function saveGameDataIcon(
|
||||
entityId: string,
|
||||
output: Buffer,
|
||||
actorId: string,
|
||||
journal?: MediaMutationJournal,
|
||||
): Promise<string> {
|
||||
const key = storageKey(type, entityId);
|
||||
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
||||
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
|
||||
const filePath = storagePath(key);
|
||||
let release: (() => void) | undefined;
|
||||
let transactionStarted = false;
|
||||
let committed = false;
|
||||
try {
|
||||
if (journal) {
|
||||
await journal.lockPath(filePath);
|
||||
const previous = await captureMediaFile(filePath);
|
||||
journal.register(() => restoreMediaFile(filePath, previous));
|
||||
} else {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
release = await acquireMediaPathLock(filePath);
|
||||
}
|
||||
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
||||
await writeFile(filePath, output, { flag: 'w', mode: 0o600 });
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
|
||||
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
|
||||
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
|
||||
).run(type, entityId, key, output.length, now);
|
||||
await registerLibraryIcon(
|
||||
database,
|
||||
type,
|
||||
entityId,
|
||||
key,
|
||||
output.length,
|
||||
now,
|
||||
actorId,
|
||||
);
|
||||
return now;
|
||||
).run(type, entityId, key, output.length, now);
|
||||
await registerLibraryIcon(
|
||||
database,
|
||||
type,
|
||||
entityId,
|
||||
key,
|
||||
output.length,
|
||||
now,
|
||||
actorId,
|
||||
);
|
||||
if (transactionStarted) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
}
|
||||
return now;
|
||||
} catch (error) {
|
||||
if (transactionStarted && !committed)
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGameDataIcon(
|
||||
database: Database,
|
||||
type: GameDataIconType,
|
||||
entityId: string,
|
||||
journal?: MediaMutationJournal,
|
||||
): Promise<void> {
|
||||
const icon = await (
|
||||
await database.prepare(
|
||||
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||
)
|
||||
).get(type, entityId);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||
)
|
||||
).run(type, entityId);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
|
||||
)
|
||||
).run(type, entityId);
|
||||
if (typeof icon?.storage_key === 'string') {
|
||||
await unlink(storagePath(icon.storage_key)).catch(
|
||||
(error: NodeJS.ErrnoException) => {
|
||||
const canonicalPath = storagePath(storageKey(type, entityId));
|
||||
let releaseCanonical: (() => void) | undefined;
|
||||
let releaseFile: (() => void) | undefined;
|
||||
let transactionStarted = false;
|
||||
let committed = false;
|
||||
try {
|
||||
if (journal) await journal.lockPath(canonicalPath);
|
||||
else {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
releaseCanonical = await acquireMediaPathLock(canonicalPath);
|
||||
}
|
||||
|
||||
const icon = await (
|
||||
await database.prepare(
|
||||
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||
)
|
||||
).get(type, entityId);
|
||||
const filePath =
|
||||
typeof icon?.storage_key === 'string'
|
||||
? storagePath(icon.storage_key)
|
||||
: null;
|
||||
if (filePath && path.resolve(filePath) !== path.resolve(canonicalPath)) {
|
||||
if (journal) await journal.lockPath(filePath);
|
||||
else releaseFile = await acquireMediaPathLock(filePath);
|
||||
}
|
||||
if (journal) {
|
||||
for (const pathToCapture of [canonicalPath, filePath]) {
|
||||
if (!pathToCapture) continue;
|
||||
const previous = await captureMediaFile(pathToCapture);
|
||||
journal.register(() => restoreMediaFile(pathToCapture, previous));
|
||||
}
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||
)
|
||||
).run(type, entityId);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
|
||||
)
|
||||
).run(type, entityId);
|
||||
if (filePath) {
|
||||
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!filePath || path.resolve(filePath) !== path.resolve(canonicalPath)) {
|
||||
await unlink(canonicalPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
}
|
||||
if (transactionStarted) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (transactionStarted && !committed)
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
releaseFile?.();
|
||||
releaseCanonical?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +538,19 @@ export async function applyIconMutation(
|
||||
entityId: string,
|
||||
mutation: { output: Buffer | null; remove: boolean },
|
||||
actorId: string,
|
||||
journal?: MediaMutationJournal,
|
||||
): Promise<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(
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
+331
-103
@@ -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,12 +296,52 @@ export async function applyGameDataRender(
|
||||
if (!mutation.file && !mutation.remove) return existing ?? null;
|
||||
if (mutation.remove) {
|
||||
if (!existing) return null;
|
||||
await (
|
||||
await database.prepare('DELETE FROM managed_images WHERE id=?')
|
||||
).run(existing.id);
|
||||
if (typeof existing.storage_key === 'string')
|
||||
await rm(storagePath(existing.storage_key), { force: true });
|
||||
return null;
|
||||
if (typeof existing.storage_key !== 'string') {
|
||||
await (
|
||||
await database.prepare('DELETE FROM managed_images WHERE id=?')
|
||||
).run(existing.id);
|
||||
return null;
|
||||
}
|
||||
let lock: CurrentRenderPathLock | undefined;
|
||||
let transactionStarted = false;
|
||||
let committed = false;
|
||||
try {
|
||||
if (!journal) {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
}
|
||||
lock = await lockCurrentGameDataRender(
|
||||
database,
|
||||
sourceType,
|
||||
entityId,
|
||||
existing.storage_key,
|
||||
journal,
|
||||
);
|
||||
const activeLock = lock;
|
||||
if (activeLock.current) {
|
||||
if (journal) {
|
||||
const previous = await captureMediaFile(activeLock.filePath);
|
||||
journal.register(() =>
|
||||
restoreMediaFile(activeLock.filePath, previous),
|
||||
);
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM managed_images WHERE id=?')
|
||||
).run(activeLock.current.id);
|
||||
await rm(activeLock.filePath, { force: true });
|
||||
}
|
||||
if (transactionStarted) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (transactionStarted && !committed)
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
lock?.release?.();
|
||||
}
|
||||
}
|
||||
const processed =
|
||||
mutation.processed ??
|
||||
@@ -242,65 +364,137 @@ export async function applyGameDataRender(
|
||||
specialization_node: 'Specializations',
|
||||
};
|
||||
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
|
||||
if (existing) {
|
||||
await writeFile(
|
||||
storagePath(String(existing.storage_key)),
|
||||
processed.output,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await (
|
||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
||||
if (existing && typeof existing.storage_key !== 'string')
|
||||
throw new Error('Invalid managed image storage key');
|
||||
if (existing && typeof existing.storage_key === 'string') {
|
||||
let lock: CurrentRenderPathLock | undefined;
|
||||
let transactionStarted = false;
|
||||
let committed = false;
|
||||
try {
|
||||
if (!journal) {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
}
|
||||
lock = await lockCurrentGameDataRender(
|
||||
database,
|
||||
sourceType,
|
||||
entityId,
|
||||
existing.storage_key,
|
||||
journal,
|
||||
);
|
||||
const activeLock = lock;
|
||||
if (activeLock.current) {
|
||||
if (journal) {
|
||||
const previous = await captureMediaFile(activeLock.filePath);
|
||||
journal.register(() =>
|
||||
restoreMediaFile(activeLock.filePath, previous),
|
||||
);
|
||||
}
|
||||
await writeFile(activeLock.filePath, processed.output, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await (
|
||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
||||
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
category[type],
|
||||
tags,
|
||||
mutation.file!.name,
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
now,
|
||||
activeLock.current.id,
|
||||
);
|
||||
const result =
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(activeLock.current.id)) ?? null;
|
||||
if (transactionStarted) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (transactionStarted) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (transactionStarted && !committed)
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
lock?.release?.();
|
||||
}
|
||||
}
|
||||
const id = randomUUID();
|
||||
const storageKey = `library-${id}.webp`;
|
||||
const filePath = storagePath(storageKey);
|
||||
let release: (() => void) | undefined;
|
||||
let transactionStarted = false;
|
||||
let committed = false;
|
||||
try {
|
||||
if (!journal) {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
release = await acquireMediaPathLock(filePath);
|
||||
} else await journal.lockPath(filePath);
|
||||
await saveManagedImageFileUnlocked(
|
||||
storageKey,
|
||||
filePath,
|
||||
processed.output,
|
||||
journal,
|
||||
);
|
||||
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
|
||||
await (
|
||||
await database.prepare(`INSERT INTO managed_images
|
||||
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
||||
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
name,
|
||||
category[type],
|
||||
tags,
|
||||
0,
|
||||
0,
|
||||
storageKey,
|
||||
mutation.file!.name,
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
sourceType,
|
||||
entityId,
|
||||
actorId,
|
||||
now,
|
||||
now,
|
||||
existing.id,
|
||||
);
|
||||
return (
|
||||
const result =
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(existing.id)) ?? null
|
||||
);
|
||||
).get(id)) ?? null;
|
||||
if (transactionStarted) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (transactionStarted && !committed)
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
|
||||
await (
|
||||
await database.prepare(`INSERT INTO managed_images
|
||||
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
||||
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
name,
|
||||
category[type],
|
||||
tags,
|
||||
0,
|
||||
0,
|
||||
storageKey,
|
||||
mutation.file!.name,
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
sourceType,
|
||||
entityId,
|
||||
actorId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteManagedImage(database: Database, id: string) {
|
||||
export async function deleteManagedImage(
|
||||
database: Database,
|
||||
id: string,
|
||||
): Promise<{
|
||||
image: Record<string, unknown>;
|
||||
sourceIcon: Record<string, unknown> | null;
|
||||
} | null> {
|
||||
const image = await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id);
|
||||
@@ -310,64 +504,98 @@ export async function deleteManagedImage(database: Database, id: string) {
|
||||
'Managed image is missing its storage key.',
|
||||
);
|
||||
|
||||
const sourceType =
|
||||
typeof image.source_entity_type === 'string'
|
||||
? image.source_entity_type
|
||||
: null;
|
||||
const sourceId =
|
||||
typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
|
||||
if (Boolean(sourceType) !== Boolean(sourceId)) {
|
||||
throw new ManagedImageDeletionError(
|
||||
'This image has an incomplete source assignment and cannot be deleted safely.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
sourceType &&
|
||||
!GAME_ICON_TYPES.includes(sourceType as GameDataIconType) &&
|
||||
!GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)
|
||||
) {
|
||||
throw new ManagedImageDeletionError(
|
||||
'This image has an unsupported source assignment and cannot be deleted safely.',
|
||||
);
|
||||
}
|
||||
|
||||
const sourceIcon =
|
||||
sourceType &&
|
||||
sourceId &&
|
||||
GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
|
||||
? ((await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||
)
|
||||
).get(sourceType, sourceId)) ?? null)
|
||||
: null;
|
||||
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
|
||||
throw new ManagedImageDeletionError(
|
||||
'The library image and its source assignment reference different files. Resolve the assignment before deleting.',
|
||||
);
|
||||
}
|
||||
|
||||
await database.exec('BEGIN');
|
||||
const filePath = storagePath(image.storage_key);
|
||||
let release: (() => void) | undefined;
|
||||
let transactionStarted = false;
|
||||
let committed = false;
|
||||
let retry = false;
|
||||
let deleted: {
|
||||
image: typeof image;
|
||||
sourceIcon: typeof image | null;
|
||||
} | null = null;
|
||||
try {
|
||||
if (sourceIcon && sourceType && sourceId) {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
release = await acquireMediaPathLock(filePath);
|
||||
const current = await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id);
|
||||
if (!current) {
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
current.storage_key !== image.storage_key ||
|
||||
current.source_entity_type !== image.source_entity_type ||
|
||||
current.source_entity_id !== image.source_entity_id
|
||||
) {
|
||||
retry = true;
|
||||
} else {
|
||||
const sourceType =
|
||||
typeof current.source_entity_type === 'string'
|
||||
? current.source_entity_type
|
||||
: null;
|
||||
const sourceId =
|
||||
typeof current.source_entity_id === 'string'
|
||||
? current.source_entity_id
|
||||
: null;
|
||||
if (Boolean(sourceType) !== Boolean(sourceId)) {
|
||||
throw new ManagedImageDeletionError(
|
||||
'This image has an incomplete source assignment and cannot be deleted safely.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
sourceType &&
|
||||
!GAME_ICON_TYPES.includes(sourceType as GameDataIconType) &&
|
||||
!GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)
|
||||
) {
|
||||
throw new ManagedImageDeletionError(
|
||||
'This image has an unsupported source assignment and cannot be deleted safely.',
|
||||
);
|
||||
}
|
||||
|
||||
const sourceIcon =
|
||||
sourceType &&
|
||||
sourceId &&
|
||||
GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
|
||||
? ((await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||
)
|
||||
).get(sourceType, sourceId)) ?? null)
|
||||
: null;
|
||||
if (sourceIcon && sourceIcon.storage_key !== current.storage_key) {
|
||||
throw new ManagedImageDeletionError(
|
||||
'The library image and its source assignment reference different files. Resolve the assignment before deleting.',
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceIcon && sourceType && sourceId) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?',
|
||||
)
|
||||
).run(sourceType, sourceId, current.storage_key);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?',
|
||||
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
|
||||
)
|
||||
).run(sourceType, sourceId, image.storage_key);
|
||||
).run(id, current.storage_key);
|
||||
await rm(filePath, { force: true });
|
||||
deleted = { image: current, sourceIcon };
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
|
||||
)
|
||||
).run(id, image.storage_key);
|
||||
await database.exec('COMMIT');
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
if (transactionStarted && !committed)
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
await rm(storagePath(image.storage_key), { force: true });
|
||||
return { image, sourceIcon };
|
||||
return retry ? deleteManagedImage(database, id) : deleted;
|
||||
}
|
||||
|
||||
export async function readManagedImage(database: Database, id: string) {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,40 +288,55 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const proposalsWithSnapshots = await Promise.all(
|
||||
proposals.map(async (proposal) => ({
|
||||
...proposal,
|
||||
payloadValid: proposalPayloadIsValid(proposal.payload_json),
|
||||
current_snapshot:
|
||||
proposal.module === 'attributes' && proposal.entity_id
|
||||
? ((await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(String(proposal.entity_id))) ?? null)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'savePrimaryProfile'
|
||||
? await getSkillChipPrimaryProfiles(database)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'saveSecondaryPool'
|
||||
? await getSkillChipSecondaryPool(database)
|
||||
: proposal.module === 'weaponmods' &&
|
||||
proposal.action === 'saveSlotIcon' &&
|
||||
proposal.entity_id
|
||||
? {
|
||||
slot: weaponModSlotDefinitionById(
|
||||
String(proposal.entity_id),
|
||||
),
|
||||
icon: await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
String(proposal.entity_id),
|
||||
),
|
||||
}
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(
|
||||
database,
|
||||
String(proposal.entity_id),
|
||||
)
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(database, proposal)
|
||||
: null,
|
||||
: proposal.module === 'talents' &&
|
||||
proposal.action === 'saveTalent' &&
|
||||
proposal.entity_id
|
||||
? await talentOwnerAuditSnapshot(
|
||||
database,
|
||||
String(proposal.entity_id),
|
||||
)
|
||||
: proposal.module === 'protocols' &&
|
||||
proposal.action === 'save' &&
|
||||
proposal.entity_id
|
||||
? await protocolSnapshot(String(proposal.entity_id), database)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'savePrimaryProfile'
|
||||
? await getSkillChipPrimaryProfiles(database)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'saveSecondaryPool'
|
||||
? await getSkillChipSecondaryPool(database)
|
||||
: proposal.module === 'weaponmods' &&
|
||||
proposal.action === 'saveSlotIcon' &&
|
||||
proposal.entity_id
|
||||
? {
|
||||
slot: weaponModSlotDefinitionById(
|
||||
String(proposal.entity_id),
|
||||
),
|
||||
icon: await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
String(proposal.entity_id),
|
||||
),
|
||||
}
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(
|
||||
database,
|
||||
String(proposal.entity_id),
|
||||
)
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(
|
||||
database,
|
||||
proposal,
|
||||
)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
const summaryRows = await (
|
||||
@@ -392,13 +379,19 @@ export const actions: Actions = {
|
||||
moderationError: 'The proposed corrections could not be read.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
new Set(corrections.map((item) => item.index)).size !== corrections.length
|
||||
)
|
||||
return fail(400, {
|
||||
moderationError: 'Each proposal field may only be corrected once.',
|
||||
});
|
||||
if (
|
||||
corrections.length > 250 ||
|
||||
corrections.some(
|
||||
(item) =>
|
||||
!Number.isInteger(item.index) ||
|
||||
item.index < 0 ||
|
||||
item.value.length > 100_000,
|
||||
Array.from(item.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS,
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
@@ -407,87 +400,150 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const proposal = await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(id);
|
||||
if (!proposal)
|
||||
return fail(404, {
|
||||
moderationError: 'That pending proposal no longer exists.',
|
||||
});
|
||||
const entries = JSON.parse(
|
||||
String(proposal.payload_json),
|
||||
) as SerializedEntry[];
|
||||
const changes: Array<{
|
||||
index: number;
|
||||
name: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}> = [];
|
||||
for (const correction of corrections) {
|
||||
const entry = entries[correction.index];
|
||||
if (!entry || entry.kind !== 'text')
|
||||
return fail(400, {
|
||||
moderationError:
|
||||
'Only existing text and numeric proposal fields may be corrected.',
|
||||
});
|
||||
if (entry.value !== correction.value) {
|
||||
changes.push({
|
||||
index: correction.index,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: correction.value,
|
||||
});
|
||||
entry.value = correction.value;
|
||||
}
|
||||
}
|
||||
if (!changes.length)
|
||||
return fail(400, { moderationError: 'No proposal fields were changed.' });
|
||||
const payload = JSON.stringify(entries);
|
||||
const oldDependencies = (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id',
|
||||
const correctProposal = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const proposal = await (
|
||||
await tx.prepare(
|
||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).all(id)
|
||||
).map((row) => String(row.depends_on_proposal_id));
|
||||
const name = entries
|
||||
.find((entry) => entry.kind === 'text' && entry.name === 'name')
|
||||
?.value.trim();
|
||||
const summary = `${String(proposal.action)
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?',
|
||||
)
|
||||
).run(payload, summary, id);
|
||||
const dependencies = await refreshProposalDependencies(id, payload);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.corrected',
|
||||
entityId: id,
|
||||
before: {
|
||||
summary: proposal.summary,
|
||||
dependencies: oldDependencies,
|
||||
fields: changes.map((change) => ({
|
||||
index: change.index,
|
||||
name: change.name,
|
||||
value: change.before,
|
||||
})),
|
||||
},
|
||||
after: {
|
||||
summary,
|
||||
dependencies,
|
||||
fields: changes.map((change) => ({
|
||||
index: change.index,
|
||||
name: change.name,
|
||||
value: change.after,
|
||||
})),
|
||||
},
|
||||
).get(id);
|
||||
if (!proposal) throw new ProposalNotPendingError();
|
||||
|
||||
let entries: SerializedEntry[];
|
||||
try {
|
||||
entries = parseSerializedProposalEntries(String(proposal.payload_json));
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalPayloadValidationError)
|
||||
return {
|
||||
ok: false as const,
|
||||
moderationError: INVALID_PROPOSAL_PAYLOAD_MESSAGE,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const changes: Array<{
|
||||
index: number;
|
||||
name: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}> = [];
|
||||
for (const correction of corrections) {
|
||||
const entry = entries[correction.index];
|
||||
if (
|
||||
entry &&
|
||||
(entry.name === 'id' ||
|
||||
entry.name === '_proposedEntityId' ||
|
||||
entry.name === 'expectedTalentId' ||
|
||||
entry.name === 'expectedOwnerUpdatedAt' ||
|
||||
entry.name === 'ranksJson')
|
||||
)
|
||||
return {
|
||||
ok: false as const,
|
||||
moderationError: 'Proposal identities cannot be corrected.',
|
||||
};
|
||||
if (!entry || entry.kind !== 'text')
|
||||
return {
|
||||
ok: false as const,
|
||||
moderationError:
|
||||
'Only existing text and numeric proposal fields may be corrected.',
|
||||
};
|
||||
if (
|
||||
isPreviewFieldHidden(entry.name) ||
|
||||
safePreviewValue(entry.name, entry.value) !== entry.value ||
|
||||
safePreviewValue(entry.name, correction.value) !== correction.value
|
||||
)
|
||||
return {
|
||||
ok: false as const,
|
||||
moderationError:
|
||||
'Protected proposal fields cannot be corrected.',
|
||||
};
|
||||
if (entry.value !== correction.value) {
|
||||
changes.push({
|
||||
index: correction.index,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: correction.value,
|
||||
});
|
||||
entry.value = correction.value;
|
||||
}
|
||||
}
|
||||
if (!changes.length)
|
||||
return {
|
||||
ok: false as const,
|
||||
moderationError: 'No proposal fields were changed.',
|
||||
};
|
||||
try {
|
||||
validateSerializedProposalEntries(entries);
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalPayloadValidationError)
|
||||
return {
|
||||
ok: false as const,
|
||||
moderationError:
|
||||
'One or more corrected fields exceed the safe proposal limits.',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(entries);
|
||||
const name = entries
|
||||
.find((entry) => entry.kind === 'text' && entry.name === 'name')
|
||||
?.value.trim();
|
||||
const summary = `${String(proposal.action)
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
|
||||
const oldDependencies = (
|
||||
await (
|
||||
await tx.prepare(
|
||||
'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id',
|
||||
)
|
||||
).all(id)
|
||||
).map((row) => String(row.depends_on_proposal_id));
|
||||
const updated = await (
|
||||
await tx.prepare(
|
||||
"UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(payload, summary, id);
|
||||
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
|
||||
const dependencies = await refreshProposalDependencies(id, payload, tx);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.corrected',
|
||||
entityId: id,
|
||||
before: {
|
||||
summaryLength: Array.from(String(proposal.summary)).length,
|
||||
dependencies: oldDependencies,
|
||||
fields: changes.map((change) => ({
|
||||
index: change.index,
|
||||
name: change.name,
|
||||
changed: true,
|
||||
valueLength: Array.from(change.before).length,
|
||||
})),
|
||||
},
|
||||
after: {
|
||||
summaryLength: Array.from(summary).length,
|
||||
dependencies,
|
||||
fields: changes.map((change) => ({
|
||||
index: change.index,
|
||||
name: change.name,
|
||||
changed: true,
|
||||
valueLength: Array.from(change.after).length,
|
||||
})),
|
||||
},
|
||||
database: tx,
|
||||
});
|
||||
return { ok: true as const };
|
||||
});
|
||||
try {
|
||||
const result = await correctProposal.immediate();
|
||||
if (!result.ok)
|
||||
return fail(400, { moderationError: result.moderationError });
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalNotPendingError)
|
||||
return fail(404, { moderationError: error.message });
|
||||
if (error instanceof ProposalDependencyCycleError)
|
||||
return fail(409, { moderationError: error.message });
|
||||
throw error;
|
||||
}
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
approve: async ({ cookies, request, fetch, url }) => {
|
||||
@@ -510,11 +566,81 @@ export const actions: Actions = {
|
||||
return fail(404, {
|
||||
moderationError: 'That pending proposal no longer exists.',
|
||||
});
|
||||
|
||||
const phase2 =
|
||||
(proposal.module === 'talents' && proposal.action === 'saveTalent') ||
|
||||
(proposal.module === 'protocols' && proposal.action === 'save') ||
|
||||
(proposal.module === 'skillchips' && proposal.action === 'save') ||
|
||||
(proposal.module === 'specializations' &&
|
||||
(proposal.action === 'saveNode' ||
|
||||
proposal.action === 'saveCombatEffect'));
|
||||
if (phase2) {
|
||||
try {
|
||||
const result = await approvePhase2Proposal(
|
||||
database,
|
||||
reviewer.id,
|
||||
id,
|
||||
note || null,
|
||||
);
|
||||
await notifySafely(
|
||||
{
|
||||
userId: String(result.proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution approved',
|
||||
body: `${String(result.proposal.summary)} was approved and deployed to the live builder data set.`,
|
||||
link: `/isac/${result.proposal.module}`,
|
||||
mandatory: true,
|
||||
},
|
||||
'contributor of approved proposal',
|
||||
);
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (error instanceof ProposalDependenciesBlockedError)
|
||||
return fail(409, { moderationError: error.message });
|
||||
if (error instanceof ProposalNotPendingError)
|
||||
return fail(404, { moderationError: error.message });
|
||||
if (error instanceof ProposalCanonicalValidationError) {
|
||||
const message = error.message;
|
||||
let failedProposal;
|
||||
try {
|
||||
failedProposal = await failPhase2Proposal(
|
||||
database,
|
||||
reviewer.id,
|
||||
id,
|
||||
message,
|
||||
);
|
||||
} catch (failureError) {
|
||||
if (failureError instanceof ProposalNotPendingError)
|
||||
return fail(404, { moderationError: failureError.message });
|
||||
throw failureError;
|
||||
}
|
||||
await notifySafely(
|
||||
{
|
||||
userId: String(failedProposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution could not be deployed',
|
||||
body: `${String(failedProposal.summary)} did not pass current validation. Oversight evidence is available in your notification inbox.`,
|
||||
link: `/isac/${failedProposal.module}`,
|
||||
mandatory: true,
|
||||
},
|
||||
'contributor of failed proposal',
|
||||
);
|
||||
return fail(409, {
|
||||
moderationError:
|
||||
'The proposal did not pass the current canonical validation and was marked failed.',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const unresolved = await (
|
||||
await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||
FROM data_change_proposal_dependencies link
|
||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
|
||||
FROM data_change_proposal_dependencies link
|
||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
|
||||
).all(id);
|
||||
if (unresolved.length) {
|
||||
const names = unresolved
|
||||
@@ -528,7 +654,59 @@ export const actions: Actions = {
|
||||
`/isac/${proposal.module}?/${proposal.action}`,
|
||||
url.origin,
|
||||
);
|
||||
const proposalForm = deserializeProposal(String(proposal.payload_json));
|
||||
let proposalForm: FormData;
|
||||
try {
|
||||
proposalForm = deserializeProposal(String(proposal.payload_json));
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProposalPayloadValidationError)) throw error;
|
||||
const failStoredPayload = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const now = new Date().toISOString();
|
||||
const updated = await (
|
||||
await tx.prepare(
|
||||
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(
|
||||
reviewer.id,
|
||||
INVALID_PROPOSAL_PAYLOAD_MESSAGE,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.failed',
|
||||
entityId: id,
|
||||
before: { status: 'pending' },
|
||||
after: { status: 'failed', reason: 'invalid_payload' },
|
||||
database: tx,
|
||||
});
|
||||
});
|
||||
try {
|
||||
await failStoredPayload.immediate();
|
||||
} catch (failureError) {
|
||||
if (failureError instanceof ProposalNotPendingError)
|
||||
return fail(404, { moderationError: failureError.message });
|
||||
throw failureError;
|
||||
}
|
||||
await notifySafely(
|
||||
{
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution could not be deployed',
|
||||
body: `${proposal.summary} could not be deployed because its stored payload is invalid.`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
},
|
||||
'contributor of invalid proposal',
|
||||
);
|
||||
return fail(409, {
|
||||
moderationError:
|
||||
'The proposal could not be deployed because its stored payload is invalid and was marked failed.',
|
||||
});
|
||||
}
|
||||
if (proposal.draft_entity_id && !proposal.entity_id)
|
||||
proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
||||
const response = await fetch(target, {
|
||||
@@ -538,17 +716,11 @@ export const actions: Actions = {
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
if (!response.ok) {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
||||
)
|
||||
).run(
|
||||
reviewer.id,
|
||||
details || note || 'Canonical validation failed.',
|
||||
now,
|
||||
id,
|
||||
);
|
||||
).run(reviewer.id, CANONICAL_FAILURE_MESSAGE, now, id);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
@@ -557,15 +729,18 @@ export const actions: Actions = {
|
||||
before: { status: 'pending' },
|
||||
after: { status: 'failed', responseStatus: response.status },
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution could not be deployed',
|
||||
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
});
|
||||
await notifySafely(
|
||||
{
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution could not be deployed',
|
||||
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
},
|
||||
'contributor of failed proposal',
|
||||
);
|
||||
return fail(409, {
|
||||
moderationError:
|
||||
'The proposal did not pass the current canonical validation and was marked failed.',
|
||||
@@ -598,7 +773,9 @@ export const actions: Actions = {
|
||||
body: `${proposal.summary} was approved and deployed to the live builder data set.`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
});
|
||||
}).catch((error) =>
|
||||
console.error('Unable to notify Data Oversight contributor', error),
|
||||
);
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
@@ -616,46 +793,59 @@ export const actions: Actions = {
|
||||
moderationError: 'Add a brief reason for rejecting this proposal.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const proposal = await (
|
||||
await database.prepare(
|
||||
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(id);
|
||||
if (!proposal)
|
||||
return fail(404, {
|
||||
moderationError: 'That pending proposal no longer exists.',
|
||||
const rejectProposal = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const proposal = await (
|
||||
await tx.prepare(
|
||||
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(id);
|
||||
if (!proposal) throw new ProposalNotPendingError();
|
||||
const now = new Date().toISOString();
|
||||
const updated = await (
|
||||
await tx.prepare(
|
||||
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(reviewer.id, note, now, id);
|
||||
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.rejected',
|
||||
entityId: id,
|
||||
before: { status: 'pending' },
|
||||
after: {
|
||||
status: 'rejected',
|
||||
module: proposal.module,
|
||||
action: proposal.action,
|
||||
proposedBy: proposal.proposed_by,
|
||||
},
|
||||
database: tx,
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
||||
)
|
||||
).run(reviewer.id, note, now, id);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.rejected',
|
||||
entityId: id,
|
||||
before: { status: 'pending' },
|
||||
after: {
|
||||
status: 'rejected',
|
||||
note,
|
||||
module: proposal.module,
|
||||
action: proposal.action,
|
||||
proposedBy: proposal.proposed_by,
|
||||
return proposal as Record<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',
|
||||
title: 'Contribution needs revision',
|
||||
body: `${String(proposal.action)
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()} was not approved. Oversight reason: ${note}`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
},
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution needs revision',
|
||||
body: `${String(proposal.action)
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()} was not approved. Oversight reason: ${note}`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
});
|
||||
'contributor of rejected proposal',
|
||||
);
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
<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>
|
||||
<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 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>SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
|
||||
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
|
||||
</details>
|
||||
{/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('')
|
||||
: '';
|
||||
@@ -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,357 +465,100 @@ 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)),
|
||||
},
|
||||
);
|
||||
}
|
||||
const saveProtocol = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const input = await parseProtocolForm(tx, form, operator.id);
|
||||
return saveProtocolInTransaction(tx, { ...input, journal });
|
||||
});
|
||||
const result = await saveProtocol.immediate();
|
||||
journal.clear();
|
||||
return {
|
||||
protocolSaved: true,
|
||||
protocolMode: result.mode,
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
} catch (error) {
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The conditional mechanic is invalid.',
|
||||
});
|
||||
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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
const transactionResult = await saveProtocol.immediate();
|
||||
if (transactionResult) return transactionResult;
|
||||
return {
|
||||
protocolSaved: true,
|
||||
protocolMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
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.' });
|
||||
await (
|
||||
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 getDatabase()
|
||||
).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'os_protocol.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await protocolSnapshot(id),
|
||||
await tx.prepare(
|
||||
'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'os_protocol.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
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() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,23 +86,30 @@
|
||||
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;
|
||||
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 baseValue = outcome?.variants?.base?.value;
|
||||
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
|
||||
.includes('protocolTalentUpgrades');
|
||||
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
|
||||
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
|
||||
: placeholder;
|
||||
});
|
||||
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));
|
||||
const baseValue = outcome?.variants?.base?.value;
|
||||
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
|
||||
.includes('protocolTalentUpgrades');
|
||||
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
|
||||
? 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-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>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={talentValueColumns}
|
||||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
||||
aiContext={{
|
||||
sourceKind: 'os_protocol_talent',
|
||||
sourceName: editing?.talent?.name ?? '',
|
||||
sourceDescription: editing?.talent?.description_template ?? ''
|
||||
}}
|
||||
allowProtocolUpgradeScaling
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
<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?.authoredSource?.template ?? ''
|
||||
}}
|
||||
allowProtocolUpgradeScaling
|
||||
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) => {
|
||||
|
||||
@@ -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
@@ -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,10 +207,15 @@ 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('conditionalRule', JSON.stringify(capturedRule));
|
||||
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');
|
||||
return new Request('http://localhost/isac/protocols?/save', {
|
||||
@@ -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',
|
||||
}),
|
||||
mechanic: null,
|
||||
}),
|
||||
after: expect.objectContaining({ talent: null, conditional: null }),
|
||||
after: expect.objectContaining({ talent: null, mechanic: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
);
|
||||
}
|
||||
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 });
|
||||
});
|
||||
const result = await save.immediate();
|
||||
journal.clear();
|
||||
return {
|
||||
skillChipSaved: true,
|
||||
skillChipMode: result.mode,
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
} catch (error) {
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The conditional mechanic is invalid.',
|
||||
});
|
||||
}
|
||||
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');
|
||||
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;
|
||||
}
|
||||
return {
|
||||
skillChipSaved: true,
|
||||
skillChipMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
allowAssignedAbility
|
||||
valueColumns={talentValueColumns}
|
||||
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
|
||||
aiContext={{
|
||||
sourceKind: 'skill_chip_talent',
|
||||
sourceName: editing?.talent?.name ?? '',
|
||||
sourceDescription: editing?.talent?.description_template ?? ''
|
||||
}}
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
/>
|
||||
<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?.authoredSource?.template ?? ''
|
||||
}}
|
||||
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
@@ -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>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.attributes}
|
||||
valueColumns={conditionalValueColumns}
|
||||
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
|
||||
aiContext={{
|
||||
sourceKind: 'specialization_tree_rank',
|
||||
sourceName: combatEffectById(combatEffect.effectId)?.name ?? `${nodeEditing?.name ?? 'Tree node'} · Rank ${rank.rank}`,
|
||||
sourceDescription: rank?.description ?? ''
|
||||
}}
|
||||
bind:rule={combatEffect.conditionalRule}
|
||||
<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-${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.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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user