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
|
CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx
|
||||||
ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order);
|
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 (
|
CREATE TABLE IF NOT EXISTS talent_tier_effects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE,
|
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
|
- Tiered records with shared structure use one shared selector and side-by-side
|
||||||
value columns rather than duplicating the entire editor. Template-driven copy
|
value columns rather than duplicating the entire editor. Template-driven copy
|
||||||
must show live previews for every tier and document its placeholder syntax.
|
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
|
### Forms
|
||||||
|
|
||||||
|
|||||||
+51
-10
@@ -2216,6 +2216,17 @@ if (legacyProtocolEffects) {
|
|||||||
await database.exec('DROP TABLE os_protocol_attribute_effects');
|
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 (
|
const proposalDefinition = await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'",
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'",
|
||||||
@@ -2235,6 +2246,7 @@ if (
|
|||||||
draft_entity_id TEXT,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
@@ -2244,14 +2256,20 @@ if (
|
|||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
applied_at TEXT
|
applied_at TEXT
|
||||||
)`);
|
)`);
|
||||||
await database.exec(`INSERT INTO data_change_proposals_expanded
|
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
|
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`);
|
FROM data_change_proposals`);
|
||||||
await database.exec('DROP TABLE data_change_proposals');
|
await database.exec('DROP TABLE data_change_proposals');
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals',
|
'ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals',
|
||||||
);
|
);
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
await assertProposalContributorNoteColumn();
|
||||||
}
|
}
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
|
'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,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
@@ -2291,14 +2310,19 @@ if (
|
|||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
applied_at TEXT
|
applied_at TEXT
|
||||||
)`);
|
)`);
|
||||||
await database.exec(`INSERT INTO data_change_proposals_weaponmods
|
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
|
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`);
|
FROM data_change_proposals`);
|
||||||
await database.exec('DROP TABLE data_change_proposals');
|
await database.exec('DROP TABLE data_change_proposals');
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals',
|
'ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals',
|
||||||
);
|
);
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
await assertProposalContributorNoteColumn();
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
|
'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,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
@@ -2338,14 +2363,19 @@ if (
|
|||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
applied_at TEXT
|
applied_at TEXT
|
||||||
)`);
|
)`);
|
||||||
await database.exec(`INSERT INTO data_change_proposals_specializations
|
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
|
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`);
|
FROM data_change_proposals`);
|
||||||
await database.exec('DROP TABLE data_change_proposals');
|
await database.exec('DROP TABLE data_change_proposals');
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals',
|
'ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals',
|
||||||
);
|
);
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
await assertProposalContributorNoteColumn();
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
|
'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,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
@@ -2385,14 +2416,19 @@ if (
|
|||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
applied_at TEXT
|
applied_at TEXT
|
||||||
)`);
|
)`);
|
||||||
await database.exec(`INSERT INTO data_change_proposals_calculations
|
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
|
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`);
|
FROM data_change_proposals`);
|
||||||
await database.exec('DROP TABLE data_change_proposals');
|
await database.exec('DROP TABLE data_change_proposals');
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals',
|
'ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals',
|
||||||
);
|
);
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
await assertProposalContributorNoteColumn();
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
|
'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,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
@@ -2436,11 +2473,14 @@ if (
|
|||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
applied_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,
|
SELECT id,
|
||||||
CASE WHEN module='triggers' THEN 'talents' ELSE module END,
|
CASE WHEN module='triggers' THEN 'talents' ELSE module END,
|
||||||
CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action 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,
|
CASE WHEN module='triggers' AND status='pending' THEN 'failed' ELSE status END,
|
||||||
proposed_by,reviewed_by,
|
proposed_by,reviewed_by,
|
||||||
CASE
|
CASE
|
||||||
@@ -2466,6 +2506,7 @@ if (
|
|||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
await assertProposalContributorNoteColumn();
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
|
'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);
|
font-size: var(--shd-type-telemetry);
|
||||||
line-height: 1.35;
|
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 {
|
.dashboard-row > .registry-render-identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+25
-6
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '$lib/server/site-access';
|
} from '$lib/server/site-access';
|
||||||
import {
|
import {
|
||||||
DATA_MODULES,
|
DATA_MODULES,
|
||||||
|
ProposalPayloadValidationError,
|
||||||
proposalActionResult,
|
proposalActionResult,
|
||||||
queueDataProposal,
|
queueDataProposal,
|
||||||
shouldModerateDataMutation,
|
shouldModerateDataMutation,
|
||||||
@@ -82,12 +83,30 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const submittedForm = await event.request.formData();
|
const submittedForm = await event.request.formData();
|
||||||
const proposal = await queueDataProposal(
|
let proposal: Awaited<ReturnType<typeof queueDataProposal>>;
|
||||||
user,
|
try {
|
||||||
module,
|
proposal = await queueDataProposal(
|
||||||
action,
|
user,
|
||||||
submittedForm,
|
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')) {
|
if (event.request.headers.get('accept')?.includes('application/json')) {
|
||||||
const result = proposalActionResult(
|
const result = proposalActionResult(
|
||||||
module,
|
module,
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import {
|
|||||||
const talentSource = (): MechanicAuthoredSource => ({
|
const talentSource = (): MechanicAuthoredSource => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
|
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) => ({
|
variants: ['I', 'II'].map((key, index) => ({
|
||||||
key,
|
key,
|
||||||
materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`,
|
materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`,
|
||||||
slots: [
|
slots: [
|
||||||
{ key: 'max_stacks', value: '3', order: 2 },
|
{ key: 'max_stacks', value: '3', order: 2 },
|
||||||
{ key: '1', value: `${10 + index}%`, order: 0, unit: 'percent' },
|
{ key: '1', value: `${10 + index}`, order: 0, unit: 'percent' },
|
||||||
{ key: 'duration', value: '5 seconds', order: 1, unit: 'seconds' },
|
{ key: 'duration', value: '5', order: 1, unit: 'seconds' },
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -59,11 +59,11 @@ describe('mechanic authored descriptions', () => {
|
|||||||
const source: MechanicAuthoredSource = {
|
const source: MechanicAuthoredSource = {
|
||||||
version: 1,
|
version: 1,
|
||||||
owner,
|
owner,
|
||||||
template: 'Gain {1}.',
|
template: 'Gain {1}%.',
|
||||||
variants: keys.map((key) => ({
|
variants: keys.map((key) => ({
|
||||||
key,
|
key,
|
||||||
materialized: 'Gain 10%.',
|
materialized: 'Gain 10%.',
|
||||||
slots: [{ key: '1', value: '10%', order: 0 }],
|
slots: [{ key: '1', value: '10', order: 0 }],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
expect(materializeMechanicAuthoredDescription(source).ok).toBe(true);
|
expect(materializeMechanicAuthoredDescription(source).ok).toBe(true);
|
||||||
@@ -73,12 +73,12 @@ describe('mechanic authored descriptions', () => {
|
|||||||
const result = materializeMechanicAuthoredDescription({
|
const result = materializeMechanicAuthoredDescription({
|
||||||
version: 1,
|
version: 1,
|
||||||
owner: { type: 'specialization_tree_rank', id: 'rank-1' },
|
owner: { type: 'specialization_tree_rank', id: 'rank-1' },
|
||||||
template: 'Gain {1}.',
|
template: 'Gain {1}%.',
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
key: 'base',
|
key: 'base',
|
||||||
materialized: 'Gain 10%.',
|
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 =
|
injected.variants[0].slots.find((slot) => slot.key === '1')!.value =
|
||||||
'{raw}';
|
'{raw}';
|
||||||
injected.variants[0].materialized =
|
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({
|
expect(materializeMechanicAuthoredDescription(injected)).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
errors: expect.arrayContaining([
|
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', () => {
|
it('rejects unknown, unresolved, unused, and mismatched authored values', () => {
|
||||||
const source = talentSource();
|
const source = talentSource();
|
||||||
source.template = 'Gain {1} for {cooldown}.';
|
source.template = 'Gain {1}% for {cooldown} seconds.';
|
||||||
source.variants[0].slots.push({ key: '2', value: 'unused', order: 4 });
|
source.variants[0].slots.push({ key: '2', value: '4', order: 4 });
|
||||||
const result = materializeMechanicAuthoredDescription(source);
|
const result = materializeMechanicAuthoredDescription(source);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
@@ -149,11 +160,11 @@ describe('mechanic authored descriptions', () => {
|
|||||||
|
|
||||||
it('preserves exact authored whitespace and materialized prose', () => {
|
it('preserves exact authored whitespace and materialized prose', () => {
|
||||||
const source = talentSource();
|
const source = talentSource();
|
||||||
source.template = ' Gain {1}.\nNext line. ';
|
source.template = ' Gain {1}%.\nNext line. ';
|
||||||
for (const variant of source.variants) {
|
for (const variant of source.variants) {
|
||||||
const value = variant.slots.find((slot) => slot.key === '1')?.value;
|
const value = variant.slots.find((slot) => slot.key === '1')?.value;
|
||||||
variant.slots = variant.slots.filter((slot) => slot.key === '1');
|
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);
|
const result = materializeMechanicAuthoredDescription(source);
|
||||||
expect(result.ok && result.variants[0].text).toBe(
|
expect(result.ok && result.variants[0].text).toBe(
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export type MechanicAuthoredFingerprintPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TOKEN_PATTERN = /\{([^{}]+)\}/g;
|
const TOKEN_PATTERN = /\{([^{}]+)\}/g;
|
||||||
|
const NUMERIC_SLOT_VALUE_PATTERN = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/;
|
||||||
const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']);
|
const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']);
|
||||||
const objectValue = (value: unknown): value is Record<string, unknown> =>
|
const objectValue = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
@@ -209,15 +210,14 @@ function materializeKnownMechanicAuthoredDescription(
|
|||||||
!isAllowedToken(slot.key) ||
|
!isAllowedToken(slot.key) ||
|
||||||
!Number.isInteger(slot.order) ||
|
!Number.isInteger(slot.order) ||
|
||||||
slot.order < 0 ||
|
slot.order < 0 ||
|
||||||
!slot.value.trim() ||
|
!NUMERIC_SLOT_VALUE_PATTERN.test(slot.value) ||
|
||||||
/[{}]/.test(slot.value) ||
|
|
||||||
slots.has(slot.key) ||
|
slots.has(slot.key) ||
|
||||||
slotOrders.has(slot.order)
|
slotOrders.has(slot.order)
|
||||||
) {
|
) {
|
||||||
errors.push({
|
errors.push({
|
||||||
code: 'invalid_slot',
|
code: 'invalid_slot',
|
||||||
path: `${path}.slots[${slotIndex}]`,
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canonicalMechanicProgramJson,
|
canonicalMechanicProgramJson,
|
||||||
classifyMechanicLifecycle,
|
classifyMechanicLifecycle,
|
||||||
|
classifyMechanicVariantLifecycle,
|
||||||
hashMechanicAuthoredSource,
|
hashMechanicAuthoredSource,
|
||||||
hashMechanicProgram,
|
hashMechanicProgram,
|
||||||
|
hashMechanicProgramVariants,
|
||||||
mechanicProgramSemanticPayload,
|
mechanicProgramSemanticPayload,
|
||||||
type MechanicLifecycleInput,
|
type MechanicLifecycleInput,
|
||||||
type MechanicModelRevision,
|
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 () => {
|
it('hashes a valid authored source and rejects unresolved source values', async () => {
|
||||||
const source = {
|
const source = {
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' },
|
owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' },
|
||||||
template: 'Gain {1}.',
|
template: 'Gain {1}%.',
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
key: 'base',
|
key: 'base',
|
||||||
materialized: 'Gain 10%.',
|
materialized: 'Gain 10%.',
|
||||||
slots: [{ key: '1', value: '10%', order: 0 }],
|
slots: [{ key: '1', value: '10', order: 0 }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
renderPublicMechanicPrograms,
|
renderPublicMechanicPrograms,
|
||||||
type MechanicAttributeLabelResolver,
|
type MechanicAttributeLabelResolver,
|
||||||
|
type MechanicPublicProgramVariant,
|
||||||
} from './mechanic-public-renderer.js';
|
} from './mechanic-public-renderer.js';
|
||||||
import {
|
import {
|
||||||
validateMechanicProgram,
|
validateMechanicProgram,
|
||||||
@@ -29,6 +30,8 @@ import {
|
|||||||
/** The version of the canonical Mechanic Program hash contract. */
|
/** The version of the canonical Mechanic Program hash contract. */
|
||||||
export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const;
|
export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const;
|
||||||
export const MECHANIC_PROGRAM_HASH_KIND = 'mechanic_program' 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 MechanicLifecycle = 'absent' | 'stale' | 'current';
|
||||||
export type MechanicAuthoredFingerprintResult =
|
export type MechanicAuthoredFingerprintResult =
|
||||||
@@ -72,6 +75,11 @@ export type MechanicProgramHashSource = MechanicProgram & {
|
|||||||
|
|
||||||
export type MechanicProgramHashInput = MechanicProgramHashSource;
|
export type MechanicProgramHashInput = MechanicProgramHashSource;
|
||||||
|
|
||||||
|
export type MechanicProgramVariantsHashInput = {
|
||||||
|
simulationReadiness: MechanicSimulationReadiness;
|
||||||
|
variants: MechanicPublicProgramVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
export type MechanicModelRevision = {
|
export type MechanicModelRevision = {
|
||||||
program: MechanicProgram | null;
|
program: MechanicProgram | null;
|
||||||
sourceFingerprint: string;
|
sourceFingerprint: string;
|
||||||
@@ -88,6 +96,20 @@ export type MechanicLifecycleInput = {
|
|||||||
supportedMechanicContractVersion?: number;
|
supportedMechanicContractVersion?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MechanicModelVariantRevision = Omit<
|
||||||
|
MechanicModelRevision,
|
||||||
|
'program' | 'sourceVariants'
|
||||||
|
> & {
|
||||||
|
variants: MechanicPublicProgramVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MechanicVariantLifecycleInput = Omit<
|
||||||
|
MechanicLifecycleInput,
|
||||||
|
'selectedRevision'
|
||||||
|
> & {
|
||||||
|
selectedRevision: MechanicModelVariantRevision | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type MechanicLifecycleReason =
|
export type MechanicLifecycleReason =
|
||||||
| 'no_selected_revision'
|
| 'no_selected_revision'
|
||||||
| 'empty_program'
|
| 'empty_program'
|
||||||
@@ -584,6 +606,29 @@ export async function hashMechanicProgram(
|
|||||||
return sha256Utf8(canonicalMechanicProgramJson(input));
|
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}$/;
|
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
function lifecycleAbsent(
|
function lifecycleAbsent(
|
||||||
@@ -692,3 +737,109 @@ export async function classifyMechanicLifecycle(
|
|||||||
simulationReadiness: revision.simulationReadiness,
|
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">
|
<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 {
|
let {
|
||||||
module,
|
module,
|
||||||
@@ -13,8 +24,10 @@
|
|||||||
currentSnapshot?: Record<string, unknown> | null;
|
currentSnapshot?: Record<string, unknown> | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const entries = $derived(JSON.parse(payload) as Entry[]);
|
const parsedPayload = $derived(parseProposalPreviewPayload(payload));
|
||||||
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
|
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 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 shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
|
||||||
const jsonValue = (value: unknown) => {
|
const jsonValue = (value: unknown) => {
|
||||||
@@ -43,16 +56,8 @@
|
|||||||
: 'TREE NODE'
|
: 'TREE NODE'
|
||||||
);
|
);
|
||||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
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 currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
|
||||||
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) 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(
|
const specializationFields = $derived(
|
||||||
action === 'saveProgression'
|
action === 'saveProgression'
|
||||||
? [
|
? [
|
||||||
@@ -116,23 +121,80 @@
|
|||||||
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
|
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
|
||||||
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
|
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
|
||||||
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) 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 effectSummary = (effect: any) => {
|
||||||
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
|
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}` : ''}`;
|
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
|
||||||
return `${target}${value ? ` · ${value}` : ''}`;
|
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 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) => {
|
const attackProfileLines = (attack: Record<string, unknown> | null) => {
|
||||||
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
|
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
|
||||||
const delivery = String(attack.delivery ?? '').toLowerCase();
|
const delivery = String(attack.delivery ?? '').toLowerCase();
|
||||||
@@ -156,90 +218,100 @@
|
|||||||
}
|
}
|
||||||
return lines;
|
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>
|
</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">
|
<section class="proposal-change-preview" aria-label="Proposed data comparison">
|
||||||
<div class="proposal-preview-head">
|
<div class="proposal-preview-head">
|
||||||
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
|
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
|
||||||
@@ -281,6 +353,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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)}
|
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
|
||||||
<div class="proposal-compare-grid">
|
<div class="proposal-compare-grid">
|
||||||
<article class="proposal-editor-preview before">
|
<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>
|
<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>
|
</article>
|
||||||
</div>
|
</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'}
|
{:else if module === 'calculations'}
|
||||||
<div class="proposal-compare-grid calculation-proposal-preview">
|
<div class="proposal-compare-grid calculation-proposal-preview">
|
||||||
<article class="proposal-editor-preview before">
|
<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}
|
{#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>
|
</article>
|
||||||
</div>
|
</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">
|
<div class="proposal-compare-grid specialization-proposal-preview">
|
||||||
<article class="proposal-editor-preview before">
|
<article class="proposal-editor-preview before">
|
||||||
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
<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>
|
<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}
|
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||||
</div>
|
</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'}
|
{:else if action === 'saveProgression'}
|
||||||
<div class="proposal-rank-preview">
|
<div class="proposal-rank-preview">
|
||||||
{#each currentRankProfiles as profile}
|
{#each currentRankProfiles as profile}
|
||||||
@@ -385,18 +458,6 @@
|
|||||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
|
<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}
|
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||||
</div>
|
</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'}
|
{:else if action === 'saveProgression'}
|
||||||
<div class="proposal-rank-preview">
|
<div class="proposal-rank-preview">
|
||||||
{#each [1,2,3] as rank}
|
{#each [1,2,3] as rank}
|
||||||
@@ -411,19 +472,94 @@
|
|||||||
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
|
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
|
||||||
<div class="proposal-field-summary">
|
<div class="proposal-field-summary">
|
||||||
{#each entries as entry}
|
{#each entries as entry}
|
||||||
|
{@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''}
|
||||||
|
{#if !isPreviewFieldHidden(entry.name)}
|
||||||
<div>
|
<div>
|
||||||
<span>{label(entry.name)}</span>
|
<span>{label(entry.name)}</span>
|
||||||
{#if entry.kind === 'file'}
|
{#if entry.kind === 'file'}
|
||||||
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
|
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
|
||||||
{:else if jsonValue(entry.value)}
|
{:else if jsonValue(safeValue)}
|
||||||
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
|
<strong>{Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
|
||||||
<small>{entry.value}</small>
|
<small>{safeValue}</small>
|
||||||
{:else}
|
{:else}
|
||||||
<strong>{shown(entry.value)}</strong>
|
<strong>{shown(safeValue)}</strong>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 { recordIsacAudit } from '$lib/server/audit';
|
||||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||||
|
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 = [
|
export const DATA_MODULES = [
|
||||||
'configuration',
|
'configuration',
|
||||||
@@ -93,7 +124,22 @@ export function proposalActionResult(
|
|||||||
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
|
: { 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; kind: 'text'; value: string }
|
||||||
| {
|
| {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -103,58 +149,647 @@ type SerializedEntry =
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingDefinition = {
|
export class ProposalPayloadValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ProposalPayloadValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingDefinition = {
|
||||||
module: DataModule;
|
module: DataModule;
|
||||||
action: string;
|
action: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
fields?: 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[] = [];
|
const entries: SerializedEntry[] = [];
|
||||||
|
let entryCount = 0;
|
||||||
|
let textBytes = 0;
|
||||||
|
let fileBytes = 0;
|
||||||
|
|
||||||
for (const [name, value] of form.entries()) {
|
for (const [name, value] of form.entries()) {
|
||||||
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
|
entryCount += 1;
|
||||||
else if (value.size > 0)
|
if (entryCount > MAX_PROPOSAL_ENTRIES)
|
||||||
entries.push({
|
throw new ProposalPayloadValidationError(
|
||||||
name,
|
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
|
||||||
kind: 'file',
|
);
|
||||||
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
|
validateProposalFieldName(name);
|
||||||
fileName: value.name,
|
|
||||||
type: value.type,
|
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;
|
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 {
|
export function deserializeProposal(payload: string): FormData {
|
||||||
|
const entries = parseSerializedProposalEntries(payload);
|
||||||
const form = new FormData();
|
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);
|
if (entry.kind === 'text') form.append(entry.name, entry.value);
|
||||||
else
|
else
|
||||||
form.append(
|
form.append(
|
||||||
entry.name,
|
entry.name,
|
||||||
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
|
new Blob([decodeSerializedProposalFile(entry.value)], {
|
||||||
|
type: entry.type,
|
||||||
|
}),
|
||||||
entry.fileName,
|
entry.fileName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return form;
|
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(
|
export async function refreshProposalDependencies(
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
payload: string,
|
payload: string,
|
||||||
|
suppliedDatabase?: Database,
|
||||||
) {
|
) {
|
||||||
const database = await getDatabase();
|
const textValues = serializedTextValues(payload);
|
||||||
const text = serializedTextValues(payload)
|
// A malformed persisted payload cannot provide reliable dependency evidence.
|
||||||
.map((entry) => entry.value)
|
// Leave existing links intact rather than mutating state during recovery.
|
||||||
.join('\n');
|
if (!textValues) return [];
|
||||||
|
const database = suppliedDatabase ?? (await getDatabase());
|
||||||
|
const text = textValues.map((entry) => entry.value).join('\n');
|
||||||
const candidates = await (
|
const candidates = await (
|
||||||
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
|
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);
|
).all(proposalId);
|
||||||
const dependencies = candidates.filter((candidate) =>
|
const dependencies = candidates.filter((candidate) =>
|
||||||
text.includes(String(candidate.draft_entity_id)),
|
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 (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
|
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
|
||||||
@@ -170,11 +805,19 @@ export async function refreshProposalDependencies(
|
|||||||
return dependencies.map((dependency) => String(dependency.id));
|
return dependencies.map((dependency) => String(dependency.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializedTextValues = (payload: string) =>
|
const serializedTextValues = (
|
||||||
(JSON.parse(payload) as SerializedEntry[]).filter(
|
payload: string,
|
||||||
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
|
): Array<Extract<SerializedEntry, { kind: 'text' }>> | null => {
|
||||||
entry.kind === 'text',
|
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(
|
export async function getPendingEntities(
|
||||||
_userId: string,
|
_userId: string,
|
||||||
@@ -211,24 +854,30 @@ export async function getPendingEntities(
|
|||||||
(item) => item.module === row.module && item.action === row.action,
|
(item) => item.module === row.module && item.action === row.action,
|
||||||
)!;
|
)!;
|
||||||
const values = serializedTextValues(String(row.payload_json));
|
const values = serializedTextValues(String(row.payload_json));
|
||||||
|
const unusable = values === null;
|
||||||
|
const safeValues = values ?? [];
|
||||||
const field = (name: string) =>
|
const field = (name: string) =>
|
||||||
values.find((entry) => entry.name === name)?.value ?? '';
|
safeValues.find((entry) => entry.name === name)?.value ?? '';
|
||||||
const fieldValue = (name: string) => {
|
const fieldValue = (name: string) => {
|
||||||
const matches = values
|
const matches = safeValues
|
||||||
.filter((entry) => entry.name === name)
|
.filter((entry) => entry.name === name)
|
||||||
.map((entry) => entry.value);
|
.map((entry) => entry.value);
|
||||||
return matches.length > 1 ? matches : (matches[0] ?? '');
|
return matches.length > 1 ? matches : (matches[0] ?? '');
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
id: String(row.draft_entity_id),
|
id: String(row.draft_entity_id ?? row.id),
|
||||||
proposalId: String(row.id),
|
proposalId: String(row.id),
|
||||||
pending: true,
|
pending: true,
|
||||||
active: 1,
|
active: 1,
|
||||||
|
...(unusable ? { unusable: true } : {}),
|
||||||
kind: definition.kind,
|
kind: definition.kind,
|
||||||
name: field('name'),
|
name: unusable ? 'UNUSABLE PROPOSAL' : field('name'),
|
||||||
proposerName: String(row.proposer_name),
|
proposerName: String(row.proposer_name),
|
||||||
...Object.fromEntries(
|
...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 name = String(form.get('name') ?? '').trim();
|
||||||
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
|
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const payload = JSON.stringify(await serialize(form));
|
const payload = JSON.stringify(await serializeProposal(form));
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
await (
|
const createProposal = database.transactionAsync(async (transaction) => {
|
||||||
await database.prepare(`INSERT INTO data_change_proposals
|
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)
|
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
|
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
|
||||||
).run(
|
).run(
|
||||||
id,
|
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: {
|
|
||||||
module,
|
module,
|
||||||
action,
|
action,
|
||||||
entityId,
|
entityId,
|
||||||
draftEntityId,
|
draftEntityId,
|
||||||
dependencies: dependencies.map((item) => item.id),
|
|
||||||
summary,
|
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({
|
await notifyOversightReview({
|
||||||
category: 'data_changes',
|
category: 'data_changes',
|
||||||
itemId: id,
|
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;
|
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.
|
* 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';
|
env.TDR_DATABASE_PATH || './data/shd-build-development.db';
|
||||||
|
|
||||||
if (!databasePromise) {
|
if (!databasePromise) {
|
||||||
databasePromise = connect(resolve(configuredPath));
|
databasePromise = connect(resolve(configuredPath)).then(configureDatabase);
|
||||||
}
|
}
|
||||||
|
|
||||||
return databasePromise;
|
return databasePromise;
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import {
|
|||||||
removeBackgroundPreview,
|
removeBackgroundPreview,
|
||||||
} from './remove-background';
|
} from './remove-background';
|
||||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||||
|
import {
|
||||||
|
acquireMediaPathLock,
|
||||||
|
captureMediaFile,
|
||||||
|
restoreMediaFile,
|
||||||
|
type MediaMutationJournal,
|
||||||
|
} from './media-journal';
|
||||||
|
|
||||||
export const GAME_ICON_TYPES = [
|
export const GAME_ICON_TYPES = [
|
||||||
'talent',
|
'talent',
|
||||||
@@ -374,54 +380,124 @@ export async function saveGameDataIcon(
|
|||||||
entityId: string,
|
entityId: string,
|
||||||
output: Buffer,
|
output: Buffer,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
journal?: MediaMutationJournal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const key = storageKey(type, entityId);
|
const key = storageKey(type, entityId);
|
||||||
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
const filePath = storagePath(key);
|
||||||
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
|
let release: (() => void) | undefined;
|
||||||
const now = new Date().toISOString();
|
let transactionStarted = false;
|
||||||
await (
|
let committed = false;
|
||||||
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
|
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
|
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`)
|
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
|
||||||
).run(type, entityId, key, output.length, now);
|
).run(type, entityId, key, output.length, now);
|
||||||
await registerLibraryIcon(
|
await registerLibraryIcon(
|
||||||
database,
|
database,
|
||||||
type,
|
type,
|
||||||
entityId,
|
entityId,
|
||||||
key,
|
key,
|
||||||
output.length,
|
output.length,
|
||||||
now,
|
now,
|
||||||
actorId,
|
actorId,
|
||||||
);
|
);
|
||||||
return now;
|
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(
|
export async function deleteGameDataIcon(
|
||||||
database: Database,
|
database: Database,
|
||||||
type: GameDataIconType,
|
type: GameDataIconType,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
|
journal?: MediaMutationJournal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const icon = await (
|
const canonicalPath = storagePath(storageKey(type, entityId));
|
||||||
await database.prepare(
|
let releaseCanonical: (() => void) | undefined;
|
||||||
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
let releaseFile: (() => void) | undefined;
|
||||||
)
|
let transactionStarted = false;
|
||||||
).get(type, entityId);
|
let committed = false;
|
||||||
await (
|
try {
|
||||||
await database.prepare(
|
if (journal) await journal.lockPath(canonicalPath);
|
||||||
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
else {
|
||||||
)
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
).run(type, entityId);
|
transactionStarted = true;
|
||||||
await (
|
releaseCanonical = await acquireMediaPathLock(canonicalPath);
|
||||||
await database.prepare(
|
}
|
||||||
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
|
|
||||||
)
|
const icon = await (
|
||||||
).run(type, entityId);
|
await database.prepare(
|
||||||
if (typeof icon?.storage_key === 'string') {
|
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
|
||||||
await unlink(storagePath(icon.storage_key)).catch(
|
)
|
||||||
(error: NodeJS.ErrnoException) => {
|
).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 (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,
|
entityId: string,
|
||||||
mutation: { output: Buffer | null; remove: boolean },
|
mutation: { output: Buffer | null; remove: boolean },
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
journal?: MediaMutationJournal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (mutation.output)
|
if (mutation.output)
|
||||||
await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
|
await saveGameDataIcon(
|
||||||
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
|
database,
|
||||||
|
type,
|
||||||
|
entityId,
|
||||||
|
mutation.output,
|
||||||
|
actorId,
|
||||||
|
journal,
|
||||||
|
);
|
||||||
|
else if (mutation.remove)
|
||||||
|
await deleteGameDataIcon(database, type, entityId, journal);
|
||||||
else {
|
else {
|
||||||
const icon = await (
|
const icon = await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ describe('deleteManagedImage', () => {
|
|||||||
});
|
});
|
||||||
expect(database.images.has(imageId)).toBe(false);
|
expect(database.images.has(imageId)).toBe(false);
|
||||||
expect(database.icons.has(`${sourceType}:${sourceId}`)).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();
|
expect(rmMock).toHaveBeenCalledOnce();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -214,7 +214,7 @@ describe('deleteManagedImage', () => {
|
|||||||
|
|
||||||
expect(deleted?.sourceIcon).toBeNull();
|
expect(deleted?.sourceIcon).toBeNull();
|
||||||
expect(database.images.has(imageId)).toBe(false);
|
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 () => {
|
it('fails closed when the library and source rows reference different files', async () => {
|
||||||
@@ -236,7 +236,7 @@ describe('deleteManagedImage', () => {
|
|||||||
).rejects.toThrow(ManagedImageDeletionError);
|
).rejects.toThrow(ManagedImageDeletionError);
|
||||||
expect(database.images.has(imageId)).toBe(true);
|
expect(database.images.has(imageId)).toBe(true);
|
||||||
expect(database.icons.has(`talent:${sourceId}`)).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();
|
expect(rmMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ describe('deleteManagedImage', () => {
|
|||||||
deleteManagedImage(database as never, imageId),
|
deleteManagedImage(database as never, imageId),
|
||||||
).rejects.toThrow(ManagedImageDeletionError);
|
).rejects.toThrow(ManagedImageDeletionError);
|
||||||
expect(database.images.has(imageId)).toBe(true);
|
expect(database.images.has(imageId)).toBe(true);
|
||||||
expect(database.executed).toEqual([]);
|
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']);
|
||||||
expect(rmMock).not.toHaveBeenCalled();
|
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 path from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
|
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
|
||||||
|
import {
|
||||||
|
acquireMediaPathLock,
|
||||||
|
captureMediaFile,
|
||||||
|
restoreMediaFile,
|
||||||
|
type MediaMutationJournal,
|
||||||
|
} from './media-journal';
|
||||||
import {
|
import {
|
||||||
BackgroundRemovalError,
|
BackgroundRemovalError,
|
||||||
removeBackgroundPreview,
|
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');
|
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
|
||||||
const key = `library-${id}.webp`;
|
const key = `library-${id}.webp`;
|
||||||
await mkdir(managedImageStorageDirectory(), { recursive: true });
|
const filePath = storagePath(key);
|
||||||
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
|
let release: (() => void) | undefined;
|
||||||
return key;
|
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<{
|
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(
|
export async function applyGameDataRender(
|
||||||
database: Database,
|
database: Database,
|
||||||
type: GameDataRenderType,
|
type: GameDataRenderType,
|
||||||
@@ -204,6 +285,7 @@ export async function applyGameDataRender(
|
|||||||
processed?: { output: Buffer; width: number; height: number };
|
processed?: { output: Buffer; width: number; height: number };
|
||||||
},
|
},
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
journal?: MediaMutationJournal,
|
||||||
) {
|
) {
|
||||||
const sourceType = `${type}_render`;
|
const sourceType = `${type}_render`;
|
||||||
const existing = await (
|
const existing = await (
|
||||||
@@ -214,12 +296,52 @@ export async function applyGameDataRender(
|
|||||||
if (!mutation.file && !mutation.remove) return existing ?? null;
|
if (!mutation.file && !mutation.remove) return existing ?? null;
|
||||||
if (mutation.remove) {
|
if (mutation.remove) {
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
await (
|
if (typeof existing.storage_key !== 'string') {
|
||||||
await database.prepare('DELETE FROM managed_images WHERE id=?')
|
await (
|
||||||
).run(existing.id);
|
await database.prepare('DELETE FROM managed_images WHERE id=?')
|
||||||
if (typeof existing.storage_key === 'string')
|
).run(existing.id);
|
||||||
await rm(storagePath(existing.storage_key), { force: true });
|
return null;
|
||||||
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 =
|
const processed =
|
||||||
mutation.processed ??
|
mutation.processed ??
|
||||||
@@ -242,65 +364,137 @@ export async function applyGameDataRender(
|
|||||||
specialization_node: 'Specializations',
|
specialization_node: 'Specializations',
|
||||||
};
|
};
|
||||||
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
|
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
|
||||||
if (existing) {
|
if (existing && typeof existing.storage_key !== 'string')
|
||||||
await writeFile(
|
throw new Error('Invalid managed image storage key');
|
||||||
storagePath(String(existing.storage_key)),
|
if (existing && typeof existing.storage_key === 'string') {
|
||||||
processed.output,
|
let lock: CurrentRenderPathLock | undefined;
|
||||||
{ mode: 0o600 },
|
let transactionStarted = false;
|
||||||
);
|
let committed = false;
|
||||||
await (
|
try {
|
||||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
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=?`)
|
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(
|
).run(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
category[type],
|
category[type],
|
||||||
tags,
|
tags,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
storageKey,
|
||||||
mutation.file!.name,
|
mutation.file!.name,
|
||||||
processed.output.length,
|
processed.output.length,
|
||||||
processed.width,
|
processed.width,
|
||||||
processed.height,
|
processed.height,
|
||||||
|
sourceType,
|
||||||
|
entityId,
|
||||||
|
actorId,
|
||||||
|
now,
|
||||||
now,
|
now,
|
||||||
existing.id,
|
|
||||||
);
|
);
|
||||||
return (
|
const result =
|
||||||
(await (
|
(await (
|
||||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
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 (
|
const image = await (
|
||||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
).get(id);
|
).get(id);
|
||||||
@@ -310,64 +504,98 @@ export async function deleteManagedImage(database: Database, id: string) {
|
|||||||
'Managed image is missing its storage key.',
|
'Managed image is missing its storage key.',
|
||||||
);
|
);
|
||||||
|
|
||||||
const sourceType =
|
const filePath = storagePath(image.storage_key);
|
||||||
typeof image.source_entity_type === 'string'
|
let release: (() => void) | undefined;
|
||||||
? image.source_entity_type
|
let transactionStarted = false;
|
||||||
: null;
|
let committed = false;
|
||||||
const sourceId =
|
let retry = false;
|
||||||
typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
|
let deleted: {
|
||||||
if (Boolean(sourceType) !== Boolean(sourceId)) {
|
image: typeof image;
|
||||||
throw new ManagedImageDeletionError(
|
sourceIcon: typeof image | null;
|
||||||
'This image has an incomplete source assignment and cannot be deleted safely.',
|
} | null = null;
|
||||||
);
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
try {
|
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 (
|
||||||
await database.prepare(
|
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');
|
await database.exec('COMMIT');
|
||||||
|
committed = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK').catch(() => undefined);
|
if (transactionStarted && !committed)
|
||||||
|
await database.exec('ROLLBACK').catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
release?.();
|
||||||
}
|
}
|
||||||
await rm(storagePath(image.storage_key), { force: true });
|
return retry ? deleteManagedImage(database, id) : deleted;
|
||||||
return { image, sourceIcon };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readManagedImage(database: Database, id: string) {
|
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 { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { requirePermission } from '$lib/server/auth/authorization';
|
import { requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
import {
|
import {
|
||||||
|
approvePhase2Proposal,
|
||||||
deserializeProposal,
|
deserializeProposal,
|
||||||
|
failPhase2Proposal,
|
||||||
|
MAX_PROPOSAL_TEXT_ENTRY_CHARS,
|
||||||
|
ProposalCanonicalValidationError,
|
||||||
|
ProposalPayloadValidationError,
|
||||||
|
ProposalDependenciesBlockedError,
|
||||||
|
ProposalDependencyCycleError,
|
||||||
|
ProposalNotPendingError,
|
||||||
|
parseSerializedProposalEntries,
|
||||||
refreshProposalDependencies,
|
refreshProposalDependencies,
|
||||||
|
validateSerializedProposalEntries,
|
||||||
} from '$lib/server/data-moderation';
|
} from '$lib/server/data-moderation';
|
||||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||||
|
import { isPreviewFieldHidden, safePreviewValue } from '$lib/components/ProposalChangePreview.helpers';
|
||||||
import { recordIsacAudit } from '$lib/server/audit';
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { createNotification } from '$lib/server/notifications';
|
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 {
|
import {
|
||||||
getSkillChipPrimaryProfiles,
|
getSkillChipPrimaryProfiles,
|
||||||
getSkillChipSecondaryPool,
|
getSkillChipSecondaryPool,
|
||||||
@@ -20,6 +38,9 @@ import { gameDataIconMetadata } from '$lib/server/game-data-icons';
|
|||||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||||
|
|
||||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
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 = [
|
const modules = [
|
||||||
'configuration',
|
'configuration',
|
||||||
'calculations',
|
'calculations',
|
||||||
@@ -44,7 +65,31 @@ type SerializedEntry =
|
|||||||
type: string;
|
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 ?? '');
|
const id = String(proposal.entity_id ?? '');
|
||||||
if (proposal.action === 'saveProgression') {
|
if (proposal.action === 'saveProgression') {
|
||||||
return {
|
return {
|
||||||
@@ -108,21 +153,8 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
|
|||||||
).all(id, id),
|
).all(id, id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (proposal.action === 'saveCombatEffect') {
|
if (proposal.action === 'saveCombatEffect')
|
||||||
return {
|
return combatEffectSnapshot(database, id);
|
||||||
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 === 'saveFocusAbilities') {
|
if (proposal.action === 'saveFocusAbilities') {
|
||||||
return {
|
return {
|
||||||
record:
|
record:
|
||||||
@@ -138,72 +170,12 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
|
|||||||
).all(id),
|
).all(id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (proposal.action === 'saveNode') {
|
if (proposal.action === 'saveNode') return nodeSnapshot(database, id);
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function skillChipProposalSnapshot(database: any, id: string) {
|
async function skillChipProposalSnapshot(database: Database, id: string) {
|
||||||
const talent = await (
|
return skillChipSnapshot(database, id);
|
||||||
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 calculationProposalSnapshot(database: any, proposal: any) {
|
async function calculationProposalSnapshot(database: any, proposal: any) {
|
||||||
@@ -316,40 +288,55 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const proposalsWithSnapshots = await Promise.all(
|
const proposalsWithSnapshots = await Promise.all(
|
||||||
proposals.map(async (proposal) => ({
|
proposals.map(async (proposal) => ({
|
||||||
...proposal,
|
...proposal,
|
||||||
|
payloadValid: proposalPayloadIsValid(proposal.payload_json),
|
||||||
current_snapshot:
|
current_snapshot:
|
||||||
proposal.module === 'attributes' && proposal.entity_id
|
proposal.module === 'attributes' && proposal.entity_id
|
||||||
? ((await (
|
? ((await (
|
||||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||||
).get(String(proposal.entity_id))) ?? null)
|
).get(String(proposal.entity_id))) ?? null)
|
||||||
: proposal.module === 'skillchips' &&
|
: proposal.module === 'talents' &&
|
||||||
proposal.action === 'savePrimaryProfile'
|
proposal.action === 'saveTalent' &&
|
||||||
? await getSkillChipPrimaryProfiles(database)
|
proposal.entity_id
|
||||||
: proposal.module === 'skillchips' &&
|
? await talentOwnerAuditSnapshot(
|
||||||
proposal.action === 'saveSecondaryPool'
|
database,
|
||||||
? await getSkillChipSecondaryPool(database)
|
String(proposal.entity_id),
|
||||||
: proposal.module === 'weaponmods' &&
|
)
|
||||||
proposal.action === 'saveSlotIcon' &&
|
: proposal.module === 'protocols' &&
|
||||||
proposal.entity_id
|
proposal.action === 'save' &&
|
||||||
? {
|
proposal.entity_id
|
||||||
slot: weaponModSlotDefinitionById(
|
? await protocolSnapshot(String(proposal.entity_id), database)
|
||||||
String(proposal.entity_id),
|
: proposal.module === 'skillchips' &&
|
||||||
),
|
proposal.action === 'savePrimaryProfile'
|
||||||
icon: await gameDataIconMetadata(
|
? await getSkillChipPrimaryProfiles(database)
|
||||||
database,
|
: proposal.module === 'skillchips' &&
|
||||||
'weapon_mod_slot',
|
proposal.action === 'saveSecondaryPool'
|
||||||
String(proposal.entity_id),
|
? await getSkillChipSecondaryPool(database)
|
||||||
),
|
: proposal.module === 'weaponmods' &&
|
||||||
}
|
proposal.action === 'saveSlotIcon' &&
|
||||||
: proposal.module === 'skillchips' && proposal.entity_id
|
proposal.entity_id
|
||||||
? await skillChipProposalSnapshot(
|
? {
|
||||||
database,
|
slot: weaponModSlotDefinitionById(
|
||||||
String(proposal.entity_id),
|
String(proposal.entity_id),
|
||||||
)
|
),
|
||||||
: proposal.module === 'calculations'
|
icon: await gameDataIconMetadata(
|
||||||
? await calculationProposalSnapshot(database, proposal)
|
database,
|
||||||
: proposal.module === 'specializations'
|
'weapon_mod_slot',
|
||||||
? await specializationProposalSnapshot(database, proposal)
|
String(proposal.entity_id),
|
||||||
: null,
|
),
|
||||||
|
}
|
||||||
|
: 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 (
|
const summaryRows = await (
|
||||||
@@ -392,13 +379,19 @@ export const actions: Actions = {
|
|||||||
moderationError: 'The proposed corrections could not be read.',
|
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 (
|
if (
|
||||||
corrections.length > 250 ||
|
corrections.length > 250 ||
|
||||||
corrections.some(
|
corrections.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
!Number.isInteger(item.index) ||
|
!Number.isInteger(item.index) ||
|
||||||
item.index < 0 ||
|
item.index < 0 ||
|
||||||
item.value.length > 100_000,
|
Array.from(item.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
@@ -407,87 +400,150 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const proposal = await (
|
const correctProposal = database.transactionAsync(async (transaction) => {
|
||||||
await database.prepare(
|
const tx = transaction as unknown as Database;
|
||||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
const proposal = await (
|
||||||
)
|
await tx.prepare(
|
||||||
).get(id);
|
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||||
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',
|
|
||||||
)
|
)
|
||||||
).all(id)
|
).get(id);
|
||||||
).map((row) => String(row.depends_on_proposal_id));
|
if (!proposal) throw new ProposalNotPendingError();
|
||||||
const name = entries
|
|
||||||
.find((entry) => entry.kind === 'text' && entry.name === 'name')
|
let entries: SerializedEntry[];
|
||||||
?.value.trim();
|
try {
|
||||||
const summary = `${String(proposal.action)
|
entries = parseSerializedProposalEntries(String(proposal.payload_json));
|
||||||
.replace(/([A-Z])/g, ' $1')
|
} catch (error) {
|
||||||
.toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
|
if (error instanceof ProposalPayloadValidationError)
|
||||||
await (
|
return {
|
||||||
await database.prepare(
|
ok: false as const,
|
||||||
'UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?',
|
moderationError: INVALID_PROPOSAL_PAYLOAD_MESSAGE,
|
||||||
)
|
};
|
||||||
).run(payload, summary, id);
|
throw error;
|
||||||
const dependencies = await refreshProposalDependencies(id, payload);
|
}
|
||||||
await recordIsacAudit({
|
const changes: Array<{
|
||||||
actorId: reviewer.id,
|
index: number;
|
||||||
category: 'data_moderation',
|
name: string;
|
||||||
action: 'proposal.corrected',
|
before: string;
|
||||||
entityId: id,
|
after: string;
|
||||||
before: {
|
}> = [];
|
||||||
summary: proposal.summary,
|
for (const correction of corrections) {
|
||||||
dependencies: oldDependencies,
|
const entry = entries[correction.index];
|
||||||
fields: changes.map((change) => ({
|
if (
|
||||||
index: change.index,
|
entry &&
|
||||||
name: change.name,
|
(entry.name === 'id' ||
|
||||||
value: change.before,
|
entry.name === '_proposedEntityId' ||
|
||||||
})),
|
entry.name === 'expectedTalentId' ||
|
||||||
},
|
entry.name === 'expectedOwnerUpdatedAt' ||
|
||||||
after: {
|
entry.name === 'ranksJson')
|
||||||
summary,
|
)
|
||||||
dependencies,
|
return {
|
||||||
fields: changes.map((change) => ({
|
ok: false as const,
|
||||||
index: change.index,
|
moderationError: 'Proposal identities cannot be corrected.',
|
||||||
name: change.name,
|
};
|
||||||
value: change.after,
|
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() };
|
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||||
},
|
},
|
||||||
approve: async ({ cookies, request, fetch, url }) => {
|
approve: async ({ cookies, request, fetch, url }) => {
|
||||||
@@ -510,11 +566,81 @@ export const actions: Actions = {
|
|||||||
return fail(404, {
|
return fail(404, {
|
||||||
moderationError: 'That pending proposal no longer exists.',
|
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 (
|
const unresolved = await (
|
||||||
await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||||
FROM data_change_proposal_dependencies link
|
FROM data_change_proposal_dependencies link
|
||||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
|
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
|
||||||
).all(id);
|
).all(id);
|
||||||
if (unresolved.length) {
|
if (unresolved.length) {
|
||||||
const names = unresolved
|
const names = unresolved
|
||||||
@@ -528,7 +654,59 @@ export const actions: Actions = {
|
|||||||
`/isac/${proposal.module}?/${proposal.action}`,
|
`/isac/${proposal.module}?/${proposal.action}`,
|
||||||
url.origin,
|
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)
|
if (proposal.draft_entity_id && !proposal.entity_id)
|
||||||
proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
||||||
const response = await fetch(target, {
|
const response = await fetch(target, {
|
||||||
@@ -538,17 +716,11 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const details = (await response.text()).slice(0, 1000);
|
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
||||||
)
|
)
|
||||||
).run(
|
).run(reviewer.id, CANONICAL_FAILURE_MESSAGE, now, id);
|
||||||
reviewer.id,
|
|
||||||
details || note || 'Canonical validation failed.',
|
|
||||||
now,
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
await recordIsacAudit({
|
await recordIsacAudit({
|
||||||
actorId: reviewer.id,
|
actorId: reviewer.id,
|
||||||
category: 'data_moderation',
|
category: 'data_moderation',
|
||||||
@@ -557,15 +729,18 @@ export const actions: Actions = {
|
|||||||
before: { status: 'pending' },
|
before: { status: 'pending' },
|
||||||
after: { status: 'failed', responseStatus: response.status },
|
after: { status: 'failed', responseStatus: response.status },
|
||||||
});
|
});
|
||||||
await createNotification({
|
await notifySafely(
|
||||||
userId: String(proposal.proposed_by),
|
{
|
||||||
category: 'contributions',
|
userId: String(proposal.proposed_by),
|
||||||
source: 'ISAC DATA OVERSIGHT',
|
category: 'contributions',
|
||||||
title: 'Contribution could not be deployed',
|
source: 'ISAC DATA OVERSIGHT',
|
||||||
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
|
title: 'Contribution could not be deployed',
|
||||||
link: `/isac/${proposal.module}`,
|
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
|
||||||
mandatory: true,
|
link: `/isac/${proposal.module}`,
|
||||||
});
|
mandatory: true,
|
||||||
|
},
|
||||||
|
'contributor of failed proposal',
|
||||||
|
);
|
||||||
return fail(409, {
|
return fail(409, {
|
||||||
moderationError:
|
moderationError:
|
||||||
'The proposal did not pass the current canonical validation and was marked failed.',
|
'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.`,
|
body: `${proposal.summary} was approved and deployed to the live builder data set.`,
|
||||||
link: `/isac/${proposal.module}`,
|
link: `/isac/${proposal.module}`,
|
||||||
mandatory: true,
|
mandatory: true,
|
||||||
});
|
}).catch((error) =>
|
||||||
|
console.error('Unable to notify Data Oversight contributor', error),
|
||||||
|
);
|
||||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||||
},
|
},
|
||||||
reject: async ({ cookies, request }) => {
|
reject: async ({ cookies, request }) => {
|
||||||
@@ -616,46 +793,59 @@ export const actions: Actions = {
|
|||||||
moderationError: 'Add a brief reason for rejecting this proposal.',
|
moderationError: 'Add a brief reason for rejecting this proposal.',
|
||||||
});
|
});
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const proposal = await (
|
const rejectProposal = database.transactionAsync(async (transaction) => {
|
||||||
await database.prepare(
|
const tx = transaction as unknown as Database;
|
||||||
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
|
const proposal = await (
|
||||||
)
|
await tx.prepare(
|
||||||
).get(id);
|
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||||
if (!proposal)
|
)
|
||||||
return fail(404, {
|
).get(id);
|
||||||
moderationError: 'That pending proposal no longer exists.',
|
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();
|
return proposal as Record<string, unknown>;
|
||||||
await (
|
});
|
||||||
await database.prepare(
|
let proposal: Record<string, unknown>;
|
||||||
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
try {
|
||||||
)
|
proposal = await rejectProposal.immediate();
|
||||||
).run(reviewer.id, note, now, id);
|
} catch (error) {
|
||||||
await recordIsacAudit({
|
if (error instanceof ProposalNotPendingError)
|
||||||
actorId: reviewer.id,
|
return fail(404, { moderationError: error.message });
|
||||||
category: 'data_moderation',
|
throw error;
|
||||||
action: 'proposal.rejected',
|
}
|
||||||
entityId: id,
|
await notifySafely(
|
||||||
before: { status: 'pending' },
|
{
|
||||||
after: {
|
userId: String(proposal.proposed_by),
|
||||||
status: 'rejected',
|
category: 'contributions',
|
||||||
note,
|
source: 'ISAC DATA OVERSIGHT',
|
||||||
module: proposal.module,
|
title: 'Contribution needs revision',
|
||||||
action: proposal.action,
|
body: `${String(proposal.action)
|
||||||
proposedBy: proposal.proposed_by,
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.trim()} was not approved. Oversight reason: ${note}`,
|
||||||
|
link: `/isac/${proposal.module}`,
|
||||||
|
mandatory: true,
|
||||||
},
|
},
|
||||||
});
|
'contributor of rejected proposal',
|
||||||
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,
|
|
||||||
});
|
|
||||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,26 +5,57 @@
|
|||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
|
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
|
||||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.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 { data, form } = $props();
|
||||||
let reviewing = $state<any>(null);
|
let reviewing = $state<any>(null);
|
||||||
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
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('');
|
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 date = (value: string) => new Date(value).toLocaleString();
|
||||||
const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) =>
|
const evidence = (payload: string): EvidenceEntry[] => parseProposalPreviewPayload(payload).entries.reduce<EvidenceEntry[]>((result, entry) => {
|
||||||
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : 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 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 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 moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
|
||||||
const openReview = (proposal: any) => {
|
const openReview = (proposal: any) => {
|
||||||
reviewing = proposal;
|
reviewing = proposal;
|
||||||
decision = 'approve';
|
decision = 'approve';
|
||||||
correctionEntries = (JSON.parse(proposal.payload_json) as any[]).map((entry, index) => ({
|
correctionEntries = parseProposalPreviewPayload(String(proposal.payload_json)).entries
|
||||||
index, name: entry.name, value: entry.kind === 'text' ? entry.value : '', kind: entry.kind, fileName: entry.fileName
|
.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('{');
|
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(() => {
|
$effect(() => {
|
||||||
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
|
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="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="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>
|
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
|
||||||
{#if reviewing.contributor_note}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{reviewing.contributor_note}</p></div>{/if}
|
{#if renderBoundedPlainText(reviewing.contributor_note, MAX_CONTRIBUTOR_NOTE_CHARS)}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{renderBoundedPlainText(reviewing.contributor_note, MAX_CONTRIBUTOR_NOTE_CHARS)}</p></div>{/if}
|
||||||
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
|
{#if reviewing.payloadValid === false}
|
||||||
<details>
|
<div class="proposal-empty-state error" role="status">
|
||||||
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
|
<strong>PROPOSAL EVIDENCE UNAVAILABLE</strong>
|
||||||
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
|
<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>
|
||||||
</details>
|
</div>
|
||||||
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
|
{: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'}
|
{#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>
|
<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'}
|
{#if decision === 'correct' && reviewing.payloadValid === true}
|
||||||
<PendingForm action="?/correct">
|
<PendingForm action="?/correct">
|
||||||
{#snippet children(pending)}
|
{#snippet children(pending)}
|
||||||
<input type="hidden" name="id" value={reviewing.id}/><input type="hidden" name="corrections" value={correctionPayload()}/>
|
<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">
|
<div class="proposal-correction-grid">
|
||||||
{#each correctionEntries as entry}
|
{#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>
|
<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}
|
{:else}
|
||||||
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
|
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
|
||||||
{/if}
|
{/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>
|
<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}
|
{/snippet}
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
{:else}
|
{:else if decision === 'approve' || decision === 'reject'}
|
||||||
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
|
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
|
||||||
{#snippet children(pending)}
|
{#snippet children(pending)}
|
||||||
<input type="hidden" name="id" value={reviewing.id}/>
|
<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}
|
{#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="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>
|
<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}
|
{/snippet}
|
||||||
</PendingForm>
|
</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}
|
{/if}
|
||||||
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
|
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
|
||||||
</div>
|
</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 {
|
import {
|
||||||
requireDataView,
|
requireDataView,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
@@ -13,110 +13,38 @@ import {
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import {
|
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||||
applyIconMutation,
|
import { managedImageUrl } from '$lib/server/managed-images';
|
||||||
gameDataIconMetadata,
|
|
||||||
gameDataIconUrl,
|
|
||||||
GameDataIconValidationError,
|
|
||||||
readIconMutation,
|
|
||||||
} from '$lib/server/game-data-icons';
|
|
||||||
import {
|
|
||||||
applyGameDataRender,
|
|
||||||
ManagedImageValidationError,
|
|
||||||
managedImageUrl,
|
|
||||||
processManagedImage,
|
|
||||||
readGameDataRenderMutation,
|
|
||||||
} from '$lib/server/managed-images';
|
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
import {
|
import {
|
||||||
conditionalRuleSnapshot,
|
MechanicSourceFingerprintConflictError,
|
||||||
loadConditionalRuleDraft,
|
} from '$lib/server/mechanic-authored-sources';
|
||||||
parseConditionalRulePayload,
|
import {
|
||||||
saveConditionalRuleInTransaction,
|
ProtocolFormValidationError,
|
||||||
} from '$lib/server/conditional-effects';
|
ProtocolNotFoundError,
|
||||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
loadProtocolTalentMechanic,
|
||||||
|
parseProtocolForm,
|
||||||
|
protocolSnapshot,
|
||||||
|
saveProtocolInTransaction,
|
||||||
|
} from './protocol-save';
|
||||||
import {
|
import {
|
||||||
getOsProtocolProfileAttributeIds,
|
getOsProtocolProfileAttributeIds,
|
||||||
type OsProtocolAttributeType,
|
type OsProtocolAttributeType,
|
||||||
} from '$lib/server/os-protocol-profiles';
|
} from '$lib/server/os-protocol-profiles';
|
||||||
import type { Database } from '@tursodatabase/database';
|
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 qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||||
const secondaryRoles = ['Core', 'Damage', 'Health'];
|
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) {
|
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
|
||||||
const database = suppliedDatabase ?? (await getDatabase());
|
const database = suppliedDatabase ?? (await getDatabase());
|
||||||
return {
|
return {
|
||||||
@@ -221,7 +149,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
profiles,
|
profiles,
|
||||||
secondaryEffects,
|
secondaryEffects,
|
||||||
talents,
|
talents,
|
||||||
talentEffects,
|
|
||||||
stats,
|
stats,
|
||||||
icons,
|
icons,
|
||||||
renders,
|
renders,
|
||||||
@@ -243,10 +170,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
|
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
|
||||||
(
|
(
|
||||||
await database.prepare(
|
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(),
|
).all(),
|
||||||
(
|
(
|
||||||
@@ -282,8 +208,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'primaryBaseValue',
|
'primaryBaseValue',
|
||||||
'hasTalent',
|
'hasTalent',
|
||||||
'talentName',
|
'talentName',
|
||||||
'descriptionTemplate',
|
'expectedTalentId',
|
||||||
'conditionalRule',
|
'authoredSource',
|
||||||
|
'expectedSourceFingerprint',
|
||||||
|
'mechanicSelectionIntent',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@@ -294,6 +222,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(effect) => effect.profile_id === profile.id,
|
(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
|
const pendingProtocols = pending
|
||||||
.filter((item) => item.kind === 'protocol')
|
.filter((item) => item.kind === 'protocol')
|
||||||
.filter((item) =>
|
.filter((item) =>
|
||||||
@@ -334,14 +270,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
protocol.hasTalent === '1'
|
protocol.hasTalent === '1'
|
||||||
? {
|
? {
|
||||||
name: protocol.talentName,
|
name: protocol.talentName,
|
||||||
description_template: protocol.descriptionTemplate,
|
authoredSource: null,
|
||||||
conditionalRule: (() => {
|
expectedSourceFingerprint: null,
|
||||||
try {
|
selectedRevision: null,
|
||||||
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
|
modelDraft: null,
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
@@ -381,15 +313,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
effects: talentEffects.filter(
|
authoredSource:
|
||||||
(item) => item.talent_id === talent.id,
|
talentMechanics.get(String(talent.id))?.authoredSource.source ??
|
||||||
),
|
null,
|
||||||
conditionalRule: await loadConditionalRuleDraft(
|
expectedSourceFingerprint:
|
||||||
database,
|
talentMechanics.get(String(talent.id))?.authoredSource
|
||||||
'os_protocol_talent',
|
.fingerprint ?? null,
|
||||||
String(talent.id),
|
selectedRevision:
|
||||||
['base'],
|
talentMechanics.get(String(talent.id))?.selectedRevision ?? null,
|
||||||
),
|
publicProjection:
|
||||||
|
talentMechanics.get(String(talent.id))?.projection ?? [],
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
@@ -532,357 +465,100 @@ export const actions: Actions = {
|
|||||||
'data.manage',
|
'data.manage',
|
||||||
);
|
);
|
||||||
const form = await request.formData();
|
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();
|
const database = await getDatabase();
|
||||||
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
const journal = new MediaMutationJournal();
|
||||||
null;
|
|
||||||
try {
|
try {
|
||||||
if (hasTalent) {
|
const saveProtocol = database.transactionAsync(async (transaction) => {
|
||||||
const conditionalRuleRaw = form.get('conditionalRule');
|
const tx = transaction as unknown as Database;
|
||||||
const activeStats = await (
|
const input = await parseProtocolForm(tx, form, operator.id);
|
||||||
await database.prepare(
|
return saveProtocolInTransaction(tx, { ...input, journal });
|
||||||
'SELECT id FROM stat_attributes WHERE active=1',
|
});
|
||||||
)
|
const result = await saveProtocol.immediate();
|
||||||
).all();
|
journal.clear();
|
||||||
conditionalRule = parseConditionalRulePayload(
|
return {
|
||||||
conditionalRuleRaw,
|
protocolSaved: true,
|
||||||
[{ key: 'base', variantKey: 'base' }],
|
protocolMode: result.mode,
|
||||||
{
|
resultToken: randomUUID(),
|
||||||
allowedAttributeIds: new Set(
|
};
|
||||||
activeStats.map((row) => String(row.id)),
|
|
||||||
),
|
|
||||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return fail(400, {
|
await journal.rollback(error);
|
||||||
protocolError:
|
if (error instanceof ProtocolFormValidationError)
|
||||||
error instanceof Error
|
return fail(error.status, { protocolError: error.message });
|
||||||
? error.message
|
if (error instanceof MechanicSourceFingerprintConflictError)
|
||||||
: 'The conditional mechanic is invalid.',
|
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 }) => {
|
archive: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
await getSessionUser(cookies),
|
await getSessionUser(cookies),
|
||||||
'data.manage',
|
'data.manage',
|
||||||
);
|
);
|
||||||
const id = String((await request.formData()).get('id') ?? '');
|
const id = String((await request.formData()).get('id') ?? '').trim();
|
||||||
const before = await protocolSnapshot(id);
|
const database = await getDatabase();
|
||||||
if (!before.record)
|
const archiveProtocol = database.transactionAsync(async (transaction) => {
|
||||||
return fail(404, { protocolError: 'OS Protocol not found.' });
|
const tx = transaction as unknown as Database;
|
||||||
await (
|
const before = await protocolSnapshot(id, tx);
|
||||||
|
if (!before.record) throw new ProtocolNotFoundError();
|
||||||
await (
|
await (
|
||||||
await getDatabase()
|
await tx.prepare(
|
||||||
).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')
|
'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?',
|
||||||
).run(new Date().toISOString(), id);
|
)
|
||||||
await recordIsacAudit({
|
).run(new Date().toISOString(), id);
|
||||||
actorId: operator.id,
|
await recordIsacAudit({
|
||||||
category: 'game_data',
|
actorId: operator.id,
|
||||||
action: 'os_protocol.archived',
|
category: 'game_data',
|
||||||
entityId: id,
|
action: 'os_protocol.archived',
|
||||||
before,
|
entityId: id,
|
||||||
after: await protocolSnapshot(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 ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.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';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -24,6 +26,11 @@
|
|||||||
Health: 'Health'
|
Health: 'Health'
|
||||||
});
|
});
|
||||||
const talentValueColumns = [{ key: 'base', variantKey: 'base', label: 'BASE' }];
|
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 => ({
|
const blankRule = (): ConditionalMechanicDraftV1 => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
simulationReadiness: 'captured_only',
|
simulationReadiness: 'captured_only',
|
||||||
@@ -31,8 +38,28 @@
|
|||||||
pools: [],
|
pools: [],
|
||||||
clauses: []
|
clauses: []
|
||||||
});
|
});
|
||||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
type ProtocolAuthoredFormSource = Pick<MechanicAuthoredSource, 'version' | 'template' | 'variants'>;
|
||||||
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() });
|
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 editing = $state<any | null>(null);
|
||||||
let profileEditing = $state<any | null>(null);
|
let profileEditing = $state<any | null>(null);
|
||||||
let talentUpgrade = $state(0);
|
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 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 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 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)
|
.flatMap((clause: any) => clause.outcomes)
|
||||||
.filter((outcome: any) => outcome.descriptionSlot)
|
.filter((outcome: any) => outcome.descriptionSlot)
|
||||||
.sort((left: any, right: any) => left.descriptionSlot - right.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 authoredPreview = () => editing.talent.authoredSource.variants.find((variant: any) => variant.key === 'base')?.materialized ?? editing.talent.authoredSource.template;
|
||||||
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
|
const talentPreview = (upgrades: number) => {
|
||||||
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
|
if (editing.talent.mechanicSelectionIntent !== 'save') return authoredPreview();
|
||||||
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
|
return editing.talent.authoredSource.template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
|
||||||
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
|
const duration = editing.talent.modelDraft.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
|
||||||
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
|
const maxStacks = editing.talent.modelDraft.pools.length ? Math.max(...editing.talent.modelDraft.pools.map((pool: any) => pool.max ?? 1)) : null;
|
||||||
const baseValue = outcome?.variants?.base?.value;
|
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
|
||||||
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
|
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
|
||||||
.includes('protocolTalentUpgrades');
|
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
|
||||||
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
|
const baseValue = outcome?.variants?.base?.value;
|
||||||
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
|
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
|
||||||
: placeholder;
|
.includes('protocolTalentUpgrades');
|
||||||
});
|
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
|
||||||
|
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
|
||||||
|
: placeholder;
|
||||||
|
});
|
||||||
|
};
|
||||||
const editProfile = (profile: any) => {
|
const editProfile = (profile: any) => {
|
||||||
profileEditing = {
|
profileEditing = {
|
||||||
...profile,
|
...profile,
|
||||||
@@ -84,15 +118,34 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
};
|
};
|
||||||
const edit = (protocol: any) => {
|
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 = {
|
editing = {
|
||||||
...protocol,
|
...protocol,
|
||||||
|
expectedOwnerUpdatedAt: protocol.updated_at ?? '',
|
||||||
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
|
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
|
||||||
hasTalent: Boolean(protocol.talent),
|
hasTalent: Boolean(protocol.talent),
|
||||||
talentOpen: false,
|
talentOpen: false,
|
||||||
talent: protocol.talent ? {
|
talent
|
||||||
...protocol.talent,
|
|
||||||
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
|
|
||||||
} : blankTalent()
|
|
||||||
};
|
};
|
||||||
profileEditing = null;
|
profileEditing = null;
|
||||||
talentUpgrade = 0;
|
talentUpgrade = 0;
|
||||||
@@ -108,6 +161,11 @@
|
|||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
profileEditing = null;
|
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) {
|
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
if (form.protocolMode === 'add') {
|
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>
|
<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">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<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="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="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="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">
|
<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>
|
<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}
|
{#if editing.hasTalent}
|
||||||
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
|
<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="modeling-content">
|
||||||
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label></div>
|
||||||
<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>
|
<AuthoredDescriptionEditor
|
||||||
<MechanicProgramEditor
|
idPrefix={`protocol-talent-authored-${editing.talent.id || 'new'}`}
|
||||||
attributes={data.stats}
|
variantLabels={talentValueColumns}
|
||||||
valueColumns={talentValueColumns}
|
bind:template={editing.talent.authoredSource.template}
|
||||||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
bind:variants={editing.talent.authoredSource.variants}
|
||||||
aiContext={{
|
|
||||||
sourceKind: 'os_protocol_talent',
|
|
||||||
sourceName: editing?.talent?.name ?? '',
|
|
||||||
sourceDescription: editing?.talent?.description_template ?? ''
|
|
||||||
}}
|
|
||||||
allowProtocolUpgradeScaling
|
|
||||||
bind:rule={editing.talent.conditionalRule}
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -213,7 +299,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
|
{#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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -224,7 +310,7 @@
|
|||||||
<div class="dashboard-table protocol-dashboard-table">
|
<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>
|
<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}
|
{#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}
|
{#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}
|
{: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>
|
</div>
|
||||||
|
|||||||
@@ -58,13 +58,6 @@ vi.mock('$lib/server/managed-images', () => ({
|
|||||||
readGameDataRenderMutation: vi.fn(),
|
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';
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
const request = (includeValues = true) => {
|
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 mocks = vi.hoisted(() => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
const applyIconMutation = vi.fn();
|
||||||
const database = {
|
const database = {
|
||||||
exec: vi.fn(async (sql: string) => {
|
exec: vi.fn(async (sql: string) => {
|
||||||
events.push(sql);
|
events.push(sql);
|
||||||
@@ -35,17 +36,34 @@ const mocks = vi.hoisted(() => {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
|
applyIconMutation,
|
||||||
talentExists: false,
|
talentExists: false,
|
||||||
profileConfigured: true,
|
profileConfigured: true,
|
||||||
auditRecord: vi.fn(),
|
auditRecord: vi.fn(),
|
||||||
conditionalRuleSnapshot: vi.fn(),
|
loadMechanicAuthoredSource: vi.fn(async () => null),
|
||||||
parseConditionalRulePayload: vi.fn(),
|
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 () => {
|
recordIsacAudit: vi.fn(async () => {
|
||||||
events.push('audit');
|
events.push('audit');
|
||||||
}),
|
}),
|
||||||
saveConditionalRuleInTransaction: vi.fn(async () => {
|
|
||||||
events.push('save conditional rule');
|
|
||||||
}),
|
|
||||||
database,
|
database,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -81,7 +99,7 @@ vi.mock('$lib/server/os-protocol-profiles', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/game-data-icons', () => ({
|
vi.mock('$lib/server/game-data-icons', () => ({
|
||||||
applyIconMutation: vi.fn(),
|
applyIconMutation: mocks.applyIconMutation,
|
||||||
gameDataIconMetadata: vi.fn(async () => null),
|
gameDataIconMetadata: vi.fn(async () => null),
|
||||||
gameDataIconUrl: vi.fn(() => ''),
|
gameDataIconUrl: vi.fn(() => ''),
|
||||||
GameDataIconValidationError: class extends Error {},
|
GameDataIconValidationError: class extends Error {},
|
||||||
@@ -96,11 +114,30 @@ vi.mock('$lib/server/managed-images', () => ({
|
|||||||
readGameDataRenderMutation: vi.fn(async () => ({})),
|
readGameDataRenderMutation: vi.fn(async () => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/conditional-effects', () => ({
|
vi.mock('$lib/server/mechanic-authored-sources', () => ({
|
||||||
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
|
loadMechanicAuthoredSource: mocks.loadMechanicAuthoredSource,
|
||||||
loadConditionalRuleDraft: vi.fn(),
|
mechanicAuthoredSourceSnapshot: mocks.mechanicAuthoredSourceSnapshot,
|
||||||
parseConditionalRulePayload: mocks.parseConditionalRulePayload,
|
MechanicSourceFingerprintConflictError:
|
||||||
saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction,
|
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';
|
import { actions } from './+page.server';
|
||||||
@@ -113,6 +150,7 @@ const protocolRecord = {
|
|||||||
quality: 'Standard',
|
quality: 'Standard',
|
||||||
primary_attribute_type: 'Firepower',
|
primary_attribute_type: 'Firepower',
|
||||||
active: 1,
|
active: 1,
|
||||||
|
updated_at: '2026-08-02T12:00:00.000Z',
|
||||||
};
|
};
|
||||||
const primaryEffect = {
|
const primaryEffect = {
|
||||||
protocol_id: protocolId,
|
protocol_id: protocolId,
|
||||||
@@ -123,7 +161,25 @@ const talentRecord = {
|
|||||||
id: talentId,
|
id: talentId,
|
||||||
protocol_id: protocolId,
|
protocol_id: protocolId,
|
||||||
name: 'Escalation Loop',
|
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 = ({
|
const saveRequest = ({
|
||||||
@@ -131,11 +187,17 @@ const saveRequest = ({
|
|||||||
hasTalent = false,
|
hasTalent = false,
|
||||||
confirmRemoval = false,
|
confirmRemoval = false,
|
||||||
includePrimaryValue = true,
|
includePrimaryValue = true,
|
||||||
|
selection = 'clear',
|
||||||
|
expectedTalentId = id && mocks.talentExists ? talentId : '',
|
||||||
|
expectedOwnerUpdatedAt = id ? protocolRecord.updated_at : '',
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
hasTalent?: boolean;
|
hasTalent?: boolean;
|
||||||
confirmRemoval?: boolean;
|
confirmRemoval?: boolean;
|
||||||
includePrimaryValue?: boolean;
|
includePrimaryValue?: boolean;
|
||||||
|
selection?: 'preserve' | 'save' | 'clear';
|
||||||
|
expectedTalentId?: string;
|
||||||
|
expectedOwnerUpdatedAt?: string;
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('id', id);
|
form.set('id', id);
|
||||||
@@ -145,10 +207,15 @@ const saveRequest = ({
|
|||||||
form.set('primaryStatId', 'weapon-damage');
|
form.set('primaryStatId', 'weapon-damage');
|
||||||
if (includePrimaryValue) form.set('primaryBaseValue', '5');
|
if (includePrimaryValue) form.set('primaryBaseValue', '5');
|
||||||
form.set('hasTalent', hasTalent ? '1' : '0');
|
form.set('hasTalent', hasTalent ? '1' : '0');
|
||||||
|
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
|
||||||
|
form.set('expectedTalentId', expectedTalentId);
|
||||||
if (hasTalent) {
|
if (hasTalent) {
|
||||||
form.set('talentName', 'Escalation Loop');
|
form.set('talentName', 'Escalation Loop');
|
||||||
form.set('descriptionTemplate', 'Captured Talent');
|
form.set('authoredSource', JSON.stringify(authoredPayload));
|
||||||
form.set('conditionalRule', JSON.stringify(capturedRule));
|
form.set('expectedSourceFingerprint', '');
|
||||||
|
form.set('mechanicSelectionIntent', selection);
|
||||||
|
if (selection === 'save')
|
||||||
|
form.set('conditionalRule', JSON.stringify(capturedRule));
|
||||||
}
|
}
|
||||||
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
||||||
return new Request('http://localhost/isac/protocols?/save', {
|
return new Request('http://localhost/isac/protocols?/save', {
|
||||||
@@ -169,16 +236,25 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
mocks.events.length = 0;
|
mocks.events.length = 0;
|
||||||
mocks.talentExists = false;
|
mocks.talentExists = false;
|
||||||
mocks.profileConfigured = true;
|
mocks.profileConfigured = true;
|
||||||
mocks.parseConditionalRulePayload.mockReturnValue(capturedRule);
|
mocks.parseMechanicAuthoredSourcePayload.mockImplementation(
|
||||||
mocks.conditionalRuleSnapshot.mockResolvedValue({
|
(_payload, owner) => ({ ...authoredSource, owner }),
|
||||||
id: 'rule-1',
|
);
|
||||||
source_id: talentId,
|
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.auditRecord.mockResolvedValue(protocolRecord);
|
||||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
get: vi.fn(async (...args: unknown[]) => {
|
get: vi.fn(async (...args: unknown[]) => {
|
||||||
if (sql.includes('FROM os_protocol_secondary_profiles'))
|
if (sql.includes('FROM os_protocol_secondary_profiles'))
|
||||||
return mocks.profileConfigured ? { id: 'profile-1' } : null;
|
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_protocols WHERE name=')) return null;
|
||||||
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
|
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
|
||||||
return mocks.talentExists ? talentRecord : null;
|
return mocks.talentExists ? talentRecord : null;
|
||||||
@@ -194,8 +270,6 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
run: vi.fn(async () => {
|
run: vi.fn(async () => {
|
||||||
if (sql.includes('INSERT INTO os_protocol_talents'))
|
if (sql.includes('INSERT INTO os_protocol_talents'))
|
||||||
mocks.talentExists = true;
|
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')) {
|
if (sql.includes('DELETE FROM os_protocol_talents')) {
|
||||||
mocks.events.push('delete talent');
|
mocks.events.push('delete talent');
|
||||||
mocks.talentExists = false;
|
mocks.talentExists = false;
|
||||||
@@ -212,8 +286,8 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
protocolMode: 'add',
|
protocolMode: 'add',
|
||||||
resultToken: expect.any(String),
|
resultToken: expect.any(String),
|
||||||
});
|
});
|
||||||
expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled();
|
expect(mocks.parseMechanicAuthoredSourcePayload).not.toHaveBeenCalled();
|
||||||
expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled();
|
expect(mocks.persistMechanicOwnerStateInTransaction).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
mocks.database.prepare.mock.calls.some(([sql]) =>
|
mocks.database.prepare.mock.calls.some(([sql]) =>
|
||||||
String(sql).includes('INSERT INTO os_protocol_talents'),
|
String(sql).includes('INSERT INTO os_protocol_talents'),
|
||||||
@@ -225,7 +299,7 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
category: 'game_data',
|
category: 'game_data',
|
||||||
action: 'os_protocol.created',
|
action: 'os_protocol.created',
|
||||||
before: null,
|
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.',
|
'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();
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,35 +325,116 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
const result = await runSave({ includePrimaryValue: false });
|
const result = await runSave({ includePrimaryValue: false });
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 400 });
|
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();
|
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 });
|
const result = await runSave({ hasTalent: true });
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
protocolSaved: true,
|
protocolSaved: true,
|
||||||
protocolMode: 'add',
|
protocolMode: 'add',
|
||||||
});
|
});
|
||||||
expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce();
|
expect(mocks.parseMechanicAuthoredSourcePayload).toHaveBeenCalledOnce();
|
||||||
expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith(
|
expect(mocks.parseMechanicSelectionIntent).toHaveBeenCalledWith(
|
||||||
mocks.database,
|
'clear',
|
||||||
'os_protocol_talent',
|
null,
|
||||||
expect.any(String),
|
expect.objectContaining({ variantKeys: ['base'] }),
|
||||||
capturedRule,
|
|
||||||
);
|
);
|
||||||
|
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)(
|
it.each(['ready', 'requires_assumptions'] as const)(
|
||||||
'rejects an empty %s mechanic program',
|
'rejects an empty %s mechanic program',
|
||||||
async (simulationReadiness) => {
|
async (simulationReadiness) => {
|
||||||
mocks.parseConditionalRulePayload.mockReturnValue({
|
mocks.parseMechanicSelectionIntent.mockImplementationOnce(() => {
|
||||||
...capturedRule,
|
throw new Error(
|
||||||
simulationReadiness,
|
'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({
|
expect(result).toMatchObject({
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -287,11 +443,40 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
|
'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();
|
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 () => {
|
it('requires confirmation before removing an existing Talent', async () => {
|
||||||
mocks.talentExists = true;
|
mocks.talentExists = true;
|
||||||
|
|
||||||
@@ -304,11 +489,12 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
'Confirm removal of the existing Talent and mechanic program.',
|
'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();
|
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;
|
mocks.talentExists = true;
|
||||||
|
|
||||||
const result = await runSave({
|
const result = await runSave({
|
||||||
@@ -320,13 +506,12 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
protocolSaved: true,
|
protocolSaved: true,
|
||||||
protocolMode: 'edit',
|
protocolMode: 'edit',
|
||||||
});
|
});
|
||||||
expect(mocks.events).toEqual([
|
expect(mocks.events).toEqual(['BEGIN', 'delete talent', 'audit', 'COMMIT']);
|
||||||
'BEGIN',
|
expect(
|
||||||
'delete conditional rule',
|
mocks.database.prepare.mock.calls.some(([sql]) =>
|
||||||
'delete talent',
|
String(sql).includes('DELETE FROM mechanic_authored_sources'),
|
||||||
'audit',
|
),
|
||||||
'COMMIT',
|
).toBe(false);
|
||||||
]);
|
|
||||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
actorId: 'operator-1',
|
actorId: 'operator-1',
|
||||||
@@ -334,10 +519,14 @@ describe('OS Protocol optional Talent action', () => {
|
|||||||
action: 'os_protocol.updated',
|
action: 'os_protocol.updated',
|
||||||
entityId: protocolId,
|
entityId: protocolId,
|
||||||
before: expect.objectContaining({
|
before: expect.objectContaining({
|
||||||
talent: talentRecord,
|
talent: expect.objectContaining({
|
||||||
conditional: expect.objectContaining({ source_id: talentId }),
|
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 { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
import {
|
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||||
applyIconMutation,
|
import { managedImageUrl } from '$lib/server/managed-images';
|
||||||
gameDataIconMetadata,
|
|
||||||
gameDataIconUrl,
|
|
||||||
GameDataIconValidationError,
|
|
||||||
readIconMutation,
|
|
||||||
} from '$lib/server/game-data-icons';
|
|
||||||
import {
|
|
||||||
applyGameDataRender,
|
|
||||||
ManagedImageValidationError,
|
|
||||||
managedImageUrl,
|
|
||||||
processManagedImage,
|
|
||||||
readGameDataRenderMutation,
|
|
||||||
} from '$lib/server/managed-images';
|
|
||||||
import {
|
import {
|
||||||
getPagination,
|
getPagination,
|
||||||
getRegistrySearch,
|
getRegistrySearch,
|
||||||
@@ -29,16 +17,28 @@ import {
|
|||||||
refreshAbilityFocuses,
|
refreshAbilityFocuses,
|
||||||
refreshSpecializationFocusReady,
|
refreshSpecializationFocusReady,
|
||||||
} from '$lib/server/specialization-readiness';
|
} from '$lib/server/specialization-readiness';
|
||||||
|
import {
|
||||||
|
SkillChipFormValidationError,
|
||||||
|
parseSkillChipForm,
|
||||||
|
_saveSkillChipInTransaction,
|
||||||
|
SkillChipNotFoundError,
|
||||||
|
loadTalent,
|
||||||
|
refreshAllFocusReadiness,
|
||||||
|
snapshot,
|
||||||
|
} from './skillchip-save';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import {
|
import {
|
||||||
conditionalRuleSnapshot,
|
loadMechanicAuthoredSource,
|
||||||
loadConditionalRuleDraft,
|
MechanicSourceFingerprintConflictError,
|
||||||
parseConditionalRulePayload,
|
} from '$lib/server/mechanic-authored-sources';
|
||||||
saveConditionalRuleInTransaction,
|
import {
|
||||||
} from '$lib/server/conditional-effects';
|
loadSelectedMechanicModelRevision,
|
||||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
} 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 {
|
import {
|
||||||
normalizeSkillChipProfiles,
|
normalizeSkillChipProfiles,
|
||||||
SKILL_CHIP_QUALITIES,
|
SKILL_CHIP_QUALITIES,
|
||||||
@@ -52,87 +52,7 @@ import {
|
|||||||
replaceSkillChipSecondaryPool,
|
replaceSkillChipSecondaryPool,
|
||||||
} from '$lib/server/skill-chip-profiles';
|
} from '$lib/server/skill-chip-profiles';
|
||||||
|
|
||||||
const mechanicOutcomes = (
|
export { _saveSkillChipInTransaction } from './skillchip-save';
|
||||||
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 const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const user = await requireDataView(await getSessionUser(cookies));
|
const user = await requireDataView(await getSessionUser(cookies));
|
||||||
@@ -240,8 +160,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'twoPieceMax',
|
'twoPieceMax',
|
||||||
'hasTalent',
|
'hasTalent',
|
||||||
'talentName',
|
'talentName',
|
||||||
'descriptionTemplate',
|
'expectedTalentId',
|
||||||
'conditionalRule',
|
'authoredSource',
|
||||||
|
'expectedSourceFingerprint',
|
||||||
|
'mechanicSelectionIntent',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@@ -266,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
sets,
|
sets,
|
||||||
twoPiece,
|
twoPiece,
|
||||||
talents,
|
talents,
|
||||||
talentEffects,
|
|
||||||
stats,
|
stats,
|
||||||
abilities,
|
abilities,
|
||||||
specializations,
|
specializations,
|
||||||
@@ -285,10 +206,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).all(),
|
||||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
|
||||||
(
|
(
|
||||||
await database.prepare(
|
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(),
|
).all(),
|
||||||
(
|
(
|
||||||
@@ -358,7 +279,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const currentPendingSets = pendingSets.filter(
|
const currentPendingSets = pendingSets.filter(
|
||||||
(set) =>
|
(set) =>
|
||||||
Boolean(set.abilityId) &&
|
Boolean(set.abilityId) &&
|
||||||
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
|
(String(set.hasTalent) !== '1' || Boolean(set.authoredSource)),
|
||||||
);
|
);
|
||||||
const pendingSetRows = requestedItemId
|
const pendingSetRows = requestedItemId
|
||||||
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
||||||
@@ -387,16 +308,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
String(set.hasTalent) === '1'
|
String(set.hasTalent) === '1'
|
||||||
? {
|
? {
|
||||||
name: set.talentName,
|
name: set.talentName,
|
||||||
description_template: set.descriptionTemplate,
|
authoredSource: (() => {
|
||||||
conditionalRule: (() => {
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(
|
return JSON.parse(String(set.authoredSource ?? 'null'));
|
||||||
String(set.conditionalRule ?? 'null'),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
|
expectedSourceFingerprint:
|
||||||
|
String(set.expectedSourceFingerprint ?? ''),
|
||||||
|
selectedRevision: null,
|
||||||
|
publicProjection: [],
|
||||||
|
mechanicSelectionIntent:
|
||||||
|
String(set.mechanicSelectionIntent ?? 'clear'),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
@@ -404,6 +328,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
sets.map(async (set) => {
|
sets.map(async (set) => {
|
||||||
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
|
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
|
||||||
const icon = icons.find((item) => item.entity_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 {
|
return {
|
||||||
...set,
|
...set,
|
||||||
iconUrl: icon
|
iconUrl: icon
|
||||||
@@ -422,15 +359,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
effects: talentEffects.filter(
|
authoredSource: authoredSource
|
||||||
(item) => item.talent_id === talent.id,
|
? {
|
||||||
),
|
version: authoredSource.source.version,
|
||||||
conditionalRule: await loadConditionalRuleDraft(
|
template: authoredSource.source.template,
|
||||||
database,
|
variants: authoredSource.source.variants,
|
||||||
'skill_chip_talent',
|
}
|
||||||
String(talent.id),
|
: null,
|
||||||
['minimum', 'maximum'],
|
expectedSourceFingerprint: authoredSource?.fingerprint ?? null,
|
||||||
),
|
selectedRevision,
|
||||||
|
publicProjection,
|
||||||
|
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
@@ -472,340 +411,31 @@ export const actions: Actions = {
|
|||||||
'data.manage',
|
'data.manage',
|
||||||
);
|
);
|
||||||
const form = await request.formData();
|
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();
|
const database = await getDatabase();
|
||||||
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
const journal = new MediaMutationJournal();
|
||||||
null;
|
|
||||||
try {
|
try {
|
||||||
if (hasTalent) {
|
const save = database.transactionAsync(async (transaction) => {
|
||||||
const conditionalRuleRaw = form.get('conditionalRule');
|
const tx = transaction as unknown as Database;
|
||||||
const activeStats = await (
|
const input = await parseSkillChipForm(tx, form, operator.id);
|
||||||
await database.prepare(
|
return _saveSkillChipInTransaction(tx, { ...input, journal });
|
||||||
'SELECT id FROM stat_attributes WHERE active=1',
|
});
|
||||||
)
|
const result = await save.immediate();
|
||||||
).all();
|
journal.clear();
|
||||||
conditionalRule = parseConditionalRulePayload(
|
return {
|
||||||
conditionalRuleRaw,
|
skillChipSaved: true,
|
||||||
[
|
skillChipMode: result.mode,
|
||||||
{ key: 'minimum', variantKey: 'minimum' },
|
resultToken: randomUUID(),
|
||||||
{ key: 'maximum', variantKey: 'maximum' },
|
};
|
||||||
],
|
|
||||||
{
|
|
||||||
allowedAttributeIds: new Set(
|
|
||||||
activeStats.map((row) => String(row.id)),
|
|
||||||
),
|
|
||||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return fail(400, {
|
await journal.rollback(error);
|
||||||
skillChipError:
|
if (error instanceof SkillChipFormValidationError)
|
||||||
error instanceof Error
|
return fail(error.status, { skillChipError: error.message });
|
||||||
? error.message
|
if (error instanceof MechanicSourceFingerprintConflictError)
|
||||||
: 'The conditional mechanic is invalid.',
|
return fail(409, { skillChipError: error.message });
|
||||||
});
|
if (error instanceof SkillChipNotFoundError)
|
||||||
}
|
return fail(404, { skillChipError: error.message });
|
||||||
if (name.length < 2 || name.length > 80)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'Skill Chip names must be 2–80 characters.',
|
|
||||||
});
|
|
||||||
if (!abilityId)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'Assign this Skill Chip to a specialization ability.',
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!twoPieceStatId ||
|
|
||||||
!Number.isFinite(twoPieceMin) ||
|
|
||||||
!Number.isFinite(twoPieceMax) ||
|
|
||||||
twoPieceMax < twoPieceMin
|
|
||||||
)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'Configure a valid 2-piece stat range.',
|
|
||||||
});
|
|
||||||
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
|
||||||
const numberedOutcomes = conditionalRule
|
|
||||||
? descriptionOutcomes(conditionalRule)
|
|
||||||
: [];
|
|
||||||
const durationSeconds =
|
|
||||||
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
|
||||||
?.expiresAfterSeconds ?? null;
|
|
||||||
const maxStacks = conditionalRule?.pools.length
|
|
||||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
hasTalent &&
|
|
||||||
(talentName.length < 2 ||
|
|
||||||
talentName.length > 80 ||
|
|
||||||
!descriptionTemplate ||
|
|
||||||
!conditionalRule?.clauses.length ||
|
|
||||||
!outcomes.length)
|
|
||||||
)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
hasTalent &&
|
|
||||||
outcomes.some(
|
|
||||||
(outcome) =>
|
|
||||||
outcome.variants.maximum.value < outcome.variants.minimum.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'Every talent outcome maximum must be at least its minimum.',
|
|
||||||
});
|
|
||||||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
|
|
||||||
(match) => Number(match[1]),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
hasTalent &&
|
|
||||||
placeholders.some(
|
|
||||||
(index) =>
|
|
||||||
!numberedOutcomes.some(
|
|
||||||
(outcome) => outcome.descriptionSlot === index,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'Description placeholders must reference configured numbered outcomes.',
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
hasTalent &&
|
|
||||||
descriptionTemplate.includes('{duration}') &&
|
|
||||||
durationSeconds === null
|
|
||||||
)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'The description uses {duration}, but no expiring state pool is defined.',
|
|
||||||
});
|
|
||||||
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!(await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
|
|
||||||
)
|
|
||||||
).get(abilityId))
|
|
||||||
) {
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'Choose an active specialization ability.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const assignedCount = Number(
|
|
||||||
(
|
|
||||||
await (
|
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
|
||||||
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
|
|
||||||
).get(abilityId, id || 'new')
|
|
||||||
)?.count ?? 0,
|
|
||||||
);
|
|
||||||
if (assignedCount >= 3) {
|
|
||||||
return fail(409, {
|
|
||||||
skillChipError:
|
|
||||||
'That ability already has all three active Skill Chips assigned.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?',
|
|
||||||
)
|
|
||||||
).get(name, id || 'new')
|
|
||||||
)
|
|
||||||
return fail(409, {
|
|
||||||
skillChipError: 'That Skill Chip set already exists.',
|
|
||||||
});
|
|
||||||
const existingTalent = id
|
|
||||||
? await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?',
|
|
||||||
)
|
|
||||||
).get(id)
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
existingTalent &&
|
|
||||||
!hasTalent &&
|
|
||||||
form.get('confirmTalentRemoval') !== 'REMOVE'
|
|
||||||
)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'Confirm removal of the existing 3-piece Talent and mechanic program.',
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
hasTalent &&
|
|
||||||
(await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
|
||||||
)
|
|
||||||
).get(talentName, existingTalent?.id ?? 'new'))
|
|
||||||
)
|
|
||||||
return fail(409, {
|
|
||||||
skillChipError: 'That Skill Chip talent name is already in use.',
|
|
||||||
});
|
|
||||||
const outcomeStatIds = outcomes
|
|
||||||
.map((effect) => effect.attributeId)
|
|
||||||
.filter((value): value is string => Boolean(value));
|
|
||||||
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
|
|
||||||
const validStats = await (
|
|
||||||
await database.prepare(
|
|
||||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
|
|
||||||
)
|
|
||||||
).get(...allStatIds);
|
|
||||||
if (Number(validStats?.count) !== allStatIds.length)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'One or more selected stats are unavailable.',
|
|
||||||
});
|
|
||||||
const setId =
|
|
||||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
|
||||||
const talentId = String(existingTalent?.id ?? randomUUID());
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const before = id ? await snapshot(id) : null;
|
|
||||||
if (id && !before?.record)
|
|
||||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
|
||||||
const previousAbilityId = String(
|
|
||||||
before?.record?.specialization_ability_id ?? '',
|
|
||||||
);
|
|
||||||
await database.exec('BEGIN');
|
|
||||||
try {
|
|
||||||
if (id) {
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
|
|
||||||
)
|
|
||||||
).run(name, abilityId, now, id);
|
|
||||||
} else
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
|
|
||||||
)
|
|
||||||
).run(setId, name, abilityId, now, now);
|
|
||||||
await (
|
|
||||||
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
|
|
||||||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
|
|
||||||
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
|
|
||||||
if (hasTalent && conditionalRule) {
|
|
||||||
await (
|
|
||||||
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
|
||||||
).run(talentId, setId, talentName, descriptionTemplate, now, now);
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
|
|
||||||
)
|
|
||||||
).run(talentId);
|
|
||||||
let compatibilitySortOrder = 0;
|
|
||||||
for (const outcome of numberedOutcomes) {
|
|
||||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
|
||||||
continue;
|
|
||||||
compatibilitySortOrder += 1;
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
|
|
||||||
)
|
|
||||||
).run(
|
|
||||||
randomUUID(),
|
|
||||||
talentId,
|
|
||||||
outcome.attributeId,
|
|
||||||
outcome.variants.minimum.value,
|
|
||||||
outcome.variants.maximum.value,
|
|
||||||
compatibilitySortOrder,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await saveConditionalRuleInTransaction(
|
|
||||||
database,
|
|
||||||
'skill_chip_talent',
|
|
||||||
talentId,
|
|
||||||
conditionalRule,
|
|
||||||
);
|
|
||||||
} else if (existingTalent) {
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
|
|
||||||
)
|
|
||||||
).run(String(existingTalent.id));
|
|
||||||
await (
|
|
||||||
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
|
|
||||||
).run(String(existingTalent.id));
|
|
||||||
}
|
|
||||||
await applyIconMutation(
|
|
||||||
database,
|
|
||||||
'skill_chip',
|
|
||||||
setId,
|
|
||||||
iconMutation,
|
|
||||||
operator.id,
|
|
||||||
);
|
|
||||||
await applyGameDataRender(
|
|
||||||
database,
|
|
||||||
'skill_chip',
|
|
||||||
setId,
|
|
||||||
name,
|
|
||||||
renderMutation,
|
|
||||||
operator.id,
|
|
||||||
);
|
|
||||||
await refreshAbilityFocuses(database, abilityId);
|
|
||||||
if (previousAbilityId && previousAbilityId !== abilityId)
|
|
||||||
await refreshAbilityFocuses(database, previousAbilityId);
|
|
||||||
await recordIsacAudit({
|
|
||||||
actorId: operator.id,
|
|
||||||
category: 'game_data',
|
|
||||||
action: id ? 'skill_chip.updated' : 'skill_chip.created',
|
|
||||||
entityId: setId,
|
|
||||||
before,
|
|
||||||
after: await snapshot(setId),
|
|
||||||
database,
|
|
||||||
});
|
|
||||||
await database.exec('COMMIT');
|
|
||||||
} catch (error) {
|
|
||||||
await database.exec('ROLLBACK');
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
skillChipSaved: true,
|
|
||||||
skillChipMode: id ? 'edit' : 'add',
|
|
||||||
resultToken: randomUUID(),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
savePrimaryProfile: async ({ request, cookies }) => {
|
savePrimaryProfile: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
@@ -936,7 +566,7 @@ export const actions: Actions = {
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
await database.exec('BEGIN IMMEDIATE');
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
const before = await snapshot(id);
|
const before = await snapshot(database, id);
|
||||||
if (!before.record) {
|
if (!before.record) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||||
@@ -956,7 +586,7 @@ export const actions: Actions = {
|
|||||||
action: 'skill_chip.archived',
|
action: 'skill_chip.archived',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
before,
|
before,
|
||||||
after: await snapshot(id),
|
after: await snapshot(database, id),
|
||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
@@ -964,7 +594,11 @@ export const actions: Actions = {
|
|||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { skillChipArchived: true };
|
return {
|
||||||
|
skillChipArchived: true,
|
||||||
|
skillChipId: id,
|
||||||
|
resultToken: randomUUID(),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
unarchive: async ({ request, cookies }) => {
|
unarchive: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
@@ -975,7 +609,7 @@ export const actions: Actions = {
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
await database.exec('BEGIN IMMEDIATE');
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
const before = await snapshot(id);
|
const before = await snapshot(database, id);
|
||||||
if (!before.record) {
|
if (!before.record) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||||
@@ -1021,7 +655,7 @@ export const actions: Actions = {
|
|||||||
action: 'skill_chip.unarchived',
|
action: 'skill_chip.unarchived',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
before,
|
before,
|
||||||
after: await snapshot(id),
|
after: await snapshot(database, id),
|
||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
@@ -1029,6 +663,10 @@ export const actions: Actions = {
|
|||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { skillChipUnarchived: true };
|
return {
|
||||||
|
skillChipUnarchived: true,
|
||||||
|
skillChipId: id,
|
||||||
|
resultToken: randomUUID(),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,34 +10,58 @@
|
|||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.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 SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
|
||||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const talentValueColumns = [
|
const talentValueColumns = [
|
||||||
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
|
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
|
||||||
{ key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' }
|
{ 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 => ({
|
const blankRule = (): ConditionalMechanicDraftV1 => ({
|
||||||
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
|
version: 1,
|
||||||
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}}}]}]
|
simulationReadiness: 'captured_only',
|
||||||
|
scenarioInputs: [],
|
||||||
|
pools: [],
|
||||||
|
clauses: []
|
||||||
});
|
});
|
||||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
const blankAuthoredVariants = (): MechanicAuthoredVariant[] => [
|
||||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
|
{ 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 editing = $state<any | null>(null);
|
||||||
let highEndCount = $state('0');
|
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
let iconResetToken = $state(0);
|
let iconResetToken = $state(0);
|
||||||
let renderResetToken = $state(0);
|
let renderResetToken = $state(0);
|
||||||
let openedRequestedItem = $state(false);
|
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 stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||||
const ability = (id: string) => data.abilities.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 format = (value: number | string, statId: string) => {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric)) return '—';
|
if (!Number.isFinite(numeric)) return '—';
|
||||||
@@ -45,32 +69,25 @@
|
|||||||
const unit = stat(statId)?.unit ?? '';
|
const unit = stat(statId)?.unit ?? '';
|
||||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${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 scaled = (minimum: number | string, _maximum: number | string) => Number(minimum);
|
||||||
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 edit = (item: any) => {
|
const edit = (item: any) => {
|
||||||
|
const selectedRevision = item.talent?.selectedRevision ?? null;
|
||||||
|
const source = item.talent?.authoredSource ?? blankAuthoredSource();
|
||||||
editing = {
|
editing = {
|
||||||
...item,
|
...item,
|
||||||
|
expectedOwnerUpdatedAt: item.updated_at ?? '',
|
||||||
abilityId: String(item.specialization_ability_id),
|
abilityId: String(item.specialization_ability_id),
|
||||||
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
||||||
hasTalent: Boolean(item.talent),
|
hasTalent: Boolean(item.talent),
|
||||||
talentOpen: false,
|
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(() => {
|
$effect(() => {
|
||||||
if (!openedRequestedItem && data.requestedItemId) {
|
if (!openedRequestedItem && data.requestedItemId) {
|
||||||
@@ -78,6 +95,11 @@
|
|||||||
if (requested) edit(requested);
|
if (requested) edit(requested);
|
||||||
openedRequestedItem = true;
|
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) {
|
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
if (form.skillChipMode === 'add') {
|
if (form.skillChipMode === 'add') {
|
||||||
@@ -85,7 +107,6 @@
|
|||||||
renderResetToken += 1;
|
renderResetToken += 1;
|
||||||
}
|
}
|
||||||
editing = form.skillChipMode === 'add' ? blank() : null;
|
editing = form.skillChipMode === 'add' ? blank() : null;
|
||||||
highEndCount = '0';
|
|
||||||
}
|
}
|
||||||
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
|
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>
|
<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">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<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="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>
|
<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">
|
<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>
|
<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>
|
<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}
|
{#if editing.hasTalent}
|
||||||
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
|
<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="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-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></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>
|
<AuthoredDescriptionEditor
|
||||||
<MechanicProgramEditor
|
variantLabels={authoredVariantLabels}
|
||||||
attributes={data.stats}
|
idPrefix={`skill-chip-authored-${editing.talent.id || 'new'}`}
|
||||||
allowAssignedAbility
|
bind:template={editing.talent.authoredSource.template}
|
||||||
valueColumns={talentValueColumns}
|
bind:variants={editing.talent.authoredSource.variants}
|
||||||
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
|
/>
|
||||||
aiContext={{
|
<div class="mechanic-selection">
|
||||||
sourceKind: 'skill_chip_talent',
|
<SegmentedChoice legend="MECHANIC PROGRAM" name="mechanic-selection" options={mechanicSelectionOptions} value={editing.talent.mechanicSelectionIntent} onchange={(values) => { editing.talent.mechanicSelectionIntent = values[0]; }}/>
|
||||||
sourceName: editing?.talent?.name ?? '',
|
{#if editing.talent.mechanicSelectionIntent === 'save' || editing.talent.selectedRevision}
|
||||||
sourceDescription: editing?.talent?.description_template ?? ''
|
<MechanicProgramEditor
|
||||||
}}
|
attributes={data.stats}
|
||||||
bind:rule={editing.talent.conditionalRule}
|
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>
|
</div>
|
||||||
</details>
|
</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}
|
{: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-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>
|
<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}
|
{#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}
|
{#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}
|
{: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>
|
</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,
|
events,
|
||||||
talentExists: true,
|
talentExists: true,
|
||||||
auditRecord: vi.fn(),
|
auditRecord: vi.fn(),
|
||||||
conditionalRuleSnapshot: vi.fn(),
|
|
||||||
gameDataIconMetadata: vi.fn(async () => null),
|
gameDataIconMetadata: vi.fn(async () => null),
|
||||||
recordIsacAudit: vi.fn(async () => {
|
recordIsacAudit: vi.fn(async () => {
|
||||||
events.push('audit');
|
events.push('audit');
|
||||||
@@ -16,6 +15,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
events.push(sql);
|
events.push(sql);
|
||||||
}),
|
}),
|
||||||
prepare: vi.fn(),
|
prepare: vi.fn(),
|
||||||
|
transactionAsync: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -63,13 +63,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
|
|||||||
refreshSpecializationFocusReady: vi.fn(),
|
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';
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
const beforeRecord = {
|
const beforeRecord = {
|
||||||
@@ -77,13 +70,13 @@ const beforeRecord = {
|
|||||||
name: 'Pulse Matrix',
|
name: 'Pulse Matrix',
|
||||||
specialization_ability_id: 'ability-1',
|
specialization_ability_id: 'ability-1',
|
||||||
active: 1,
|
active: 1,
|
||||||
|
updated_at: '2026-08-02T11:00:00.000Z',
|
||||||
};
|
};
|
||||||
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
|
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
|
||||||
const existingTalent = {
|
const existingTalent = {
|
||||||
id: 'talent-1',
|
id: 'talent-1',
|
||||||
skill_chip_set_id: 'set-1',
|
skill_chip_set_id: 'set-1',
|
||||||
name: 'Signal Cascade',
|
name: 'Signal Cascade',
|
||||||
description_template: 'Existing Talent',
|
|
||||||
};
|
};
|
||||||
const twoPiece = {
|
const twoPiece = {
|
||||||
skill_chip_set_id: 'set-1',
|
skill_chip_set_id: 'set-1',
|
||||||
@@ -91,17 +84,6 @@ const twoPiece = {
|
|||||||
min_value: 5,
|
min_value: 5,
|
||||||
max_value: 10,
|
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 saveRequest = (confirmRemoval = false) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -112,6 +94,8 @@ const saveRequest = (confirmRemoval = false) => {
|
|||||||
form.set('twoPieceMin', '5');
|
form.set('twoPieceMin', '5');
|
||||||
form.set('twoPieceMax', '10');
|
form.set('twoPieceMax', '10');
|
||||||
form.set('hasTalent', '0');
|
form.set('hasTalent', '0');
|
||||||
|
form.set('expectedOwnerUpdatedAt', beforeRecord.updated_at);
|
||||||
|
form.set('expectedTalentId', 'talent-1');
|
||||||
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
||||||
return new Request('http://localhost/isac/skillchips?/save', {
|
return new Request('http://localhost/isac/skillchips?/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -128,14 +112,31 @@ const runSave = (confirmRemoval = false) =>
|
|||||||
describe('Skill Chip Talent removal', () => {
|
describe('Skill Chip Talent removal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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.events.length = 0;
|
||||||
mocks.talentExists = true;
|
mocks.talentExists = true;
|
||||||
mocks.auditRecord
|
mocks.auditRecord
|
||||||
.mockResolvedValueOnce(beforeRecord)
|
.mockResolvedValueOnce(beforeRecord)
|
||||||
.mockResolvedValueOnce(afterRecord);
|
.mockResolvedValueOnce(afterRecord);
|
||||||
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
|
|
||||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
get: vi.fn(async (...args: unknown[]) => {
|
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'))
|
if (sql.includes('FROM specialization_abilities'))
|
||||||
return { id: 'ability-1' };
|
return { id: 'ability-1' };
|
||||||
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
|
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;
|
if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece;
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
all: vi.fn(async () => {
|
all: vi.fn(async () => []),
|
||||||
if (sql.includes('FROM skill_chip_talent_effects'))
|
|
||||||
return talentEffects;
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
run: 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')) {
|
if (sql.includes('DELETE FROM skill_chip_talents')) {
|
||||||
mocks.events.push('delete talent');
|
mocks.events.push('delete talent');
|
||||||
mocks.talentExists = false;
|
mocks.talentExists = false;
|
||||||
@@ -181,7 +176,7 @@ describe('Skill Chip Talent removal', () => {
|
|||||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
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);
|
const result = await runSave(true);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
@@ -189,13 +184,19 @@ describe('Skill Chip Talent removal', () => {
|
|||||||
skillChipMode: 'edit',
|
skillChipMode: 'edit',
|
||||||
resultToken: expect.any(String),
|
resultToken: expect.any(String),
|
||||||
});
|
});
|
||||||
|
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
|
||||||
expect(mocks.events).toEqual([
|
expect(mocks.events).toEqual([
|
||||||
'BEGIN',
|
'BEGIN IMMEDIATE',
|
||||||
'delete conditional rule',
|
|
||||||
'delete talent',
|
'delete talent',
|
||||||
'audit',
|
'audit',
|
||||||
'COMMIT',
|
'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({
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||||
actorId: 'operator-1',
|
actorId: 'operator-1',
|
||||||
category: 'game_data',
|
category: 'game_data',
|
||||||
@@ -207,8 +208,7 @@ describe('Skill Chip Talent removal', () => {
|
|||||||
render: null,
|
render: null,
|
||||||
twoPiece,
|
twoPiece,
|
||||||
talent: existingTalent,
|
talent: existingTalent,
|
||||||
talentEffects,
|
mechanic: null,
|
||||||
conditional,
|
|
||||||
},
|
},
|
||||||
after: {
|
after: {
|
||||||
record: afterRecord,
|
record: afterRecord,
|
||||||
@@ -216,8 +216,8 @@ describe('Skill Chip Talent removal', () => {
|
|||||||
render: null,
|
render: null,
|
||||||
twoPiece,
|
twoPiece,
|
||||||
talent: null,
|
talent: null,
|
||||||
talentEffects: [],
|
mechanic: null,
|
||||||
conditional: null,
|
mutationContext: { origin: 'isac' },
|
||||||
},
|
},
|
||||||
database: mocks.database,
|
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 GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
|
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
|
||||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.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 { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
|
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
|
||||||
@@ -56,11 +58,19 @@
|
|||||||
specializationId: string;
|
specializationId: string;
|
||||||
name: string;
|
name: string;
|
||||||
descriptionTemplate: string;
|
descriptionTemplate: string;
|
||||||
|
expectedOwnerUpdatedAt: string;
|
||||||
|
expectedAttachmentFingerprints: Record<string, string>;
|
||||||
active: number;
|
active: number;
|
||||||
};
|
};
|
||||||
type RankCombatEffect = {
|
type RankCombatEffect = {
|
||||||
|
/** Stable client identity for a newly added attachment before the server allocates its ID. */
|
||||||
|
editorKey: string;
|
||||||
|
id: string;
|
||||||
effectId: string;
|
effectId: string;
|
||||||
conditionalRule: ConditionalMechanicDraftV1;
|
authoredSource: MechanicAuthoredSource;
|
||||||
|
expectedSourceFingerprint: string | null;
|
||||||
|
selectionIntent: 'preserve' | 'save' | 'clear';
|
||||||
|
modelDraft: ConditionalMechanicDraftV1;
|
||||||
};
|
};
|
||||||
type RankEditor = {
|
type RankEditor = {
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -74,6 +84,8 @@
|
|||||||
focusId: string;
|
focusId: string;
|
||||||
name: string;
|
name: string;
|
||||||
descriptionTemplate: string;
|
descriptionTemplate: string;
|
||||||
|
expectedOwnerUpdatedAt: string;
|
||||||
|
expectedAttachmentFingerprints: Record<string, string>;
|
||||||
rowNumber: number | string;
|
rowNumber: number | string;
|
||||||
columnNumber: number | string;
|
columnNumber: number | string;
|
||||||
maxRank: string;
|
maxRank: string;
|
||||||
@@ -93,6 +105,12 @@
|
|||||||
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
|
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 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 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 blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '', renderUrl: '' });
|
||||||
const blankFocus = (specializationId = ''): FocusEditor => ({
|
const blankFocus = (specializationId = ''): FocusEditor => ({
|
||||||
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
||||||
@@ -107,15 +125,25 @@
|
|||||||
effects: [], active: 1, iconUrl: '', renderUrl: ''
|
effects: [], active: 1, iconUrl: '', renderUrl: ''
|
||||||
});
|
});
|
||||||
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
|
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
|
||||||
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
|
id: '', specializationId, name: '', descriptionTemplate: '', expectedOwnerUpdatedAt: '', expectedAttachmentFingerprints: {}, active: 1
|
||||||
});
|
});
|
||||||
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
|
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
|
||||||
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
|
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}}}]}]
|
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 => ({
|
const blankRankCombatEffect = (): RankCombatEffect => ({
|
||||||
|
editorKey: crypto.randomUUID(),
|
||||||
|
id: '',
|
||||||
effectId: '',
|
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 => ({
|
const blankRank = (rank: number): RankEditor => ({
|
||||||
rank,
|
rank,
|
||||||
@@ -127,7 +155,7 @@
|
|||||||
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
|
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
|
||||||
});
|
});
|
||||||
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
|
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: ''
|
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: '', renderUrl: ''
|
||||||
});
|
});
|
||||||
const normalizedEffect = (effect: any): StructuredEffect => ({
|
const normalizedEffect = (effect: any): StructuredEffect => ({
|
||||||
@@ -278,6 +306,8 @@
|
|||||||
specializationId: item.specialization_id,
|
specializationId: item.specialization_id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
descriptionTemplate: item.description_template,
|
descriptionTemplate: item.description_template,
|
||||||
|
expectedOwnerUpdatedAt: item.updated_at ?? '',
|
||||||
|
expectedAttachmentFingerprints: { ...(item.expectedAttachmentFingerprints ?? {}) },
|
||||||
active: Number(item.active)
|
active: Number(item.active)
|
||||||
};
|
};
|
||||||
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
|
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
|
||||||
@@ -288,6 +318,8 @@
|
|||||||
focusId: item.focus_id,
|
focusId: item.focus_id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
descriptionTemplate: item.description_template,
|
descriptionTemplate: item.description_template,
|
||||||
|
expectedOwnerUpdatedAt: item.updated_at ?? '',
|
||||||
|
expectedAttachmentFingerprints: { ...(item.expectedAttachmentFingerprints ?? {}) },
|
||||||
rowNumber: item.row_number,
|
rowNumber: item.row_number,
|
||||||
columnNumber: item.column_number,
|
columnNumber: item.column_number,
|
||||||
maxRank: String(item.max_rank),
|
maxRank: String(item.max_rank),
|
||||||
@@ -296,8 +328,13 @@
|
|||||||
description: rank.description,
|
description: rank.description,
|
||||||
effects: (rank.effects ?? []).map(normalizedEffect),
|
effects: (rank.effects ?? []).map(normalizedEffect),
|
||||||
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
|
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
|
||||||
|
editorKey: String(effect.id ?? '') || crypto.randomUUID(),
|
||||||
|
id: String(effect.id ?? ''),
|
||||||
effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''),
|
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 ?? {}) }
|
costs: { ...(rank.costs ?? {}) }
|
||||||
})),
|
})),
|
||||||
@@ -321,6 +358,37 @@
|
|||||||
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
|
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
|
||||||
rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index);
|
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 = () => {
|
const openAbilityConfiguration = () => {
|
||||||
if (!selectedFocus) return;
|
if (!selectedFocus) return;
|
||||||
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
|
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>
|
<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">
|
<PendingForm action="?/saveCombatEffect">
|
||||||
<input type="hidden" name="id" value={combatEffectEditing.id}/>
|
<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}
|
{#if combatEffectEditing.id}
|
||||||
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
|
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -574,9 +644,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="form-grid combat-effect-definition-grid">
|
<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 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>
|
</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}
|
{#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>
|
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
|
||||||
</PendingForm>
|
</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>
|
<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">
|
<PendingForm action="?/saveNode" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={nodeEditing.id}/>
|
<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)}/>
|
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
|
||||||
<div class="isac-record-identity specialization-record-identity node-record-identity">
|
<div class="isac-record-identity specialization-record-identity node-record-identity">
|
||||||
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
|
<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`}/>
|
<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="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>
|
<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">
|
<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>
|
<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-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
|
<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>
|
||||||
<MechanicProgramEditor
|
{#key rankCombatEffectEditorKey(combatEffect)}
|
||||||
attributes={data.attributes}
|
<AuthoredDescriptionEditor
|
||||||
valueColumns={conditionalValueColumns}
|
variantLabels={authoredVariantLabels}
|
||||||
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
|
idPrefix={`specialization-rank-${rank.rank}-authored-${nodeEditing.id || 'new-node'}-${combatEffect.id || combatEffect.editorKey}`}
|
||||||
aiContext={{
|
bind:template={combatEffect.authoredSource.template}
|
||||||
sourceKind: 'specialization_tree_rank',
|
bind:variants={combatEffect.authoredSource.variants}
|
||||||
sourceName: combatEffectById(combatEffect.effectId)?.name ?? `${nodeEditing?.name ?? 'Tree node'} · Rank ${rank.rank}`,
|
/>
|
||||||
sourceDescription: rank?.description ?? ''
|
{/key}
|
||||||
}}
|
<SegmentedChoice
|
||||||
bind:rule={combatEffect.conditionalRule}
|
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>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,13 +64,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
|
|||||||
refreshSpecializationFocuses: vi.fn(),
|
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';
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
const saveRequest = (compatibleAbilityId: string) => {
|
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 RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.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 MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
|
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
|
||||||
const tierIIDocumentationOptions = [
|
const tierIIDocumentationOptions = [
|
||||||
{ value: 'documented', label: 'DOCUMENTED' },
|
{ value: 'documented', label: 'DOCUMENTED' },
|
||||||
{ value: 'not_documented', label: 'NOT YET 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 talent = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
let iconResetToken = $state(0);
|
let iconResetToken = $state(0);
|
||||||
@@ -38,7 +46,7 @@
|
|||||||
const areaLabel = (area: TalentErrorArea) => ({
|
const areaLabel = (area: TalentErrorArea) => ({
|
||||||
icon: 'Talent icon',
|
icon: 'Talent icon',
|
||||||
identity: 'Talent identity',
|
identity: 'Talent identity',
|
||||||
description: 'Description template',
|
description: 'Authored description',
|
||||||
mechanic: 'Mechanic program',
|
mechanic: 'Mechanic program',
|
||||||
outcomes: 'Mechanic outcomes'
|
outcomes: 'Mechanic outcomes'
|
||||||
}[area]);
|
}[area]);
|
||||||
@@ -51,25 +59,46 @@
|
|||||||
pools: [],
|
pools: [],
|
||||||
clauses: []
|
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 editTalent = (row: any) => {
|
||||||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
const authored = row.authoredSource;
|
||||||
const one = tier('I'), two = tier('II');
|
const hasTierII = row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented);
|
||||||
talent = {
|
talent = {
|
||||||
...row,
|
...row,
|
||||||
descriptionTemplate: row.descriptionTemplate || one.description,
|
authoredTemplate: authored?.template ?? '',
|
||||||
tierIDescription: one.description,
|
authoredVariants: authored?.variants ?? blankVariants(hasTierII),
|
||||||
tierIIDescription: two.description,
|
expectedOwnerUpdatedAt: row.updated_at ?? '',
|
||||||
tierIIDocumented: row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented),
|
expectedSourceFingerprint: row.expectedSourceFingerprint ?? '',
|
||||||
tierIIDocumentation: row.category === 'Exotic Weapon' && !row.tierIIDocumented ? 'not_documented' : 'documented',
|
tierIIDocumented: hasTierII,
|
||||||
conditionalRule: row.conditionalRule ?? blankRule(),
|
tierIIDocumentation: hasTierII ? 'documented' : 'not_documented',
|
||||||
modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
|
conditionalRule: row.selectedRevision?.draft ?? blankRule(),
|
||||||
|
mechanicSelectionIntent: 'preserve',
|
||||||
|
modelingOpen: Boolean(row.selectedRevision?.draft?.clauses?.length)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const setTierIIDocumented = (documented: boolean) => {
|
const setTierIIDocumented = (documented: boolean) => {
|
||||||
if (!talent) return;
|
if (!talent) return;
|
||||||
talent.tierIIDocumented = documented;
|
talent.tierIIDocumented = documented;
|
||||||
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
|
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
|
||||||
|
reconcileTierVariants(documented);
|
||||||
talent.conditionalRule = {
|
talent.conditionalRule = {
|
||||||
...talent.conditionalRule,
|
...talent.conditionalRule,
|
||||||
pools: talent.conditionalRule.pools.map((pool: any) => {
|
pools: talent.conditionalRule.pools.map((pool: any) => {
|
||||||
@@ -87,11 +116,7 @@
|
|||||||
outcomes: clause.outcomes.map((outcome: any) => ({
|
outcomes: clause.outcomes.map((outcome: any) => ({
|
||||||
...outcome,
|
...outcome,
|
||||||
variants: documented
|
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 }
|
: { I: outcome.variants.I }
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
@@ -100,40 +125,13 @@
|
|||||||
const setCategory = (category: string) => {
|
const setCategory = (category: string) => {
|
||||||
if (!talent) return;
|
if (!talent) return;
|
||||||
talent.category = category;
|
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 configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
|
||||||
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
|
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
|
||||||
const description = tiers.find((item) => item.tier === name)?.description;
|
const description = tiers.find((item) => item.tier === name)?.description;
|
||||||
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
|
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(() => {
|
$effect(() => {
|
||||||
if (!openedRequestedItem && data.requestedItemId) {
|
if (!openedRequestedItem && data.requestedItemId) {
|
||||||
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
|
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
|
||||||
@@ -164,16 +162,18 @@
|
|||||||
<div class="dashboard-editor">
|
<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>
|
<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">
|
<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'}/>
|
<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}
|
{#if talentErrors.length}
|
||||||
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
|
<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>
|
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
|
||||||
<ul>
|
<ul>{#each talentErrors as error}<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>{/each}</ul>
|
||||||
{#each talentErrors as error}
|
|
||||||
<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="talent-icon-identity">
|
<div class="talent-icon-identity">
|
||||||
@@ -191,26 +191,31 @@
|
|||||||
{#if talent.category === 'Exotic Weapon'}
|
{#if talent.category === 'Exotic Weapon'}
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<SegmentedChoice legend="TIER II DOCUMENTATION" name="tierIIDocumentation" options={tierIIDocumentationOptions} bind:value={talent.tierIIDocumentation} onchange={(values) => setTierIIDocumented(values[0] === 'documented')} required/>
|
<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>
|
</div>
|
||||||
{/if}
|
{/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 id="talent-description" class:validation-area-error={hasErrors(['description'])}>
|
||||||
<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>
|
<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}>
|
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
|
||||||
<summary>
|
<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>
|
<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>
|
<strong>{talent.modelingOpen ? 'COLLAPSE' : 'EXPAND'}</strong>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="modeling-content">
|
<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
|
<MechanicProgramEditor
|
||||||
attributes={data.stats}
|
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'}`}
|
idPrefix={`talent-${talent.id || 'new'}`}
|
||||||
aiContext={{
|
aiContext={{sourceKind: 'talent', sourceName: talent.name ?? '', sourceDescription: talent.authoredTemplate ?? ''}}
|
||||||
sourceKind: 'talent',
|
|
||||||
sourceName: talent?.name ?? '',
|
|
||||||
sourceDescription: talent?.descriptionTemplate ?? ''
|
|
||||||
}}
|
|
||||||
bind:rule={talent.conditionalRule}
|
bind:rule={talent.conditionalRule}
|
||||||
/>
|
/>
|
||||||
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
||||||
@@ -222,9 +227,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if talent && !talent.id}
|
{#if talent && !talent.id}{@render talentEditor()}{/if}
|
||||||
{@render talentEditor()}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<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';
|
} from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { MediaMutationJournal } from '$lib/server/media-journal';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
import {
|
import {
|
||||||
getPagination,
|
getPagination,
|
||||||
@@ -429,6 +430,7 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
|
const journal = new MediaMutationJournal();
|
||||||
await database.exec('BEGIN IMMEDIATE');
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
const beforeIcon = await gameDataIconMetadata(
|
const beforeIcon = await gameDataIconMetadata(
|
||||||
@@ -439,6 +441,7 @@ export const actions: Actions = {
|
|||||||
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
|
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
|
||||||
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
|
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
|
journal.clear();
|
||||||
return fail(409, {
|
return fail(409, {
|
||||||
weaponModSlotIconError:
|
weaponModSlotIconError:
|
||||||
'This slot icon changed after the editor opened. Review the latest icon and try again.',
|
'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) {
|
if (mutation.remove && !beforeIcon) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
|
journal.clear();
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
weaponModSlotIconError: 'This slot does not have an icon to remove.',
|
weaponModSlotIconError: 'This slot does not have an icon to remove.',
|
||||||
});
|
});
|
||||||
@@ -456,6 +460,7 @@ export const actions: Actions = {
|
|||||||
definition.id,
|
definition.id,
|
||||||
mutation,
|
mutation,
|
||||||
operator.id,
|
operator.id,
|
||||||
|
journal,
|
||||||
);
|
);
|
||||||
const afterIcon = await gameDataIconMetadata(
|
const afterIcon = await gameDataIconMetadata(
|
||||||
database,
|
database,
|
||||||
@@ -472,8 +477,10 @@ export const actions: Actions = {
|
|||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
|
journal.clear();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
|
await journal.rollback(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ describe('Weapon Mod shared slot icon action', () => {
|
|||||||
definition.id,
|
definition.id,
|
||||||
{ output: Buffer.from('icon'), remove: false },
|
{ output: Buffer.from('icon'), remove: false },
|
||||||
'operator-1',
|
'operator-1',
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||||
actorId: 'operator-1',
|
actorId: 'operator-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user