Author SHA1 Message Date
jb 8991682e59 feat: consume selected mechanic revisions 2026-09-02 23:32:42 -04:00
jb 7e392a1f37 feat: persist authored mechanic revisions 2026-09-02 15:39:50 -04:00
jb 98f7f51e46 feat: add authored mechanic contracts 2026-08-26 23:50:03 -04:00
88 changed files with 31896 additions and 3737 deletions
+260
View File
@@ -926,6 +926,266 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
);
CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx
ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order);
-- Phase 2 mechanic persistence. Revision rows are append-only by application
-- convention. No database triggers enforce immutability.
CREATE TABLE IF NOT EXISTS mechanic_authored_sources (
id TEXT PRIMARY KEY,
owner_type TEXT NOT NULL CHECK(owner_type IN (
'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank'
)),
owner_id TEXT NOT NULL CHECK(length(trim(owner_id))>0),
template TEXT NOT NULL CHECK(length(trim(template))>0),
authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0),
source_fingerprint TEXT NOT NULL CHECK(
length(source_fingerprint)=64
AND lower(source_fingerprint)=source_fingerprint
AND source_fingerprint NOT GLOB '*[^0-9a-f]*'
),
talent_scope TEXT CHECK(talent_scope IS NULL OR talent_scope IN ('standard','exotic')),
talent_has_tier_ii INTEGER CHECK(talent_has_tier_ii IS NULL OR talent_has_tier_ii IN (0,1)),
specialization_rank_description TEXT,
specialization_effect_id TEXT,
specialization_effect_template TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(owner_type,owner_id),
CHECK(
(owner_type='talent'
AND talent_scope IS NOT NULL
AND specialization_rank_description IS NULL
AND specialization_effect_id IS NULL
AND specialization_effect_template IS NULL)
OR
(owner_type='specialization_tree_rank'
AND talent_scope IS NULL
AND talent_has_tier_ii IS NULL
AND specialization_rank_description IS NOT NULL
AND length(trim(specialization_rank_description))>0
AND specialization_effect_id IS NOT NULL
AND length(trim(specialization_effect_id))>0
AND specialization_effect_template IS NOT NULL
AND length(trim(specialization_effect_template))>0)
OR
(owner_type IN ('os_protocol_talent','skill_chip_talent')
AND talent_scope IS NULL
AND talent_has_tier_ii IS NULL
AND specialization_rank_description IS NULL
AND specialization_effect_id IS NULL
AND specialization_effect_template IS NULL)
)
);
CREATE INDEX IF NOT EXISTS mechanic_authored_sources_owner_idx
ON mechanic_authored_sources(owner_type,owner_id);
CREATE TABLE IF NOT EXISTS mechanic_authored_variants (
source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0),
materialized TEXT NOT NULL CHECK(length(materialized)>0),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
PRIMARY KEY(source_id,variant_key),
UNIQUE(source_id,sort_order)
);
CREATE INDEX IF NOT EXISTS mechanic_authored_variants_source_order_idx
ON mechanic_authored_variants(source_id,sort_order);
CREATE TABLE IF NOT EXISTS mechanic_authored_slots (
source_id TEXT NOT NULL,
variant_key TEXT NOT NULL,
slot_key TEXT NOT NULL CHECK(length(trim(slot_key))>0),
display_value TEXT NOT NULL CHECK(
display_value=trim(display_value)
AND display_value GLOB '*[0-9]*'
AND display_value NOT GLOB '*[^0-9.+-]*'
AND substr(display_value,2) NOT GLOB '*[+-]*'
AND length(display_value)-length(replace(display_value,'.',''))<=1
AND substr(display_value,-1)<>'.'
AND display_value NOT IN ('+','-','.','+.','-.')
),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
unit TEXT,
stat_attribute_id TEXT REFERENCES stat_attributes(id),
PRIMARY KEY(source_id,variant_key,slot_key),
UNIQUE(source_id,variant_key,sort_order),
FOREIGN KEY(source_id,variant_key)
REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_authored_slots_variant_order_idx
ON mechanic_authored_slots(source_id,variant_key,sort_order);
CREATE INDEX IF NOT EXISTS mechanic_authored_slots_attribute_idx
ON mechanic_authored_slots(stat_attribute_id)
WHERE stat_attribute_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS mechanic_model_revisions (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL CHECK(revision_number>0),
source_fingerprint TEXT NOT NULL CHECK(
length(source_fingerprint)=64
AND lower(source_fingerprint)=source_fingerprint
AND source_fingerprint NOT GLOB '*[^0-9a-f]*'
),
mechanic_contract_version INTEGER NOT NULL CHECK(mechanic_contract_version>0),
program_hash TEXT NOT NULL CHECK(
length(program_hash)=64
AND lower(program_hash)=program_hash
AND program_hash NOT GLOB '*[^0-9a-f]*'
),
simulation_readiness TEXT NOT NULL CHECK(simulation_readiness IN ('ready','requires_assumptions','captured_only')),
scenario_inputs_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(scenario_inputs_json)),
source_variants_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(source_variants_json)),
program_name TEXT NOT NULL CHECK(length(trim(program_name))>0),
origin TEXT NOT NULL CHECK(length(trim(origin))>0),
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
UNIQUE(source_id,revision_number),
UNIQUE(source_id,id)
);
CREATE INDEX IF NOT EXISTS mechanic_model_revisions_source_created_idx
ON mechanic_model_revisions(source_id,revision_number DESC,created_at DESC);
CREATE INDEX IF NOT EXISTS mechanic_model_revisions_fingerprint_idx
ON mechanic_model_revisions(source_id,source_fingerprint);
CREATE TABLE IF NOT EXISTS mechanic_model_revision_pools (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
pool_key TEXT NOT NULL CHECK(length(trim(pool_key))>0),
label TEXT NOT NULL CHECK(length(trim(label))>0),
state_kind TEXT NOT NULL CHECK(state_kind IN ('flag','count','stack','duration','scalar','target_mark','resource')),
state_scope TEXT NOT NULL CHECK(state_scope IN ('agent','target')),
initial_value REAL NOT NULL,
minimum_value REAL,
maximum_value REAL,
best_case_value REAL,
duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0),
duration_variants_json TEXT CHECK(duration_variants_json IS NULL OR json_valid(duration_variants_json)),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
UNIQUE(revision_id,id),
UNIQUE(revision_id,pool_key),
UNIQUE(revision_id,sort_order),
CHECK(minimum_value IS NULL OR maximum_value IS NULL OR maximum_value>=minimum_value),
CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value),
CHECK(best_case_value IS NULL OR maximum_value IS NULL OR best_case_value<=maximum_value),
CHECK(NOT (duration_seconds IS NOT NULL AND duration_variants_json IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_pools_revision_order_idx
ON mechanic_model_revision_pools(revision_id,sort_order);
CREATE TABLE IF NOT EXISTS mechanic_model_revision_clauses (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
clause_key TEXT NOT NULL CHECK(length(trim(clause_key))>0),
label TEXT,
event_kind TEXT NOT NULL CHECK(event_kind IN (
'shot_fired','weapon_hit','skill_deployed','signature_ability_deployed',
'skill_hit','skill_multishot_triggered','agent_hit','health_damage_taken',
'damage_dealt','enemy_killed','assist','ally_downed','agent_downed',
'ally_rescued','grenade_thrown','medical_kit_used','healing_applied',
'extra_health_granted','extra_health_changed','movement_snapshot',
'proximity_snapshot','firing_started','firing_stopped','reload_started',
'reload_completed','weapon_swapped','tick'
)),
recipe_key TEXT,
conditions_json TEXT CHECK(conditions_json IS NULL OR json_valid(conditions_json)),
reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}' CHECK(json_valid(reducer_json)),
weight_expression_json TEXT CHECK(weight_expression_json IS NULL OR json_valid(weight_expression_json)),
mutations_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(mutations_json)),
cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0),
proc_chance_percent REAL CHECK(proc_chance_percent IS NULL OR (proc_chance_percent>=0 AND proc_chance_percent<=100)),
requires_assigned_ability_active INTEGER CHECK(requires_assigned_ability_active IS NULL OR requires_assigned_ability_active IN (0,1)),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
UNIQUE(revision_id,id),
UNIQUE(revision_id,clause_key),
UNIQUE(revision_id,sort_order)
);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_revision_order_idx
ON mechanic_model_revision_clauses(revision_id,sort_order);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_event_idx
ON mechanic_model_revision_clauses(event_kind,revision_id,sort_order);
CREATE TABLE IF NOT EXISTS mechanic_model_revision_outcomes (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
clause_id TEXT NOT NULL,
state_pool_id TEXT,
effect_key TEXT NOT NULL CHECK(length(trim(effect_key))>0),
variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0),
label TEXT NOT NULL CHECK(length(trim(label))>0),
outcome_kind TEXT NOT NULL CHECK(outcome_kind IN ('attribute_modifier','direct_damage','restoration','resource_cost','weapon_reload','marker')),
effect_scope TEXT NOT NULL CHECK(effect_scope IN ('self','equipped_weapon','inactive_weapon','target','allies','self_and_allies','assigned_ability','all_skills')),
value REAL NOT NULL,
value_expression_json TEXT CHECK(value_expression_json IS NULL OR json_valid(value_expression_json)),
value_basis TEXT NOT NULL CHECK(value_basis IN ('flat','max_health_percent','current_health_percent','weapon_base_percent')),
resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo','signature_ability_charge')),
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
application_phase TEXT NOT NULL CHECK(application_phase IN ('on_event','after_event','on_enter','on_exit','while_active')),
combat_timing TEXT CHECK(combat_timing IS NULL OR combat_timing IN ('pre_event','current')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
per_stack INTEGER CHECK(per_stack IS NULL OR per_stack IN (0,1)),
min_state_value REAL CHECK(min_state_value IS NULL OR min_state_value>=0),
max_state_value REAL CHECK(max_state_value IS NULL OR max_state_value>=0),
targeting_json TEXT CHECK(targeting_json IS NULL OR json_valid(targeting_json)),
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
UNIQUE(revision_id,id),
UNIQUE(revision_id,clause_id,variant_key,effect_key),
UNIQUE(revision_id,clause_id,variant_key,sort_order),
CHECK(max_state_value IS NULL OR min_state_value IS NULL OR max_state_value>=min_state_value),
CHECK(
(delivery_mode='instant' AND delivery_duration_seconds IS NULL)
OR (delivery_mode='continuous' AND outcome_kind='restoration' AND delivery_duration_seconds IS NOT NULL)
),
CHECK(
(outcome_kind='attribute_modifier' AND stat_attribute_id IS NOT NULL AND resource_kind IS NULL)
OR (outcome_kind IN ('restoration','resource_cost') AND stat_attribute_id IS NULL AND resource_kind IS NOT NULL)
OR (outcome_kind IN ('direct_damage','weapon_reload','marker') AND stat_attribute_id IS NULL AND resource_kind IS NULL)
),
FOREIGN KEY(revision_id,clause_id)
REFERENCES mechanic_model_revision_clauses(revision_id,id) ON DELETE CASCADE,
FOREIGN KEY(revision_id,state_pool_id)
REFERENCES mechanic_model_revision_pools(revision_id,id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_clause_order_idx
ON mechanic_model_revision_outcomes(revision_id,clause_id,variant_key,sort_order);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_pool_idx
ON mechanic_model_revision_outcomes(revision_id,state_pool_id)
WHERE state_pool_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_attribute_idx
ON mechanic_model_revision_outcomes(stat_attribute_id)
WHERE stat_attribute_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS mechanic_model_selections (
source_id TEXT PRIMARY KEY REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
revision_id TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(source_id,revision_id)
REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_model_selections_revision_idx
ON mechanic_model_selections(revision_id,source_id);
CREATE TABLE IF NOT EXISTS mechanic_public_variants (
source_id TEXT NOT NULL,
variant_key TEXT NOT NULL,
selected_revision_id TEXT,
current_source_fingerprint TEXT NOT NULL CHECK(
length(current_source_fingerprint)=64
AND lower(current_source_fingerprint)=current_source_fingerprint
AND current_source_fingerprint NOT GLOB '*[^0-9a-f]*'
),
authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0),
mechanic_contract_version INTEGER CHECK(mechanic_contract_version IS NULL OR mechanic_contract_version>0),
status TEXT NOT NULL CHECK(status IN ('modeled','not_modeled')),
behavior_text TEXT NOT NULL CHECK(length(behavior_text)>0),
display_source TEXT NOT NULL CHECK(display_source IN ('model','authored')),
updated_at TEXT NOT NULL,
PRIMARY KEY(source_id,variant_key),
CHECK(
(display_source='model' AND status='modeled' AND selected_revision_id IS NOT NULL)
OR (display_source='authored' AND status='not_modeled')
),
FOREIGN KEY(source_id,variant_key)
REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE,
FOREIGN KEY(source_id,selected_revision_id)
REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_public_variants_status_idx
ON mechanic_public_variants(status,source_id,variant_key);
CREATE INDEX IF NOT EXISTS mechanic_public_variants_revision_idx
ON mechanic_public_variants(selected_revision_id)
WHERE selected_revision_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS talent_tier_effects (
id TEXT PRIMARY KEY,
talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE,
+265
View File
@@ -0,0 +1,265 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(new URL('./schema.sql', import.meta.url), 'utf8');
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
const insertSource = async (
target: Database,
id: string,
ownerId: string,
fingerprint: string,
) => {
await (
await target.prepare(
`INSERT INTO mechanic_authored_sources(
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
talent_scope,talent_has_tier_ii,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
)
).run(
id,
'talent',
ownerId,
'Gain {1}',
1,
fingerprint,
'standard',
1,
'2026-08-27T00:00:00.000Z',
'2026-08-27T00:00:00.000Z',
);
};
describe('mechanic persistence schema', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
});
afterEach(async () => {
await database.close();
});
it('installs the complete schema with clean foreign keys', async () => {
const rows = await (
await database.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'mechanic_%' ORDER BY name",
)
).all();
expect(rows.map((row) => row.name)).toEqual([
'mechanic_authored_slots',
'mechanic_authored_sources',
'mechanic_authored_variants',
'mechanic_model_revision_clauses',
'mechanic_model_revision_outcomes',
'mechanic_model_revision_pools',
'mechanic_model_revisions',
'mechanic_model_selections',
'mechanic_public_variants',
]);
expect(await (await database.prepare('PRAGMA foreign_key_check')).all()).toEqual([]);
});
it('enforces owner metadata, ordered authored children, and source cascades', async () => {
const fingerprint = 'a'.repeat(64);
await insertSource(database, 'source-1', 'talent-1', fingerprint);
await expect(
insertSource(database, 'source-2', 'talent-1', fingerprint),
).rejects.toThrow();
await expect(
database.exec(
`INSERT INTO mechanic_authored_sources(
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
specialization_rank_description,specialization_effect_id,specialization_effect_template,
created_at,updated_at
) VALUES('source-3','talent','talent-3','Gain',1,'${fingerprint}',
'rank','effect','template','now','now')`,
),
).rejects.toThrow();
await (
await database.prepare(
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
VALUES('source-1','I','Gain 1',0),('source-1','II','Gain 2',1)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_authored_slots(
source_id,variant_key,slot_key,display_value,sort_order
) VALUES('source-1','I','1','1',0)`,
)
).run();
await expect(
database.exec(
`INSERT INTO mechanic_authored_slots(
source_id,variant_key,slot_key,display_value,sort_order
) VALUES('source-1','missing','1','1',0)`,
),
).rejects.toThrow();
await expect(
database.exec(
`INSERT INTO mechanic_authored_slots(
source_id,variant_key,slot_key,display_value,sort_order
) VALUES('source-1','II','1','10% Weapon Damage',0)`,
),
).rejects.toThrow();
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
for (const table of [
'mechanic_authored_variants',
'mechanic_authored_slots',
]) {
expect(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get(),
).toMatchObject({ count: 0 });
}
});
it('allows deliberate duplicate graphs while proving selection ownership', async () => {
const sourceFingerprint = 'a'.repeat(64);
const programHash = 'b'.repeat(64);
await insertSource(database, 'source-1', 'talent-1', sourceFingerprint);
await (
await database.prepare(
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
VALUES('source-1','I','Gain 1',0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_model_revisions(
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
'revision-1',
'source-1',
1,
sourceFingerprint,
1,
programHash,
'ready',
'[]',
'["I"]',
'Model',
'manual',
'now',
);
await (
await database.prepare(
`INSERT INTO mechanic_model_revisions(
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at
) SELECT 'revision-2',source_id,2,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
)
).run();
await expect(
database.exec(
`INSERT INTO mechanic_model_revisions(
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at
) SELECT 'revision-3',source_id,1,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
),
).rejects.toThrow();
await (
await database.prepare(
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
VALUES('source-1','revision-2','now')`,
)
).run();
await expect(
database.exec(
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
VALUES('source-1','not-this-source','now')`,
),
).rejects.toThrow();
await (
await database.prepare(
`INSERT INTO mechanic_model_revision_pools(
id,revision_id,pool_key,label,state_kind,state_scope,initial_value,
minimum_value,maximum_value,best_case_value,duration_seconds,
duration_variants_json,sort_order
) VALUES('pool-1','revision-1','stacks','Stacks','stack','agent',0,0,5,5,NULL,NULL,0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_model_revision_clauses(
id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json,
reducer_json,weight_expression_json,mutations_json,cooldown_seconds,
proc_chance_percent,requires_assigned_ability_active,sort_order
) VALUES('clause-1','revision-1','on-hit','On hit','weapon_hit',NULL,NULL,
'{"kind":"none"}',NULL,'[]',NULL,25,NULL,0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_model_revision_outcomes(
id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label,
outcome_kind,effect_scope,value,value_expression_json,value_basis,
resource_kind,delivery_mode,delivery_duration_seconds,application_phase,
combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value,
targeting_json,probability_percent,sort_order
) VALUES('outcome-1','revision-1','clause-1','pool-1','mark','I','Mark',
'marker','self',1,NULL,'flat',NULL,'instant',NULL,'while_active','current',
NULL,0,NULL,NULL,NULL,NULL,0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_public_variants(
source_id,variant_key,selected_revision_id,current_source_fingerprint,
authored_contract_version,mechanic_contract_version,status,behavior_text,
display_source,updated_at
) VALUES('source-1','I','revision-1',?,1,1,'modeled','Modeled behavior','model','now')`,
)
).run(sourceFingerprint);
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
for (const table of [
'mechanic_authored_variants',
'mechanic_authored_slots',
'mechanic_model_revisions',
'mechanic_model_revision_pools',
'mechanic_model_revision_clauses',
'mechanic_model_revision_outcomes',
'mechanic_model_selections',
'mechanic_public_variants',
]) {
expect(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get(),
).toMatchObject({ count: 0 });
}
});
});
+42
View File
@@ -896,6 +896,20 @@ error, or danger indicators.
- Tiered records with shared structure use one shared selector and side-by-side
value columns rather than duplicating the entire editor. Template-driven copy
must show live previews for every tier and document its placeholder syntax.
- Talent, OS Protocol Talent, Skill Chip Talent, and Specialization rank-effect
forms use the shared authored-description editor. Place the exact source text
first because it remains authoritative when no model revision is selected.
Put model controls in a separate bordered section with explicit Preserve,
Save, and Clear choices. Keep fixed units and Attribute wording in the
authored template; every token value is numeric and contains only the number
that changes between variants, while the combat model owns Attribute
semantics. The token matrix
keeps one row per varying authored value and one column per variant so
operators can compare substitutions without duplicating the program editor.
On narrow screens, keep the matrix
horizontally scrollable instead of collapsing labels into ambiguous cards.
Show modeled previews, readiness, assumptions, and validation as text; color
may reinforce these states but must not replace their labels.
### Forms
@@ -1843,6 +1857,34 @@ responsive card grid. Record cards use the same hierarchy:
- bordered fact cells for primary values;
- relationship lines and tier descriptions only when applicable.
#### Public mechanic behavior status
Mechanic owners use the shared integrated card hierarchy. The public DTO carries a
stable group ID, owner type, label and optional context, plus explicitly labeled
variants. Each variant provides public behavior text, `modeled` or `not_modeled`
status, and `model` or `authored` display source. Public cards never receive
lifecycle, readiness, revision, fingerprint, or program data.
Every owner group displays one textual `MODELED` or `NOT MODELED` badge beside
its identity. `MODELED` uses the model surface, model border, and blue
informational text. `NOT MODELED` uses neutral borders and muted text. Orange
remains reserved for navigation, focus, and actions. Each behavior block labels
its displayed basis as `MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`.
Talent groups retain Tier I and Tier II variants. Exotic Talents omit Tier II
when the authored source omits it. OS Protocol Talents use a Base variant beside
their existing level and upgrade controls. Skill Chip Talents use Minimum and
Maximum variants in the three-piece section. Specialization tree-rank effects
remain independent groups under their Focus, node, and rank context.
Compact cards visually clamp long behavior text without removing it from the
accessible name or linked detail. Quick views render the same shared card at a
wider reading measure, retain the binary badge, and add `DISPLAY SOURCE` with
`MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`. Global Database results show the
owner category, record name, current public behavior, binary status, and an
explicit registry navigation label. Status is textual at every breakpoint;
mobile places it after identity and before behavior.
When a record represents an ordered progression rather than independent facts,
use the stacked fact treatment. Brand Set bonuses are shown vertically in two-,
three-, then four-piece order; do not add a redundant “3/3 bonuses” badge when
+51 -10
View File
@@ -2216,6 +2216,17 @@ if (legacyProtocolEffects) {
await database.exec('DROP TABLE os_protocol_attribute_effects');
}
const assertProposalContributorNoteColumn = async () => {
const columns = await (
await database.prepare("PRAGMA table_info('data_change_proposals')")
).all();
if (!columns.some((column) => column.name === 'contributor_note')) {
throw new Error(
'Schema migration lost the contributor_note column from data_change_proposals.',
);
}
};
const proposalDefinition = await (
await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'",
@@ -2235,6 +2246,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2244,14 +2256,20 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_expanded
SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_expanded(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,
draft_entity_id,applied_entity_id,summary,contributor_note,payload_json,status,
proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
}
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
@@ -2282,6 +2300,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2291,14 +2310,19 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_weaponmods
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_weaponmods(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2329,6 +2353,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2338,14 +2363,19 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_specializations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_specializations(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2376,6 +2406,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2385,14 +2416,19 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_calculations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_calculations(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2427,6 +2463,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2436,11 +2473,14 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_source_owned
await database.exec(`INSERT INTO data_change_proposals_source_owned(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,
CASE WHEN module='triggers' THEN 'talents' ELSE module END,
CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action END,
entity_id,draft_entity_id,applied_entity_id,summary,payload_json,
entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,payload_json,
CASE WHEN module='triggers' AND status='pending' THEN 'failed' ELSE status END,
proposed_by,reviewed_by,
CASE
@@ -2466,6 +2506,7 @@ if (
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
+250
View File
@@ -2818,6 +2818,24 @@ a.secondary:active {
font-size: var(--shd-type-telemetry);
line-height: 1.35;
}
.talent-icon-identity .game-data-render-upload .image-drop-input {
grid-template-columns: 1fr;
gap: 6px;
text-align: center;
}
.talent-icon-identity .game-data-render-upload .image-upload-preview {
width: 100%;
}
.talent-icon-identity .game-data-render-upload .image-upload-actions {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
}
.talent-icon-identity .game-data-render-upload .image-upload-actions button {
min-width: 0;
padding: 7px 3px;
font-size: var(--shd-type-telemetry);
}
.dashboard-row > .registry-render-identity {
display: flex;
align-items: center;
@@ -19386,3 +19404,235 @@ a.secondary:active {
grid-template-columns: 1fr;
}
}
/* Public mechanic behavior status: Variant A integrated card hierarchy. */
.database-item-name-line {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 8px;
}
.database-item-name-line > h2 {
min-width: 0;
}
.database-mechanic-status {
flex: none;
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 4px 6px;
border: 1px solid var(--shd-model-border);
background: var(--shd-model-surface);
color: var(--shd-info);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.7px;
line-height: 1;
white-space: nowrap;
}
.database-mechanic-status.not-modeled {
border-color: var(--shd-border-strong);
background: var(--shd-surface-active);
color: var(--shd-text-muted);
}
.database-mechanic-groups {
display: grid;
gap: var(--shd-gap-sm);
margin: 10px 12px 0;
}
.database-mechanic-group {
min-width: 0;
display: grid;
gap: 6px;
}
.database-mechanic-group-context {
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 800;
letter-spacing: 0.5px;
}
.database-mechanic-group-identity {
min-height: 48px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--shd-border);
background: var(--shd-surface-raised);
}
.database-mechanic-group-identity > div {
min-width: 0;
display: grid;
gap: 3px;
}
.database-mechanic-group-identity small,
.database-mechanic-behavior > small,
.database-mechanic-variant-label > span,
.database-mechanic-display-source dt {
color: var(--shd-text-dim);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.8px;
line-height: 1.1;
}
.database-mechanic-group-identity strong {
overflow-wrap: anywhere;
color: var(--shd-text);
font-size: var(--shd-type-supporting);
line-height: 1.25;
}
.database-mechanic-variants {
display: grid;
gap: 6px;
}
.database-mechanic-variant {
min-width: 0;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
border: 1px solid var(--shd-model-border);
background: var(--shd-model-surface);
}
.database-mechanic-variant[data-status='not_modeled'] {
border-color: var(--shd-border);
background: var(--shd-surface-raised);
}
.database-mechanic-variant-label {
align-self: stretch;
display: grid;
align-content: start;
padding: 10px 8px;
border-right: 1px solid var(--shd-model-border);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label {
border-color: var(--shd-border);
}
.database-mechanic-variant-label > span {
color: var(--shd-info);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label > span {
color: var(--shd-text-muted);
}
.database-mechanic-behavior {
min-width: 0;
padding: 10px 12px;
border-left: 2px solid var(--shd-info);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior {
border-left-color: var(--shd-text-dim);
}
.database-mechanic-behavior > small {
display: block;
color: var(--shd-info);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior > small {
color: var(--shd-text-muted);
}
.database-mechanic-behavior > p {
margin: 6px 0 0;
color: var(--shd-text);
font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial);
}
.database-mechanic-group:not(.detail) .database-mechanic-behavior > p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.database-mechanic-display-source {
margin: 0;
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.database-mechanic-display-source > div {
display: grid;
gap: 4px;
padding: 9px 10px;
}
.database-mechanic-display-source dd {
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 800;
letter-spacing: 0.5px;
}
.database-specialization-effect-groups {
padding-top: 9px;
border-top: 1px solid var(--shd-border);
}
.database-preview-dialog:has(.database-mechanic-groups) {
width: min(760px, 100%);
}
.database-global-results > div > a {
grid-template-columns: 110px minmax(0, 0.8fr) minmax(120px, 1.35fr) auto auto;
grid-template-areas: 'category identity behavior status navigation';
}
.database-global-results > div > a > small {
grid-area: category;
}
.database-global-results > div > a > .database-global-result-identity {
grid-area: identity;
}
.database-global-results .database-global-result-behavior {
display: block;
grid-area: behavior;
}
.database-global-results .database-mechanic-status {
grid-area: status;
justify-self: end;
}
.database-global-results .database-global-result-navigation {
grid-area: navigation;
color: var(--shd-orange);
}
@media (max-width: 750px) {
.database-item-name-line {
display: grid;
gap: 7px;
}
.database-item-name-line .database-mechanic-status {
width: max-content;
}
.database-mechanic-variant {
grid-template-columns: 1fr;
}
.database-mechanic-variant-label {
padding: 7px 10px;
border-right: 0;
border-bottom: 1px solid var(--shd-model-border);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label {
border-color: var(--shd-border);
}
.database-mechanic-group-identity {
align-items: stretch;
flex-direction: column;
}
.database-mechanic-group-identity .database-mechanic-status {
width: max-content;
}
.database-global-results > div > a {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'category category'
'identity navigation'
'status navigation'
'behavior behavior';
}
.database-global-results .database-mechanic-status {
justify-self: start;
}
.database-global-results > div > a:not(:has(.database-mechanic-status)) {
grid-template-areas:
'category category'
'identity navigation'
'behavior behavior';
}
}
@media (prefers-reduced-motion: reduce) {
.database-mechanic-status,
.database-mechanic-variant {
transition: none;
}
}
+25 -6
View File
@@ -8,6 +8,7 @@ import {
} from '$lib/server/site-access';
import {
DATA_MODULES,
ProposalPayloadValidationError,
proposalActionResult,
queueDataProposal,
shouldModerateDataMutation,
@@ -82,12 +83,30 @@ export const handle: Handle = async ({ event, resolve }) => {
);
}
const submittedForm = await event.request.formData();
const proposal = await queueDataProposal(
user,
module,
action,
submittedForm,
);
let proposal: Awaited<ReturnType<typeof queueDataProposal>>;
try {
proposal = await queueDataProposal(
user,
module,
action,
submittedForm,
);
} catch (error) {
if (!(error instanceof ProposalPayloadValidationError)) throw error;
if (
event.request.headers.get('accept')?.includes('application/json')
) {
return json(
{
type: 'failure',
status: 413,
data: { proposalError: error.message },
},
{ status: 413 },
);
}
return new Response(error.message, { status: 413 });
}
if (event.request.headers.get('accept')?.includes('application/json')) {
const result = proposalActionResult(
module,
+581 -71
View File
@@ -10,12 +10,36 @@ import {
equippedConditionalProgram,
mechanicModifiersAt,
skillModifierWindowsForMechanic,
evaluateLoadoutCombat,
weaponCriticalChance,
} from './loadout-combat.js';
import {
calculateSkillDps,
type SkillAbilityInput,
} from '$lib/calculations/skill';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
function selectedRuntime(
variants: Record<string, MechanicProgram>,
options: Partial<
Pick<MechanicRuntimeSelection, 'simulationReadiness' | 'scenarioInputs'>
> = {},
): MechanicRuntimeSelection {
return {
owner: { type: 'talent', id: 'talent', scope: 'standard', hasTierII: true },
sourceId: 'source',
sourceFingerprint: 'a'.repeat(64),
revisionId: 'revision',
revisionNumber: 1,
simulationReadiness: options.simulationReadiness ?? 'ready',
scenarioInputs: options.scenarioInputs ?? [],
variants: Object.entries(variants).map(([key, program]) => ({
key,
label: key,
program,
})),
};
}
describe('loadout mechanic timeline', () => {
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
@@ -291,82 +315,568 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
source_type: 'talent',
source_id: 'exotic-talent',
configured: 1,
script_version: 1,
simulation_readiness: 'ready',
scenario_inputs_json: '[]',
},
statePools: [],
clauses: [
{
id: 'shot-clause',
rule_id: 'tier-i-only',
clause_key: 'shot',
label: 'Shot',
trigger_id: null,
event_kind: 'shot_fired',
parameters_json: '{}',
conditions_json: JSON.stringify({ kind: 'true' }),
reducer_json: JSON.stringify({ kind: 'none' }),
emission_kind: 'each_match',
state_pool_id: null,
mutations_json: '[]',
cooldown_seconds: null,
output_branch: 'active',
sort_order: 1,
active: 1,
},
],
outcomes: [
{
id: 'weapon-damage-i',
rule_id: 'tier-i-only',
clause_id: 'shot-clause',
state_pool_id: null,
effect_key: 'Weapon Damage',
variant_key: 'I',
outcome_key: 'weapon-damage',
application_kind: 'active_modifier',
effect_scope: 'equipped_weapon',
stat_attribute_id: 'weapon-damage',
value: 15,
value_expression_json: null,
value_basis: 'flat',
resource_kind: null,
combat_timing: null,
delivery_mode: 'instant',
delivery_duration_seconds: null,
targeting_json: null,
value_scaling: 'flat',
unit: '%',
damage_basis: null,
application_phase: 'on_event',
min_state_value: null,
max_state_value: null,
description_slot: 1,
sort_order: 1,
},
],
triggers: [],
it('selects a direct validated Phase 2 program without adapting legacy rows', () => {
const tierTwo: MechanicProgram = {
id: 'mechanic-runtime:["source","II"]',
name: 'Selected Tier II',
clauses: [],
};
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
rule: persistedRule,
expect(
equippedConditionalProgram({
key: 'weapon:0:talent',
tier: 2,
isExotic: false,
runtime: selectedRuntime({ I: { ...tierTwo, id: 'tier-I' }, II: tierTwo }),
}),
).toBe(tierTwo);
});
it('interpolates Skill Chip minimum and maximum programs from one selected revision', () => {
const program = (id: string, value: number): MechanicProgram => ({
id,
name: 'Skill Chip',
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value,
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
],
});
const runtime = selectedRuntime({
minimum: program('minimum', 10),
maximum: program('maximum', 40),
});
expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({
id: 'weapon-damage-i',
value: 15,
const interpolated = equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier: 2,
isExotic: false,
runtime,
});
expect(interpolated?.clauses[0].outcomes?.[0]?.value).toBe(30);
expect(interpolated?.id).toBe('minimum');
});
it('interpolates expression-backed outcomes, nested values, and pool durations', () => {
const program = (id: string, value: number): MechanicProgram => ({
id,
name: 'Skill Chip',
scenarioInputs: [
{
key: 'target-count',
label: 'Target count',
kind: 'slider',
readiness: 'ready',
defaultValue: value,
bestCaseValue: value + 3,
min: value - 2,
max: value + 7,
},
],
pools: [
{
key: 'chip-pool',
label: 'Chip pool',
scope: 'agent',
initial: value,
min: value - 2,
max: value * 2,
bestCaseValue: value + 2,
expiresAfterSeconds: value - 1,
},
],
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: {
kind: 'periodic',
immediate: true,
intervalSeconds: {
op: 'clamp',
value: {
op: 'add',
left: value,
right: { op: 'floor', value: value + 1 },
},
min: value - 1,
max: value * 3,
},
},
cooldownSeconds: value,
weight: {
op: 'multiply',
left: value,
right: {
op: 'subtract',
left: { ref: { source: 'scenario', key: 'target-count' } },
right: value - 1,
},
},
mutations: [
{
kind: 'add',
pool: 'chip-pool',
value: {
op: 'multiply',
left: value,
right: { op: 'add', left: value, right: 1 },
},
},
],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'all_skills',
value: {
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'add',
left: value,
right: { op: 'floor', value: value + 1 },
},
min: value - 1,
max: value * 3,
},
right: {
ref: { source: 'attribute', attributeId: 'skill-damage' },
},
},
basis: 'flat',
attributeId: 'skill-damage',
statePool: 'chip-pool',
perStack: true,
durationSeconds: value,
minStacks: value - 1,
maxStacks: value * 2,
probabilityPercent: value * 10,
},
],
},
],
});
const runtime = selectedRuntime({
minimum: program('minimum', 3),
maximum: program('maximum', 15),
});
const interpolated = equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier: 1,
isExotic: false,
runtime,
});
expect(interpolated?.scenarioInputs?.[0]).toMatchObject({
defaultValue: 7,
bestCaseValue: 10,
min: 5,
max: 14,
});
expect(interpolated?.pools?.[0]).toMatchObject({
initial: 7,
min: 5,
max: 14,
bestCaseValue: 9,
expiresAfterSeconds: 6,
});
expect(interpolated?.clauses[0]).toMatchObject({
cooldownSeconds: 7,
reducer: {
kind: 'periodic',
immediate: true,
intervalSeconds: {
op: 'clamp',
value: {
op: 'add',
left: 7,
right: { op: 'floor', value: 8 },
},
min: 6,
max: 21,
},
},
mutations: [
{
value: {
op: 'multiply',
left: 7,
right: { op: 'add', left: 7, right: 1 },
},
},
],
});
expect(interpolated?.clauses[0].outcomes?.[0]).toMatchObject({
value: {
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'add',
left: 7,
right: { op: 'floor', value: 8 },
},
min: 6,
max: 21,
},
right: {
ref: { source: 'attribute', attributeId: 'skill-damage' },
},
},
attributeId: 'skill-damage',
statePool: 'chip-pool',
perStack: true,
durationSeconds: 7,
minStacks: 6,
maxStacks: 14,
probabilityPercent: 70,
});
});
it('returns exact endpoint programs and retains quality-count clamping', () => {
const program = (id: string, value: number): MechanicProgram => ({
id,
name: 'Skill Chip',
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value,
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
],
});
const minimum = program('minimum', 10);
const maximum = program('maximum', 40);
const runtime = selectedRuntime({ minimum, maximum });
const selected = (tier: number) =>
equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier,
isExotic: false,
runtime,
});
expect(selected(-1)).toBe(minimum);
expect(selected(0)).toBe(minimum);
expect(selected(3)).toBe(maximum);
expect(selected(4)).toBe(maximum);
});
it('withholds Skill Chip execution when minimum and maximum graphs differ', () => {
const minimum: MechanicProgram = {
id: 'minimum',
name: 'Skill Chip',
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value: {
op: 'multiply',
left: 10,
right: {
ref: { source: 'attribute', attributeId: 'skill-damage' },
},
},
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
],
};
const maximum: MechanicProgram = {
...minimum,
id: 'maximum',
clauses: [
{
...minimum.clauses[0],
outcomes: [
{
...minimum.clauses[0].outcomes![0],
value: {
op: 'multiply',
left: 40,
right: {
ref: { source: 'attribute', attributeId: 'skill-duration' },
},
},
},
],
},
],
};
const selected = (
runtime: MechanicRuntimeSelection,
tier = 2,
) =>
equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier,
isExotic: false,
runtime,
});
expect(selected(selectedRuntime({ minimum }))).toBeNull();
expect(selected(selectedRuntime({ maximum }))).toBeNull();
expect(selected(selectedRuntime({ minimum, maximum }), 0)).toBeNull();
expect(selected(selectedRuntime({ minimum, maximum }))).toBeNull();
expect(selected(selectedRuntime({ minimum, maximum }), 3)).toBeNull();
const extraOutcomeMaximum: MechanicProgram = {
...minimum,
id: 'maximum-extra-outcome',
clauses: [
{
...minimum.clauses[0],
outcomes: [
...minimum.clauses[0].outcomes!,
{ ...minimum.clauses[0].outcomes![0], id: 'additional-outcome' },
],
},
],
};
expect(
selected(selectedRuntime({ minimum, maximum: extraOutcomeMaximum })),
).toBeNull();
});
it('falls back to Tier I only for an exotic selected revision that omits Tier II', () => {
const tierOne: MechanicProgram = {
id: 'mechanic-runtime:["exotic-source","I"]',
name: 'Selected Tier I',
clauses: [],
};
const runtime = selectedRuntime({ I: tierOne });
expect(
equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
runtime,
}),
).toBe(tierOne);
expect(
equippedConditionalProgram({
key: 'weapon:0:standard-talent',
tier: 2,
isExotic: false,
runtime,
}),
).toBeNull();
});
it('does not execute an unselected or stale legacy payload', () => {
const staleHistorical = {
key: 'weapon:0:talent',
tier: 1,
isExotic: false,
runtime: null,
rule: { clauses: [{ outcomes: [{ value: 999 }] }] },
};
expect(equippedConditionalProgram(staleHistorical)).toBeNull();
});
});
describe('requires-assumptions mechanic gating', () => {
const program: MechanicProgram = {
id: 'assumed-direct-damage',
name: 'Assumed direct damage',
scenarioInputs: [
{
key: 'assumed-direct-damage',
label: 'Assumed direct damage',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 100,
},
],
clauses: [
{
id: 'damage-on-hit',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'assumed-damage',
label: 'Assumed damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'equipped_weapon',
basis: 'flat',
value: {
ref: { source: 'scenario', key: 'assumed-direct-damage' },
},
},
],
},
],
};
function fixture(inputs?: Record<string, number | boolean>) {
const draft = createDraft();
for (const weapon of draft.weapons) {
weapon.itemId = 'weapon';
weapon.damage = 100;
}
for (const slot of Object.keys(draft.gear)) draft.gear[slot].itemId = 'gear';
for (const chip of draft.chips) chip.setId = 'chip';
draft.protocol.itemId = 'protocol';
draft.specialization.focusId = 'focus';
draft.conditionalEffects['weapon:0:assumed'] = {
mode: 'encounter',
...(inputs ? { inputs } : {}),
};
const runtime = selectedRuntime(
{ I: program },
{
simulationReadiness: 'requires_assumptions',
scenarioInputs: program.scenarioInputs,
},
);
return {
draft,
catalog: {
attributes: [
{
id: 'weapon-damage',
name: 'Weapon Damage',
calculation_type: 'Additive',
unit: '%',
cap_value: null,
},
],
gear: [],
gearAugments: [],
brands: [],
weapons: [
{
id: 'weapon',
name: 'Weapon',
category: 'Assault Rifle',
rpm: 60,
magazine: 100,
reload_speed: 1,
},
],
talents: [],
chipSets: [],
protocols: [],
specializations: [
{
focuses: [
{
id: 'focus',
abilities: [{ id: 'ability-1' }, { id: 'ability-2' }, { id: 'ability-3' }],
nodes: [],
},
],
},
],
calculation: {
revision: { id: 'revision', version: 1 },
stages: [],
profiles: [
{
weapon_category: '*',
stage_key: 'weapon_damage',
stat_attribute_id: 'weapon-damage',
weight: 1,
},
],
},
},
summary: {
stats: [{ id: 'weapon-damage', value: 0, sourceCount: 0 }],
abilityStats: [],
activeBonuses: [],
equippedTalents: [
{
key: 'weapon:0:assumed',
id: 'assumed',
runtime,
tier: 1,
isExotic: false,
source: 'weapon',
description: '',
custom: { name: 'Assumed direct damage' },
},
],
},
};
}
it('blocks combat output when an active requires-assumptions mechanic has no caller input', () => {
const { draft, catalog, summary } = fixture();
const primary = evaluateLoadoutCombat(draft, catalog, summary)[0];
expect(primary.status).toBe('insufficient');
if (primary.status === 'insufficient') {
expect(primary.missing).toContain(
'Conditional mechanic Assumed direct damage requires explicit valid scenario inputs: Assumed direct damage',
);
}
});
it('executes a requires-assumptions mechanic when the caller supplies a valid input', () => {
const { draft, catalog, summary } = fixture({
'assumed-direct-damage': 50,
});
const primary = evaluateLoadoutCombat(draft, catalog, summary)[0];
expect(primary.status).toBe('complete');
if (primary.status === 'complete') {
expect(primary.effectSummaries?.[0]?.activationCount).toBeGreaterThan(0);
expect(primary.encounterDps).toBeGreaterThan(100);
}
});
});
+248 -88
View File
@@ -14,9 +14,8 @@ import {
type MechanicEvent,
type MechanicOutcome,
type MechanicProgram,
type NumericExpression,
} from '$lib/calculations/mechanic';
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
import {
calculateSkillDps,
skillActivationTimes,
@@ -192,103 +191,157 @@ export function weaponCriticalChance(
);
}
function conditionalProgram(
rule: any,
function selectedRuntimeProgram(
runtime: MechanicRuntimeSelection | null | undefined,
variantKey: string,
valueScale = 1,
): MechanicProgram | null {
if (!rule?.rule) return null;
const adapted = adaptConditionalEffectProgram(rule, { variantKey });
if (
!adapted.program ||
adapted.status === 'INVALID' ||
adapted.status === 'CAPTURED_ONLY'
)
return null;
if (valueScale === 1) return adapted.program;
const scale = (value: NumericExpression): NumericExpression =>
typeof value === 'number' ? value * valueScale : value;
return {
...adapted.program,
clauses: adapted.program.clauses.map((clause) => ({
...clause,
outcomes: clause.outcomes?.map((outcome) => ({
...outcome,
value: scale(outcome.value),
})),
})),
};
return runtime?.variants.find((variant) => variant.key === variantKey)?.program ?? null;
}
function interpolatedConditionalProgram(
rule: any,
function hasMechanicProgram<T extends { program: MechanicProgram | null }>(
source: T,
): source is T & { program: MechanicProgram } {
return source.program !== null;
}
type InterpolatedMechanicValue = { value: unknown };
function isMechanicDefinitionRecord(
value: unknown,
): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/**
* Interpolates numeric leaves only when the entire mechanic definition graph
* matches. Non-numeric values carry mechanic identity and semantics, so a
* mismatch withholds the program rather than combining different mechanics.
*/
function interpolateMechanicValue(
minimum: unknown,
maximum: unknown,
ratio: number,
): InterpolatedMechanicValue | null {
if (typeof minimum === 'number' || typeof maximum === 'number') {
if (
typeof minimum !== 'number' ||
typeof maximum !== 'number' ||
!Number.isFinite(minimum) ||
!Number.isFinite(maximum)
)
return null;
return { value: minimum + (maximum - minimum) * ratio };
}
if (Object.is(minimum, maximum)) return { value: minimum };
if (Array.isArray(minimum) || Array.isArray(maximum)) {
if (
!Array.isArray(minimum) ||
!Array.isArray(maximum) ||
minimum.length !== maximum.length
)
return null;
const values: unknown[] = [];
for (let index = 0; index < minimum.length; index += 1) {
const interpolated = interpolateMechanicValue(
minimum[index],
maximum[index],
ratio,
);
if (!interpolated) return null;
values.push(interpolated.value);
}
return { value: values };
}
if (
!isMechanicDefinitionRecord(minimum) ||
!isMechanicDefinitionRecord(maximum)
)
return null;
const minimumKeys = Object.keys(minimum).sort();
const maximumKeys = Object.keys(maximum).sort();
if (
minimumKeys.length !== maximumKeys.length ||
minimumKeys.some((key, index) => key !== maximumKeys[index])
)
return null;
const entries: [string, unknown][] = [];
for (const key of minimumKeys) {
const interpolated = interpolateMechanicValue(
minimum[key],
maximum[key],
ratio,
);
if (!interpolated) return null;
entries.push([key, interpolated.value]);
}
return { value: Object.fromEntries(entries) };
}
function interpolateMechanicProgram(
minimum: MechanicProgram,
maximum: MechanicProgram,
ratio: number,
): MechanicProgram | null {
const { id: minimumId, ...minimumDefinition } = minimum;
const { id: _maximumId, ...maximumDefinition } = maximum;
const interpolated = interpolateMechanicValue(
minimumDefinition,
maximumDefinition,
ratio,
);
if (!interpolated || !isMechanicDefinitionRecord(interpolated.value))
return null;
return { id: minimumId, ...interpolated.value } as MechanicProgram;
}
function interpolatedRuntimeProgram(
runtime: MechanicRuntimeSelection | null | undefined,
minimumVariant: string,
maximumVariant: string,
step: number,
totalSteps: number,
): MechanicProgram | null {
const minimum = conditionalProgram(rule, minimumVariant);
const maximum = conditionalProgram(rule, maximumVariant);
if (!minimum || !maximum) return minimum ?? maximum;
const minimum = selectedRuntimeProgram(runtime, minimumVariant);
const maximum = selectedRuntimeProgram(runtime, maximumVariant);
if (!minimum || !maximum) return null;
const ratio = Math.max(
0,
Math.min(1, Number(step) / Math.max(1, Number(totalSteps))),
);
const maximumByKey = new Map(
maximum.clauses.flatMap((clause) =>
(clause.outcomes ?? []).map(
(outcome) => [`${clause.id}:${outcome.id}`, outcome] as const,
),
),
);
return {
...minimum,
clauses: minimum.clauses.map((clause) => ({
...clause,
outcomes: clause.outcomes?.map((outcome) => {
const upper = maximumByKey.get(`${clause.id}:${outcome.id}`);
return upper &&
typeof outcome.value === 'number' &&
typeof upper.value === 'number'
? {
...outcome,
value: outcome.value + (upper.value - outcome.value) * ratio,
}
: outcome;
}),
})),
};
if (!Number.isFinite(ratio)) return null;
const interpolated = interpolateMechanicProgram(minimum, maximum, ratio);
if (!interpolated) return null;
if (ratio === 0) return minimum;
if (ratio === 1) return maximum;
return interpolated;
}
export function equippedConditionalProgram(
equipped: any,
equipped: Pick<
LoadoutSummary['equippedTalents'][number],
'key' | 'runtime' | 'tier' | 'isExotic'
>,
): MechanicProgram | null {
if (String(equipped.key).startsWith('chips:')) {
return interpolatedConditionalProgram(
equipped.rule,
if (equipped.key.startsWith('chips:')) {
return interpolatedRuntimeProgram(
equipped.runtime,
'minimum',
'maximum',
Number(equipped.tier),
3,
);
}
if (String(equipped.key).startsWith('protocol:')) {
return conditionalProgram(equipped.rule, 'base');
if (equipped.key.startsWith('protocol:')) {
return selectedRuntimeProgram(equipped.runtime, 'base');
}
const equipmentTalent =
String(equipped.key).startsWith('weapon:') ||
String(equipped.key).startsWith('gear:');
const variant = equipmentTalent
? Number(equipped.tier) >= 2
? 'II'
: 'I'
: 'base';
return (
conditionalProgram(equipped.rule, variant) ??
equipped.key.startsWith('weapon:') || equipped.key.startsWith('gear:');
const variant = equipmentTalent && Number(equipped.tier) >= 2 ? 'II' : 'I';
const selected = selectedRuntimeProgram(equipped.runtime, variant);
return selected ??
(equipmentTalent && equipped.isExotic && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null)
);
? selectedRuntimeProgram(equipped.runtime, 'I')
: null);
}
export type ActiveMechanicEvaluation = {
@@ -299,15 +352,58 @@ export type ActiveMechanicEvaluation = {
events?: MechanicEvent[];
};
function configuredScenarioInputValues(configured: unknown) {
return configured && typeof configured === 'object' && !Array.isArray(configured)
? (configured as Record<string, unknown>)
: {};
}
function requiredScenarioInputs(program: MechanicProgram) {
const inputs = program.scenarioInputs ?? [];
const assumptionInputs = inputs.filter(
(input) => input.readiness === 'requires_assumption',
);
return assumptionInputs.length ? assumptionInputs : inputs;
}
function isValidScenarioInputValue(
input: NonNullable<MechanicProgram['scenarioInputs']>[number],
value: unknown,
) {
if (input.kind === 'toggle') return typeof value === 'boolean';
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
if (input.min !== undefined && value < input.min) return false;
if (input.max !== undefined && value > input.max) return false;
return true;
}
function unresolvedScenarioInputs(
program: MechanicProgram,
configured: unknown,
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'],
) {
if (simulationReadiness !== 'requires_assumptions') return [];
const inputs = requiredScenarioInputs(program);
if (!inputs.length) return ['no supported required scenario inputs'];
const values = configuredScenarioInputValues(configured);
return inputs.flatMap((input) => {
if (!Object.prototype.hasOwnProperty.call(values, input.key))
return [input.label];
return isValidScenarioInputValue(input, values[input.key])
? []
: [`${input.label} (invalid value)`];
});
}
function mechanicScenarioInputs(
program: MechanicProgram,
configured: unknown,
maximum = false,
) {
const values =
configured && typeof configured === 'object' && !Array.isArray(configured)
? (configured as Record<string, number | boolean>)
: {};
): Record<string, number | boolean> {
const values = configuredScenarioInputValues(configured) as Record<
string,
number | boolean
>;
return Object.fromEntries(
(program.scenarioInputs ?? []).map((input) => [
input.key,
@@ -322,22 +418,59 @@ function mechanicScenarioInputs(
);
}
function mechanicIsEnabled(
state: Pick<LoadoutDraft['conditionalEffects'][string], 'mode'>,
calculationMode: LoadoutDraft['calculationMode'],
) {
return calculationMode === 'maximum' || state.mode !== 'off';
}
function unavailableMechanicEvidence(
sources: ReadonlyArray<{
key: string;
name: string;
program: MechanicProgram;
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'];
}>,
draft: LoadoutDraft,
) {
return sources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
if (!mechanicIsEnabled(state, draft.calculationMode)) return [];
const inputs = unresolvedScenarioInputs(
source.program,
state.inputs,
source.simulationReadiness,
);
return inputs.length
? [
`Conditional mechanic ${source.name} requires explicit valid scenario inputs: ${inputs.join(', ')}`,
]
: [];
});
}
function evaluateConfiguredMechanic(
program: MechanicProgram,
buildEvents: (
scenarioInputs: Record<string, number | boolean>,
) => MechanicEvent[],
state: any,
state: LoadoutDraft['conditionalEffects'][string],
profile: LoadoutDraft['combatProfile'],
weaponBaseDamage: number,
maxHealth: number,
calculationMode: LoadoutDraft['calculationMode'],
equipmentContext = { protocolTalentUpgrades: 0 },
attributes: Record<string, number> = {},
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'] = 'ready',
): ActiveMechanicEvaluation | null {
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
if (!mechanicIsEnabled(state, calculationMode)) return null;
if (unresolvedScenarioInputs(program, state.inputs, simulationReadiness).length)
return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
const maximum = calculationMode === 'maximum' || state.mode === 'peak';
const maximum =
simulationReadiness !== 'requires_assumptions' &&
(calculationMode === 'maximum' || state.mode === 'peak');
const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum);
const events = buildEvents(scenarioInputs);
const result = evaluateMechanicProgram(
@@ -1045,7 +1178,7 @@ function skillModifierWindows(
catalog: Catalog,
summary: LoadoutSummary,
allowedAttributeIds: Set<string>,
): SkillModifierWindow[] {
): { windows: SkillModifierWindow[]; unavailable: string[] } {
const focus = selectedFocus(draft, catalog);
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(
({ node, rank }: any) =>
@@ -1058,21 +1191,25 @@ function skillModifierWindows(
const sources = [
...summary.equippedTalents.map((equipped: any) => ({
key: String(equipped.key),
name: String(equipped.custom?.name ?? 'Talent'),
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
equipmentContext: {
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
? Number(equipped.tier)
: 0,
},
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
program: equippedConditionalProgram(equipped),
})),
...combatEffects.map((effect: any) => ({
key: String(effect.id),
name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'),
abilityId: effect.abilityId,
equipmentContext: { protocolTalentUpgrades: 0 },
program: conditionalProgram(effect.rule, 'base'),
simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready',
program: selectedRuntimeProgram(effect.runtime, 'base'),
})),
].filter((source) => source.program);
].filter(hasMechanicProgram);
const primarySelection = draft.weapons[0];
const primaryWeapon = catalog.weapons.find(
(weapon) => weapon.id === primarySelection.itemId,
@@ -1123,6 +1260,7 @@ function skillModifierWindows(
draft.calculationMode,
source.equipmentContext,
baseAttributes,
source.simulationReadiness,
);
return evaluation ? [{ ...source, evaluation }] : [];
});
@@ -1138,7 +1276,10 @@ function skillModifierWindows(
);
}
return windows;
return {
windows,
unavailable: unavailableMechanicEvidence(sources, draft),
};
}
export function evaluateLoadoutCombat(
@@ -1166,6 +1307,7 @@ export function evaluateLoadoutCombat(
? Number(equipped.tier)
: 0,
},
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
program: equippedConditionalProgram(equipped),
})),
...combatEffects.map((effect: any) => ({
@@ -1174,9 +1316,10 @@ export function evaluateLoadoutCombat(
effect.definition?.name ?? effect.nodeName ?? 'Specialization effect',
),
equipmentContext: { protocolTalentUpgrades: 0 },
program: conditionalProgram(effect.rule, 'base'),
simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready',
program: selectedRuntimeProgram(effect.runtime, 'base'),
})),
].filter((source) => source.program);
].filter(hasMechanicProgram);
const skillDeploymentEvents = loadoutSkillDeploymentEvents(
draft,
catalog,
@@ -1201,6 +1344,7 @@ export function evaluateLoadoutCombat(
missing.push(
`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`,
);
missing.push(...unavailableMechanicEvidence(conditionalSources, draft));
return missing;
};
@@ -1251,6 +1395,7 @@ export function evaluateLoadoutCombat(
draft.calculationMode,
source.equipmentContext,
criticalAttributes,
source.simulationReadiness,
);
return evaluation ? [{ ...source, state, evaluation }] : [];
});
@@ -1418,6 +1563,21 @@ export function evaluateLoadoutSkillCombat(
.map((stat) => [stat.id, stat.value]),
);
const abilities = loadoutSkillAbilities(draft, catalog);
const mechanicWindows = skillModifierWindows(
draft,
catalog,
summary,
boundIds,
);
if (mechanicWindows.unavailable.length) {
return {
status: 'INSUFFICIENT_DATA',
revisionId: revisionId || null,
missing: mechanicWindows.unavailable,
abilities: [],
combined: null,
};
}
return calculateSkillDps({
rules: { revisionId, bindings },
abilities,
@@ -1440,7 +1600,7 @@ export function evaluateLoadoutSkillCombat(
];
}),
),
modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds),
modifierWindows: mechanicWindows.windows,
assumptions: {
encounterSeconds: draft.combatProfile.encounterSeconds,
targetCount: Math.max(
+83 -17
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
import { createDraft } from './loadout.js';
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
@@ -35,6 +36,24 @@ const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
summary.stats.find((entry) => entry.id === id);
const selectedRuntime = (
sourceId: string,
variantKeys: string[],
): MechanicRuntimeSelection => ({
owner: { type: 'talent', id: sourceId, scope: 'standard', hasTierII: true },
sourceId,
sourceFingerprint: 'a'.repeat(64),
revisionId: `${sourceId}-revision`,
revisionNumber: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
variants: variantKeys.map((key) => ({
key,
label: key,
program: { id: `${sourceId}:${key}`, name: sourceId, clauses: [] },
})),
});
describe('summarizeLoadout', () => {
it('returns an empty deterministic summary for a partial draft', () => {
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
@@ -193,7 +212,6 @@ describe('summarizeLoadout', () => {
max_value: 45,
},
],
rule: null,
},
},
],
@@ -399,7 +417,6 @@ describe('summarizeLoadout', () => {
name: 'Recovery',
description_template: 'Restore {1}.',
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
rule: null,
},
},
],
@@ -536,19 +553,6 @@ describe('summarizeLoadout', () => {
exoticTalentTier: 1,
});
const rule = {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'talent-stat',
value: 100,
},
],
},
],
};
const summary = summarizeLoadout(
draft,
catalog({
@@ -585,7 +589,6 @@ describe('summarizeLoadout', () => {
],
},
],
rule,
},
{
id: 'talent-two',
@@ -627,7 +630,7 @@ describe('summarizeLoadout', () => {
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
runtime: null,
}),
expect.objectContaining({
id: 'talent-two',
@@ -645,6 +648,69 @@ describe('summarizeLoadout', () => {
expect(stat(summary, 'talent-stat')).toBeUndefined();
});
it('carries selected runtime capabilities without retaining legacy rule payloads', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
for (const chip of draft.chips.slice(0, 3)) chip.setId = 'chip-set';
draft.protocol.itemId = 'protocol';
const talentRuntime = selectedRuntime('talent-source', ['I', 'II']);
const chipRuntime = selectedRuntime('chip-source', ['minimum', 'maximum']);
const protocolRuntime = selectedRuntime('protocol-source', ['base']);
const summary = summarizeLoadout(
draft,
catalog({
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Talent',
runtime: talentRuntime,
template: 'Talent',
tiers: [{ tier: 'I', effects: [] }],
},
],
chipSets: [
{
id: 'chip-set',
name: 'Chip',
specialization_ability_id: 'ability',
talent: {
name: 'Chip Talent',
runtime: chipRuntime,
description_template: 'Chip',
effects: [],
},
},
],
protocols: [
{
id: 'protocol',
name: 'Protocol',
talent: {
name: 'Protocol Talent',
runtime: protocolRuntime,
description_template: 'Protocol',
effects: [],
},
},
],
}),
);
expect(summary.equippedTalents.map((equipped) => equipped.runtime)).toEqual([
talentRuntime,
chipRuntime,
protocolRuntime,
]);
expect(
summary.equippedTalents.every(
(equipped) => !Object.hasOwn(equipped, 'rule'),
),
).toBe(true);
});
it('uses the authored tier description when a Talent has no template row', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
+20 -8
View File
@@ -4,6 +4,7 @@ import {
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
export type LoadoutCatalog = {
attributes: any[];
@@ -38,7 +39,18 @@ export type LoadoutSummary = {
}>;
}>;
activeBonuses: any[];
equippedTalents: any[];
equippedTalents: Array<{
key: string;
id: string;
runtime: MechanicRuntimeSelection | null;
tier: number;
tierLabel?: string;
isExotic?: boolean;
abilityId?: string | null;
source: string;
description: string;
custom?: { name?: string };
}>;
};
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
@@ -164,7 +176,7 @@ export function summarizeLoadout(
)?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
const equippedTalents: LoadoutSummary['equippedTalents'] = [];
const abilityValues = new Map<string, Map<string, number>>();
const abilitySources = new Map<
string,
@@ -260,9 +272,9 @@ export function summarizeLoadout(
equippedTalents.push({
key: `gear:${slot}:${selection.talentId}`,
id: selection.talentId,
rule:
runtime:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
?.runtime ?? null,
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
@@ -336,9 +348,9 @@ export function summarizeLoadout(
equippedTalents.push({
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
rule:
runtime:
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.rule ?? null,
?.runtime ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
@@ -434,7 +446,7 @@ export function summarizeLoadout(
key: `chips:${group.item.id}:${group.abilityId}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
runtime: group.item.talent.runtime ?? null,
tier: group.highEnd,
abilityId: group.abilityId || null,
source: '3 Skill Chips',
@@ -486,7 +498,7 @@ export function summarizeLoadout(
key: `protocol:${protocol.id}`,
id: '',
custom: protocol.talent,
rule: protocol.talent.rule ?? null,
runtime: protocol.talent.runtime ?? null,
tier: draft.protocol.talentUpgrades,
source: 'OS Protocol',
description: protocol.talent.description_template.replace(
@@ -0,0 +1,294 @@
import { describe, expect, it } from 'vitest';
import {
materializeMechanicAuthoredDescription,
mechanicAuthoredFingerprintPayload,
parseMechanicAuthoredSource,
type MechanicAuthoredSource,
} from './mechanic-authored-description.js';
const talentSource = (): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
template: 'Gain {1}% for {duration} seconds. Gain {1}% again, up to {max_stacks}.',
variants: ['I', 'II'].map((key, index) => ({
key,
materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`,
slots: [
{ key: 'max_stacks', value: '3', order: 2 },
{ key: '1', value: `${10 + index}`, order: 0, unit: 'percent' },
{ key: 'duration', value: '5', order: 1, unit: 'seconds' },
],
})),
});
describe('mechanic authored descriptions', () => {
it('materializes repeated strict tokens for ordered Talent variants', () => {
expect(materializeMechanicAuthoredDescription(talentSource())).toEqual({
ok: true,
variants: talentSource().variants.map((variant) => ({
key: variant.key,
text: variant.materialized,
slots: variant.slots,
})),
});
});
it.each([
{
owner: { type: 'talent', id: 'exotic', scope: 'exotic' } as const,
keys: ['I'],
},
{
owner: {
type: 'talent',
id: 'exotic',
scope: 'exotic',
hasTierII: true,
} as const,
keys: ['I', 'II'],
},
{
owner: { type: 'os_protocol_talent', id: 'protocol' } as const,
keys: ['base'],
},
{
owner: { type: 'skill_chip_talent', id: 'chip' } as const,
keys: ['minimum', 'maximum'],
},
])('enforces the $owner.type variant contract', ({ owner, keys }) => {
const source: MechanicAuthoredSource = {
version: 1,
owner,
template: 'Gain {1}%.',
variants: keys.map((key) => ({
key,
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10', order: 0 }],
})),
};
expect(materializeMechanicAuthoredDescription(source).ok).toBe(true);
});
it('requires Specialization rank source identity', () => {
const result = materializeMechanicAuthoredDescription({
version: 1,
owner: { type: 'specialization_tree_rank', id: 'rank-1' },
template: 'Gain {1}%.',
variants: [
{
key: 'base',
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10', order: 0 }],
},
],
});
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_specialization_source' }],
});
});
it('rejects malformed and slot-injected placeholders', () => {
const malformed = talentSource();
malformed.template = 'Gain {1.';
malformed.variants.forEach((variant) => {
variant.materialized = 'Gain {1.';
variant.slots = [];
});
expect(materializeMechanicAuthoredDescription(malformed)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unknown_token' }),
]),
});
const injected = talentSource();
injected.variants[0].slots.find((slot) => slot.key === '1')!.value =
'{raw}';
injected.variants[0].materialized =
'Gain {raw}% for 5 seconds. Gain {raw}% again, up to 3.';
expect(materializeMechanicAuthoredDescription(injected)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('rejects units embedded in authored slot values', () => {
const source = talentSource();
source.variants[0].slots.find((slot) => slot.key === '1')!.value = '10%';
expect(materializeMechanicAuthoredDescription(source)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
]),
});
});
it('rejects unknown, unresolved, unused, and mismatched authored values', () => {
const source = talentSource();
source.template = 'Gain {1}% for {cooldown} seconds.';
source.variants[0].slots.push({ key: '2', value: '4', order: 4 });
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(new Set(result.errors.map((error) => error.code))).toEqual(
new Set([
'unknown_token',
'unresolved_token',
'unused_slot',
'materialization_mismatch',
]),
);
});
it('rejects a missing required slot rather than retaining its placeholder', () => {
const source = talentSource();
source.variants[0].slots = source.variants[0].slots.filter(
(slot) => slot.key !== 'duration',
);
const result = materializeMechanicAuthoredDescription(source);
expect(result).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('preserves exact authored whitespace and materialized prose', () => {
const source = talentSource();
source.template = ' Gain {1}%.\nNext line. ';
for (const variant of source.variants) {
const value = variant.slots.find((slot) => slot.key === '1')?.value;
variant.slots = variant.slots.filter((slot) => slot.key === '1');
variant.materialized = ` Gain ${value}%.\nNext line. `;
}
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok && result.variants[0].text).toBe(
' Gain 10%.\nNext line. ',
);
});
it('parses unknown source values without throwing on malformed input', () => {
expect(parseMechanicAuthoredSource(null)).toMatchObject({
ok: false,
errors: [{ code: 'invalid_source' }],
});
expect(
parseMechanicAuthoredSource({
...talentSource(),
owner: {
type: new String('os_protocol_talent'),
id: 'protocol-1',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(
parseMechanicAuthoredSource({
...talentSource(),
variants: [{ key: 'I', materialized: 10, slots: [] }],
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(parseMechanicAuthoredSource(talentSource())).toMatchObject({
ok: true,
});
});
it('rejects malformed typed values and whitespace-only authored content', () => {
expect(
materializeMechanicAuthoredDescription({
...talentSource(),
owner: {
type: 'talent',
id: 'talent-1',
scope: 'exotic',
hasTierII: 'false',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
const whitespace = talentSource();
whitespace.template = ' ';
whitespace.variants.forEach((variant) => {
variant.materialized = ' ';
variant.slots = [];
});
expect(materializeMechanicAuthoredDescription(whitespace)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_template' }),
]),
});
});
it('strictly parses Talent Tier II and unknown source fields', () => {
expect(
parseMechanicAuthoredSource({
...talentSource(),
owner: {
type: 'talent',
id: 'talent-1',
scope: 'exotic',
hasTierII: 'false',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(
parseMechanicAuthoredSource({ ...talentSource(), unexpected: true }),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
});
it('normalizes omitted and false optional Talent Tier II flags identically', () => {
const omitted = talentSource();
const explicit = talentSource();
explicit.owner = {
type: 'talent',
id: 'talent-1',
scope: 'standard',
hasTierII: false,
};
expect(mechanicAuthoredFingerprintPayload(omitted)).toEqual(
mechanicAuthoredFingerprintPayload(explicit),
);
});
it('reports unsupported parser contracts specifically', () => {
expect(
parseMechanicAuthoredSource({ ...talentSource(), version: 2 }),
).toMatchObject({
ok: false,
errors: [{ code: 'unsupported_contract', path: 'version' }],
});
});
it('rejects duplicate slot ordering', () => {
const source = talentSource();
source.variants[0].slots[1].order = source.variants[0].slots[0].order;
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
]),
);
});
it('creates a versioned canonical fingerprint payload with ordered slots', () => {
expect(mechanicAuthoredFingerprintPayload(talentSource())).toMatchObject({
kind: 'mechanic_authored_source',
version: 1,
owner: { type: 'talent', id: 'talent-1' },
variants: [
{
key: 'I',
slots: [{ key: '1' }, { key: 'duration' }, { key: 'max_stacks' }],
},
{ key: 'II' },
],
});
});
});
@@ -0,0 +1,431 @@
export const MECHANIC_AUTHORED_CONTRACT_VERSION = 1 as const;
export type MechanicAuthoredOwner =
| {
type: 'talent';
id: string;
scope: 'standard' | 'exotic';
hasTierII?: boolean;
}
| { type: 'os_protocol_talent'; id: string }
| { type: 'skill_chip_talent'; id: string }
| { type: 'specialization_tree_rank'; id: string };
export type MechanicAuthoredSlot = {
key: string;
value: string;
order: number;
unit?: string;
attributeId?: string;
};
export type MechanicAuthoredVariant = {
key: string;
materialized: string;
slots: MechanicAuthoredSlot[];
};
export type SpecializationAuthoredInputs = {
rankDescription: string;
effectId: string;
effectTemplate: string;
};
export type MechanicAuthoredSource = {
version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION;
owner: MechanicAuthoredOwner;
template: string;
variants: MechanicAuthoredVariant[];
specialization?: SpecializationAuthoredInputs;
};
export type MaterializedMechanicVariant = {
key: string;
text: string;
slots: MechanicAuthoredSlot[];
};
export type MechanicAuthoredDescriptionError = {
code:
| 'invalid_source'
| 'unsupported_contract'
| 'invalid_owner'
| 'invalid_template'
| 'invalid_variant'
| 'invalid_slot'
| 'unknown_token'
| 'unresolved_token'
| 'unused_slot'
| 'materialization_mismatch'
| 'invalid_specialization_source';
path: string;
message: string;
};
export type MechanicAuthoredDescriptionResult =
| { ok: true; variants: MaterializedMechanicVariant[] }
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type ParsedMechanicAuthoredSourceResult =
| { ok: true; value: MechanicAuthoredSource }
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type MechanicAuthoredFingerprintPayload = {
kind: 'mechanic_authored_source';
version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION;
owner: MechanicAuthoredOwner;
template: string;
variants: Array<{
key: string;
materialized: string;
slots: MechanicAuthoredSlot[];
}>;
specialization?: SpecializationAuthoredInputs;
};
const TOKEN_PATTERN = /\{([^{}]+)\}/g;
const NUMERIC_SLOT_VALUE_PATTERN = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/;
const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']);
const objectValue = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const hasOnlyKeys = (
value: Record<string, unknown>,
allowed: readonly string[],
) => Object.keys(value).every((key) => allowed.includes(key));
function requiredVariantKeys(owner: MechanicAuthoredOwner): readonly string[] {
switch (owner.type) {
case 'talent':
return owner.scope === 'standard' || owner.hasTierII
? ['I', 'II']
: ['I'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
}
function tokenKeys(template: string) {
return [...template.matchAll(TOKEN_PATTERN)].map((match) => match[1]);
}
function isAllowedToken(key: string) {
return ALLOWED_NAMED_TOKENS.has(key) || /^[1-9]\d*$/.test(key);
}
function sourceErrors(source: MechanicAuthoredSource) {
const errors: MechanicAuthoredDescriptionError[] = [];
if (source.version !== MECHANIC_AUTHORED_CONTRACT_VERSION) {
errors.push({
code: 'unsupported_contract',
path: 'version',
message: `Authored-description contract version ${String(source.version)} is not supported.`,
});
}
if (!source.owner.id.trim()) {
errors.push({
code: 'invalid_owner',
path: 'owner.id',
message: 'The owner ID is required.',
});
}
if (!source.template.trim()) {
errors.push({
code: 'invalid_template',
path: 'template',
message: 'The authored template is required.',
});
}
const tokens = tokenKeys(source.template);
if (/[{}]/.test(source.template.replace(TOKEN_PATTERN, ''))) {
errors.push({
code: 'unknown_token',
path: 'template',
message: 'The authored template contains malformed placeholder braces.',
});
}
for (const token of tokens) {
if (!isAllowedToken(token)) {
errors.push({
code: 'unknown_token',
path: 'template',
message: `The authored template contains unsupported token {${token}}.`,
});
}
}
if (source.owner.type === 'specialization_tree_rank') {
if (
!source.specialization?.rankDescription.trim() ||
!source.specialization.effectId.trim() ||
!source.specialization.effectTemplate.trim()
) {
errors.push({
code: 'invalid_specialization_source',
path: 'specialization',
message:
'Specialization rank description, effect identity, and effect template are required.',
});
}
} else if (source.specialization !== undefined) {
errors.push({
code: 'invalid_specialization_source',
path: 'specialization',
message:
'Specialization inputs are valid only for a Specialization tree-rank owner.',
});
}
const expected = requiredVariantKeys(source.owner);
const actual = source.variants.map((variant) => variant.key);
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
errors.push({
code: 'invalid_variant',
path: 'variants',
message: `Expected authored variants in this order: ${expected.join(', ')}.`,
});
}
return { errors, tokens };
}
function materializeKnownMechanicAuthoredDescription(
source: MechanicAuthoredSource,
): MechanicAuthoredDescriptionResult {
const { errors, tokens } = sourceErrors(source);
const usedTokens = new Set(tokens.filter(isAllowedToken));
const variants: MaterializedMechanicVariant[] = [];
source.variants.forEach((variant, variantIndex) => {
const path = `variants[${variantIndex}]`;
const slots = new Map<string, MechanicAuthoredSlot>();
const slotOrders = new Set<number>();
for (const [slotIndex, slot] of variant.slots.entries()) {
if (
!isAllowedToken(slot.key) ||
!Number.isInteger(slot.order) ||
slot.order < 0 ||
!NUMERIC_SLOT_VALUE_PATTERN.test(slot.value) ||
slots.has(slot.key) ||
slotOrders.has(slot.order)
) {
errors.push({
code: 'invalid_slot',
path: `${path}.slots[${slotIndex}]`,
message: `Authored slot ${slot.key || '(empty)'} must contain a plain numeric value with a unique key and order.`,
});
continue;
}
slots.set(slot.key, slot);
slotOrders.add(slot.order);
if (!usedTokens.has(slot.key)) {
errors.push({
code: 'unused_slot',
path: `${path}.slots[${slotIndex}]`,
message: `Authored slot ${slot.key} is not referenced by the template.`,
});
}
}
for (const token of usedTokens) {
if (!slots.has(token)) {
errors.push({
code: 'unresolved_token',
path: `${path}.slots`,
message: `Authored token {${token}} has no value for variant ${variant.key}.`,
});
}
}
const text = source.template.replace(TOKEN_PATTERN, (match, key: string) =>
isAllowedToken(key) ? (slots.get(key)?.value ?? match) : match,
);
if (/[{}]/.test(text)) {
errors.push({
code: 'unresolved_token',
path: `${path}.materialized`,
message: `Materialized variant ${variant.key} contains an unresolved placeholder.`,
});
}
if (variant.materialized !== text) {
errors.push({
code: 'materialization_mismatch',
path: `${path}.materialized`,
message: `Stored materialization for variant ${variant.key} does not match the authored source.`,
});
}
variants.push({ key: variant.key, text, slots: variant.slots });
});
return errors.length ? { ok: false, errors } : { ok: true, variants };
}
function parseMechanicAuthoredSourceShape(
value: unknown,
): ParsedMechanicAuthoredSourceResult {
const invalid = (
path: string,
message: string,
): ParsedMechanicAuthoredSourceResult => ({
ok: false,
errors: [{ code: 'invalid_source', path, message }],
});
if (!objectValue(value))
return invalid('', 'The authored source must be an object.');
if (
!hasOnlyKeys(value, [
'version',
'owner',
'template',
'variants',
'specialization',
])
)
return invalid('', 'The authored source contains an unknown field.');
if (value.version !== MECHANIC_AUTHORED_CONTRACT_VERSION)
return {
ok: false,
errors: [
{
code: 'unsupported_contract',
path: 'version',
message: 'The authored source has an unsupported contract version.',
},
],
};
if (!objectValue(value.owner) || typeof value.owner.id !== 'string')
return invalid('owner', 'The authored source owner is invalid.');
const ownerType = value.owner.type;
if (
typeof ownerType !== 'string' ||
![
'talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
].includes(ownerType)
)
return invalid('owner.type', 'The authored source owner type is invalid.');
if (ownerType === 'talent') {
if (!hasOnlyKeys(value.owner, ['type', 'id', 'scope', 'hasTierII']))
return invalid('owner', 'The Talent owner contains an unknown field.');
if (value.owner.scope !== 'standard' && value.owner.scope !== 'exotic')
return invalid('owner.scope', 'The Talent scope is invalid.');
if (
value.owner.hasTierII !== undefined &&
typeof value.owner.hasTierII !== 'boolean'
)
return invalid(
'owner.hasTierII',
'Talent Tier II presence must be boolean.',
);
} else if (!hasOnlyKeys(value.owner, ['type', 'id'])) {
return invalid(
'owner',
'The authored source owner contains an unknown field.',
);
}
if (typeof value.template !== 'string' || !Array.isArray(value.variants))
return invalid(
'template',
'The authored template or variants are invalid.',
);
for (const [variantIndex, variant] of value.variants.entries()) {
if (
!objectValue(variant) ||
!hasOnlyKeys(variant, ['key', 'materialized', 'slots']) ||
typeof variant.key !== 'string' ||
typeof variant.materialized !== 'string' ||
!Array.isArray(variant.slots)
)
return invalid(
`variants[${variantIndex}]`,
'The authored variant is invalid.',
);
for (const [slotIndex, slot] of variant.slots.entries()) {
if (
!objectValue(slot) ||
!hasOnlyKeys(slot, ['key', 'value', 'order', 'unit', 'attributeId']) ||
typeof slot.key !== 'string' ||
typeof slot.value !== 'string' ||
typeof slot.order !== 'number' ||
(slot.unit !== undefined && typeof slot.unit !== 'string') ||
(slot.attributeId !== undefined && typeof slot.attributeId !== 'string')
)
return invalid(
`variants[${variantIndex}].slots[${slotIndex}]`,
'The authored slot is invalid.',
);
}
}
if (value.specialization !== undefined) {
if (
!objectValue(value.specialization) ||
!hasOnlyKeys(value.specialization, [
'rankDescription',
'effectId',
'effectTemplate',
]) ||
typeof value.specialization.rankDescription !== 'string' ||
typeof value.specialization.effectId !== 'string' ||
typeof value.specialization.effectTemplate !== 'string'
)
return invalid(
'specialization',
'The Specialization authored inputs are invalid.',
);
}
return { ok: true, value: value as unknown as MechanicAuthoredSource };
}
export function materializeMechanicAuthoredDescription(
value: unknown,
): MechanicAuthoredDescriptionResult {
const parsed = parseMechanicAuthoredSourceShape(value);
return parsed.ok
? materializeKnownMechanicAuthoredDescription(parsed.value)
: parsed;
}
export function parseMechanicAuthoredSource(
value: unknown,
): ParsedMechanicAuthoredSourceResult {
const parsed = parseMechanicAuthoredSourceShape(value);
if (!parsed.ok) return parsed;
const materialized = materializeKnownMechanicAuthoredDescription(
parsed.value,
);
return materialized.ok ? parsed : materialized;
}
export function mechanicAuthoredFingerprintPayload(
source: MechanicAuthoredSource,
): MechanicAuthoredFingerprintPayload | null {
const materialized = materializeMechanicAuthoredDescription(source);
if (!materialized.ok) return null;
return {
kind: 'mechanic_authored_source',
version: MECHANIC_AUTHORED_CONTRACT_VERSION,
owner:
source.owner.type === 'talent'
? {
type: source.owner.type,
id: source.owner.id,
scope: source.owner.scope,
...(source.owner.hasTierII ? { hasTierII: true } : {}),
}
: { type: source.owner.type, id: source.owner.id },
template: source.template,
variants: materialized.variants.map((variant) => ({
key: variant.key,
materialized: variant.text,
slots: [...variant.slots].sort((left, right) => left.order - right.order),
})),
...(source.specialization ? { specialization: source.specialization } : {}),
};
}
@@ -0,0 +1,534 @@
import { describe, expect, it } from 'vitest';
import {
canonicalMechanicProgramJson,
classifyMechanicLifecycle,
classifyMechanicVariantLifecycle,
hashMechanicAuthoredSource,
hashMechanicProgram,
hashMechanicProgramVariants,
mechanicProgramSemanticPayload,
type MechanicLifecycleInput,
type MechanicModelRevision,
type MechanicProgramHashSource,
} from './mechanic-lifecycle.js';
import type { MechanicProgram } from './mechanic.js';
const program = (): MechanicProgram => ({
id: 'generated-program-id',
name: 'Sustained fire',
scenarioInputs: [
{
key: 'distance',
label: 'Distance',
kind: 'number',
readiness: 'requires_assumption',
description: 'Distance to target',
defaultValue: 10,
bestCaseValue: 5,
min: 0,
max: 100,
},
{
key: 'headshot',
label: 'Headshots',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{
key: 'stacks',
label: 'Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 3,
bestCaseValue: 3,
},
{
key: 'cooldown',
label: 'Cooldown',
scope: 'agent',
initial: 0,
min: 0,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'generated-clause-id',
label: 'On weapon hit',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [
{
kind: 'add',
pool: 'stacks',
value: { ref: { source: 'scenario', key: 'headshot' } },
},
],
outcomes: [
{
id: 'generated-outcome-id',
label: 'Weapon damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
attributeId: 'weaponDamage',
perStack: true,
},
],
},
],
});
const hashSource = (): MechanicProgramHashSource => ({
...program(),
simulationReadiness: 'captured_only',
sourceVariants: ['I', 'II'],
});
const revision = (
overrides: Partial<MechanicModelRevision> = {},
): MechanicModelRevision => ({
program: program(),
sourceFingerprint: 'a'.repeat(64),
programHash: '0'.repeat(64),
mechanicContractVersion: 1,
simulationReadiness: 'ready',
...overrides,
});
const lifecycleInput = (
selectedRevision: MechanicModelRevision | null,
publiclyRenderable = true,
) => ({
selectedRevision,
currentSourceFingerprint: 'a'.repeat(64),
...(publiclyRenderable
? { resolveAttributeLabel: (attributeId: string) => attributeId }
: {}),
});
describe('Mechanic Program lifecycle and semantic hashing', () => {
it('builds explicit canonical JSON and excludes generated IDs and provenance', () => {
const source = hashSource() as MechanicProgramHashSource &
Record<string, unknown>;
source.persistenceRowId = 'row-1';
source.updatedAt = '2026-08-27T00:00:00Z';
source.editorState = { activeClause: 'generated-clause-id' };
source.recipeProvenance = 'recipe-a';
const json = canonicalMechanicProgramJson(source);
expect(json).toContain('"kind":"mechanic_program"');
expect(json).toContain('"version":1');
expect(json).not.toContain('generated-program-id');
expect(json).not.toContain('generated-clause-id');
expect(json).not.toContain('generated-outcome-id');
expect(json).not.toContain('persistenceRowId');
expect(json).not.toContain('recipeProvenance');
});
it('does not change when keyed Scenario Inputs or pools are reordered', async () => {
const original = hashSource();
const reordered: MechanicProgramHashSource = {
...original,
scenarioInputs: [...(original.scenarioInputs ?? [])].reverse(),
pools: [...(original.pools ?? [])].reverse(),
};
await expect(hashMechanicProgram(original)).resolves.toBe(
await hashMechanicProgram(reordered),
);
});
it('changes when clause, mutation, or outcome execution/render order changes', async () => {
const original = program();
const reorderedClause: MechanicProgram = {
...original,
clauses: [
...original.clauses,
{
id: 'second-clause',
label: 'Later clause',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
},
],
};
const reorderedMutation: MechanicProgram = {
...original,
clauses: [
{
...original.clauses[0],
mutations: [{ kind: 'none' }, ...original.clauses[0].mutations],
},
],
};
const reorderedOutcome: MechanicProgram = {
...original,
clauses: [
{
...original.clauses[0],
outcomes: [
...(original.clauses[0].outcomes ?? []),
{
...(original.clauses[0].outcomes?.[0] ?? {
id: 'second-outcome',
label: 'Second',
phase: 'on_event',
kind: 'marker',
scope: 'self',
value: 0,
basis: 'flat',
}),
id: 'second-outcome',
label: 'Second',
},
],
},
],
};
const originalHash = await hashMechanicProgram(original);
await expect(hashMechanicProgram(reorderedClause)).resolves.not.toBe(
originalHash,
);
await expect(hashMechanicProgram(reorderedMutation)).resolves.not.toBe(
originalHash,
);
await expect(hashMechanicProgram(reorderedOutcome)).resolves.not.toBe(
originalHash,
);
});
it('rejects invalid pool scopes and reducer kinds from canonical hashes', async () => {
const invalidScope = program();
(invalidScope.pools![0] as { scope: string }).scope = 'squad';
await expect(hashMechanicProgram(invalidScope)).rejects.toThrow(
'unsupported scope',
);
const invalidReducer = program();
(invalidReducer.clauses[0] as { reducer: { kind: string } }).reducer = {
kind: 'unknown',
};
await expect(hashMechanicProgram(invalidReducer)).rejects.toThrow(
'unsupported kind',
);
});
it('keeps generated identities out of the semantic hash', async () => {
const changedIds: MechanicProgram = {
...program(),
id: 'another-program-id',
clauses: [
{
...program().clauses[0],
id: 'another-clause-id',
outcomes: [
{
...(program().clauses[0].outcomes?.[0] as NonNullable<
MechanicProgram['clauses'][number]['outcomes']
>[number]),
id: 'another-outcome-id',
},
],
},
],
};
await expect(hashMechanicProgram(program())).resolves.toBe(
await hashMechanicProgram(changedIds),
);
});
it('classifies selected models with fail-closed lifecycle boundaries', async () => {
await expect(
classifyMechanicLifecycle(lifecycleInput(null)),
).resolves.toEqual({
status: 'absent',
reason: 'no_selected_revision',
});
await expect(
classifyMechanicLifecycle(
lifecycleInput(
revision({
program: { ...program(), clauses: [] },
simulationReadiness: 'captured_only',
}),
),
),
).resolves.toEqual({ status: 'absent', reason: 'empty_captured_program' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ program: { ...program(), name: '' } })),
),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
await expect(
classifyMechanicLifecycle({
selectedRevision: revision({ sourceFingerprint: 'b'.repeat(64) }),
currentSourceFingerprint: 'c'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
}),
).resolves.toEqual({ status: 'stale', reason: 'fingerprint_mismatch' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ mechanicContractVersion: 999 })),
),
).resolves.toEqual({ status: 'stale', reason: 'unsupported_contract' });
await expect(
classifyMechanicLifecycle(lifecycleInput(revision(), false)),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
await expect(
classifyMechanicLifecycle({
selectedRevision: revision(),
currentSourceFingerprint: 'a'.repeat(64),
} as MechanicLifecycleInput),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
});
it('rejects non-SHA fingerprints and publicly unrenderable program fields', async () => {
await expect(
classifyMechanicLifecycle({
selectedRevision: revision({ sourceFingerprint: 'same' }),
currentSourceFingerprint: 'same',
resolveAttributeLabel: (attributeId) => attributeId,
}),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
const invalidPhase = program();
(invalidPhase.clauses[0].outcomes![0] as { phase: string }).phase =
'sometimes';
const selectedRevision = revision({ program: invalidPhase });
selectedRevision.programHash = await hashMechanicProgram({
...invalidPhase,
simulationReadiness: selectedRevision.simulationReadiness,
});
await expect(
classifyMechanicLifecycle(lifecycleInput(selectedRevision)),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
});
it.each(['ready', 'requires_assumptions', 'captured_only'] as const)(
'classifies a matching non-empty %s model as current',
async (simulationReadiness) => {
const selectedRevision = revision({ simulationReadiness });
selectedRevision.programHash = await hashMechanicProgram({
...selectedRevision.program!,
simulationReadiness,
});
await expect(
classifyMechanicLifecycle(lifecycleInput(selectedRevision)),
).resolves.toEqual({
status: 'current',
fingerprint: 'a'.repeat(64),
programHash: selectedRevision.programHash,
simulationReadiness,
});
},
);
it('fails closed when the selected program hash is missing or does not match', async () => {
await expect(
classifyMechanicLifecycle(
lifecycleInput({
...revision(),
programHash: undefined,
} as unknown as MechanicModelRevision),
),
).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ programHash: '0'.repeat(64) })),
),
).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' });
});
it('distinguishes an explicit zero weight from an omitted weight', async () => {
const implicit = program();
const zeroWeight: MechanicProgram = {
...implicit,
clauses: [{ ...implicit.clauses[0], weight: 0 }],
};
await expect(hashMechanicProgram(zeroWeight)).resolves.not.toBe(
await hashMechanicProgram(implicit),
);
});
it('normalizes omitted runtime defaults to their explicit equivalents', async () => {
const implicit = program();
const explicit: MechanicProgram = {
...implicit,
scenarioInputs: implicit.scenarioInputs?.map((input) => ({
...input,
readiness: input.readiness ?? 'ready',
})),
clauses: implicit.clauses.map((clause) => ({
...clause,
mutations: clause.mutations.map((mutation) =>
mutation.kind === 'transfer'
? {
...mutation,
value: mutation.value ?? 'all',
clearSource: mutation.clearSource ?? true,
}
: mutation,
),
outcomes: clause.outcomes?.map((outcome) => ({
...outcome,
delivery: outcome.delivery ?? 'instant',
combatTiming: outcome.combatTiming ?? 'current',
perStack: outcome.perStack ?? false,
})),
})),
};
await expect(hashMechanicProgram(implicit)).resolves.toBe(
await hashMechanicProgram(explicit),
);
});
it('hashes ordered program variants as one revision graph', async () => {
const tierOne = program();
const tierTwo = program();
tierTwo.clauses[0].outcomes![0].value = 10;
const forward = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants: [
{ key: 'I', label: 'Tier I', program: tierOne },
{ key: 'II', label: 'Tier II', program: tierTwo },
],
});
const sameSemantics = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants: [
{ key: 'I', label: 'Renamed display label', program: tierOne },
{ key: 'II', label: 'Tier II', program: tierTwo },
],
});
const reversed = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants: [
{ key: 'II', label: 'Tier II', program: tierTwo },
{ key: 'I', label: 'Tier I', program: tierOne },
],
});
expect(forward).toBe(sameSemantics);
expect(reversed).not.toBe(forward);
});
it('classifies the complete selected variant graph and fails closed when one variant is invalid', async () => {
const variants = [
{ key: 'I', label: 'Tier I', program: program() },
{ key: 'II', label: 'Tier II', program: program() },
];
variants[1].program.clauses[0].outcomes![0].value = 10;
const programHash = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants,
});
await expect(
classifyMechanicVariantLifecycle({
currentSourceFingerprint: 'a'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
selectedRevision: {
variants,
sourceFingerprint: 'a'.repeat(64),
programHash,
mechanicContractVersion: 1,
simulationReadiness: 'ready',
},
}),
).resolves.toMatchObject({ status: 'current', programHash });
variants[1].program.pools = [
{ key: 'bad', label: 'Bad', scope: 'party' as 'agent', initial: 0 },
];
await expect(
classifyMechanicVariantLifecycle({
currentSourceFingerprint: 'a'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
selectedRevision: {
variants,
sourceFingerprint: 'a'.repeat(64),
programHash,
mechanicContractVersion: 1,
simulationReadiness: 'ready',
},
}),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
});
it('hashes a valid authored source and rejects unresolved source values', async () => {
const source = {
version: 1 as const,
owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' },
template: 'Gain {1}%.',
variants: [
{
key: 'base',
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10', order: 0 }],
},
],
};
await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({
ok: true,
fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/),
payload: { kind: 'mechanic_authored_source', version: 1 },
});
source.variants[0].slots = [];
await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('returns a stable fixed SHA-256 vector as lowercase hexadecimal', async () => {
const hash = await hashMechanicProgram({
id: 'generated-id',
name: 'x',
clauses: [
{
id: 'generated-clause-id',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
},
],
});
expect(hash).toBe(
'4ba3a6b9ddc7831375779c865928a715bc3dbd2b8aac77b889fc7fcc672d8339',
);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it('exposes canonical payload fields in stable key order', () => {
const payload = mechanicProgramSemanticPayload(hashSource());
expect(Object.keys(payload)).toEqual([
'kind',
'version',
'name',
'scenarioInputs',
'pools',
'clauses',
'simulationReadiness',
'sourceVariants',
]);
});
});
+845
View File
@@ -0,0 +1,845 @@
import {
mechanicAuthoredFingerprintPayload,
materializeMechanicAuthoredDescription,
type MechanicAuthoredDescriptionError,
type MechanicAuthoredFingerprintPayload,
type MechanicAuthoredSource,
} from './mechanic-authored-description.js';
import {
renderPublicMechanicPrograms,
type MechanicAttributeLabelResolver,
type MechanicPublicProgramVariant,
} from './mechanic-public-renderer.js';
import {
validateMechanicProgram,
type ClauseMutation,
type EntitySelector,
type MechanicClause,
type MechanicOutcome,
type MechanicOutcomeTargeting,
type MechanicProgram,
type NumericExpression,
type PoolDefinition,
type Predicate,
type Reducer,
type ScenarioInputDefinition,
type Scalar,
type ValueReference,
} from './mechanic.js';
/** The version of the canonical Mechanic Program hash contract. */
export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const;
export const MECHANIC_PROGRAM_HASH_KIND = 'mechanic_program' as const;
export const MECHANIC_PROGRAM_VARIANTS_HASH_KIND =
'mechanic_program_variants' as const;
export type MechanicLifecycle = 'absent' | 'stale' | 'current';
export type MechanicAuthoredFingerprintResult =
| {
ok: true;
fingerprint: string;
payload: MechanicAuthoredFingerprintPayload;
}
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type MechanicSimulationReadiness =
'ready' | 'requires_assumptions' | 'captured_only';
export type CanonicalJsonValue =
| null
| boolean
| number
| string
| CanonicalJsonValue[]
| { [key: string]: CanonicalJsonValue };
export type CanonicalMechanicProgram = {
kind: typeof MECHANIC_PROGRAM_HASH_KIND;
version: typeof MECHANIC_PROGRAM_HASH_VERSION;
name: string;
simulationReadiness?: MechanicSimulationReadiness;
sourceVariants?: CanonicalJsonValue;
scenarioInputs: Array<{ [key: string]: CanonicalJsonValue }>;
pools: Array<{ [key: string]: CanonicalJsonValue }>;
clauses: Array<{ [key: string]: CanonicalJsonValue }>;
};
/**
* A program plus source metadata which belongs to the model contract rather
* than to the executable graph. Metadata is optional because the current
* runtime MechanicProgram type predates model revisions.
*/
export type MechanicProgramHashSource = MechanicProgram & {
simulationReadiness?: MechanicSimulationReadiness;
sourceVariants?: CanonicalJsonValue;
};
export type MechanicProgramHashInput = MechanicProgramHashSource;
export type MechanicProgramVariantsHashInput = {
simulationReadiness: MechanicSimulationReadiness;
variants: MechanicPublicProgramVariant[];
};
export type MechanicModelRevision = {
program: MechanicProgram | null;
sourceFingerprint: string;
programHash: string;
sourceVariants?: CanonicalJsonValue;
mechanicContractVersion: number;
simulationReadiness: MechanicSimulationReadiness;
};
export type MechanicLifecycleInput = {
selectedRevision: MechanicModelRevision | null;
currentSourceFingerprint: string;
resolveAttributeLabel?: MechanicAttributeLabelResolver;
supportedMechanicContractVersion?: number;
};
export type MechanicModelVariantRevision = Omit<
MechanicModelRevision,
'program' | 'sourceVariants'
> & {
variants: MechanicPublicProgramVariant[];
};
export type MechanicVariantLifecycleInput = Omit<
MechanicLifecycleInput,
'selectedRevision'
> & {
selectedRevision: MechanicModelVariantRevision | null;
};
export type MechanicLifecycleReason =
| 'no_selected_revision'
| 'empty_program'
| 'empty_captured_program'
| 'invalid_program'
| 'unrenderable_program'
| 'fingerprint_mismatch'
| 'unsupported_contract'
| 'invalid_program_hash'
| 'hash_unavailable';
export type MechanicLifecycleResult =
| {
status: 'absent';
reason:
| 'no_selected_revision'
| 'empty_program'
| 'empty_captured_program'
| 'invalid_program'
| 'unrenderable_program'
| 'invalid_program_hash'
| 'hash_unavailable';
errors?: string[];
}
| {
status: 'stale';
reason: 'fingerprint_mismatch' | 'unsupported_contract';
}
| {
status: 'current';
fingerprint: string;
programHash: string;
simulationReadiness: MechanicSimulationReadiness;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function canonicalizeValue(value: unknown): CanonicalJsonValue {
if (value === null) return null;
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new TypeError(
'Mechanic Program hash values must be finite numbers.',
);
}
return value;
}
if (Array.isArray(value)) return value.map(canonicalizeValue);
if (isRecord(value)) {
const result: { [key: string]: CanonicalJsonValue } = {};
for (const key of Object.keys(value).sort()) {
const child = value[key];
if (child !== undefined) result[key] = canonicalizeValue(child);
}
return result;
}
throw new TypeError('Mechanic Program hash values must be JSON values.');
}
function canonicalObject(fields: Record<string, unknown>): {
[key: string]: CanonicalJsonValue;
} {
return canonicalizeValue(fields) as { [key: string]: CanonicalJsonValue };
}
function stableJson(value: CanonicalJsonValue): string {
return JSON.stringify(value);
}
function stableSemanticSort<T extends CanonicalJsonValue>(values: T[]): T[] {
return values
.map((value, index) => ({ value, index, key: stableJson(value) }))
.sort((left, right) => {
if (left.key < right.key) return -1;
if (left.key > right.key) return 1;
return left.index - right.index;
})
.map(({ value }) => value);
}
function canonicalScenarioInput(input: ScenarioInputDefinition): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
key: input.key,
label: input.label,
kind: input.kind,
readiness: input.readiness ?? 'ready',
description: input.description,
defaultValue: input.defaultValue,
bestCaseValue: input.bestCaseValue,
min: input.min,
max: input.max,
});
}
function canonicalPool(pool: PoolDefinition): {
[key: string]: CanonicalJsonValue;
} {
if (pool.scope !== 'agent' && pool.scope !== 'target') {
throw new TypeError('Mechanic pools have an unsupported scope.');
}
return canonicalObject({
key: pool.key,
label: pool.label,
scope: pool.scope,
initial: pool.initial,
min: pool.min,
max: pool.max,
bestCaseValue: pool.bestCaseValue,
expiresAfterSeconds: pool.expiresAfterSeconds,
});
}
function canonicalEntitySelector(selector: EntitySelector): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
kind: selector.kind,
path: 'path' in selector ? selector.path : undefined,
});
}
function canonicalReference(reference: ValueReference): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
source: reference.source,
path: 'path' in reference ? reference.path : undefined,
timing: 'timing' in reference ? reference.timing : undefined,
key: 'key' in reference ? reference.key : undefined,
attributeId: 'attributeId' in reference ? reference.attributeId : undefined,
pool: 'pool' in reference ? reference.pool : undefined,
entity:
'entity' in reference && reference.entity
? canonicalEntitySelector(reference.entity)
: undefined,
});
}
function canonicalExpression(
expression: NumericExpression,
): CanonicalJsonValue {
if (typeof expression === 'number') return canonicalizeValue(expression);
if (!isRecord(expression)) {
throw new TypeError('Numeric expressions must be typed JSON values.');
}
if ('ref' in expression) {
if (!isRecord(expression.ref))
throw new TypeError('Numeric expression references must be objects.');
return canonicalObject({
ref: canonicalReference(expression.ref as ValueReference),
});
}
const op = expression.op;
switch (op) {
case 'floor':
return canonicalObject({
op,
value: canonicalExpression(expression.value as NumericExpression),
});
case 'clamp':
return canonicalObject({
op,
value: canonicalExpression(expression.value as NumericExpression),
min: canonicalExpression(expression.min as NumericExpression),
max: canonicalExpression(expression.max as NumericExpression),
});
case 'add':
case 'subtract':
case 'multiply':
case 'divide':
case 'min':
case 'max':
return canonicalObject({
op,
left: canonicalExpression(expression.left as NumericExpression),
right: canonicalExpression(expression.right as NumericExpression),
});
default:
throw new TypeError('Numeric expressions have an unsupported operator.');
}
}
function canonicalOperand(value: ValueReference | Scalar): CanonicalJsonValue {
return isRecord(value) && 'source' in value
? canonicalReference(value as ValueReference)
: canonicalizeValue(value);
}
function canonicalPredicate(predicate: Predicate): CanonicalJsonValue {
if (!isRecord(predicate)) {
throw new TypeError('Predicates must be typed JSON values.');
}
switch (predicate.kind) {
case 'true':
return { kind: 'true' };
case 'exists':
return canonicalObject({
kind: 'exists',
value: canonicalReference(predicate.value as ValueReference),
});
case 'compare':
return canonicalObject({
kind: 'compare',
left: canonicalOperand(predicate.left as ValueReference | Scalar),
operator: predicate.operator,
right: canonicalOperand(predicate.right as ValueReference | Scalar),
});
case 'all':
case 'any':
return canonicalObject({
kind: predicate.kind,
// Logical member order is retained because deterministic rendering
// presents the members in this order.
predicates: (predicate.predicates as Predicate[]).map(
canonicalPredicate,
),
});
case 'not':
return canonicalObject({
kind: 'not',
predicate: canonicalPredicate(predicate.predicate as Predicate),
});
default:
throw new TypeError('Predicates have an unsupported kind.');
}
}
function canonicalReducer(reducer: Reducer): CanonicalJsonValue {
if (!isRecord(reducer)) {
throw new TypeError('Reducers must be typed JSON values.');
}
const groupBy =
'groupBy' in reducer && reducer.groupBy
? canonicalReference(reducer.groupBy as ValueReference)
: undefined;
switch (reducer.kind) {
case 'none':
return { kind: 'none' };
case 'count':
return canonicalObject({
kind: reducer.kind,
threshold: reducer.threshold,
reset: reducer.reset ?? 'zero',
groupBy,
});
case 'sliding_count':
return canonicalObject({
kind: reducer.kind,
threshold: reducer.threshold,
reset: reducer.reset ?? 'zero',
windowSeconds: reducer.windowSeconds,
groupBy,
});
case 'modulo':
return canonicalObject({ kind: 'modulo', every: reducer.every, groupBy });
case 'consecutive_count':
return canonicalObject({
kind: 'consecutive_count',
threshold: reducer.threshold,
groupBy,
});
case 'distinct_count':
return canonicalObject({
kind: 'distinct_count',
threshold: reducer.threshold,
windowSeconds: reducer.windowSeconds,
distinctBy: canonicalReference(reducer.distinctBy),
});
case 'duration':
return canonicalObject({
kind: 'duration',
seconds: reducer.seconds,
repeat: reducer.repeat ?? false,
groupBy,
});
case 'periodic':
return canonicalObject({
kind: 'periodic',
intervalSeconds: canonicalExpression(reducer.intervalSeconds),
immediate: reducer.immediate ?? false,
groupBy,
});
case 'derived_scalar':
return canonicalObject({
kind: 'derived_scalar',
value: canonicalExpression(reducer.value),
});
default:
throw new TypeError('Reducers have an unsupported kind.');
}
}
function canonicalMutation(mutation: ClauseMutation): CanonicalJsonValue {
if (!isRecord(mutation)) {
throw new TypeError('Mutations must be typed JSON values.');
}
const entity =
'entity' in mutation && mutation.entity
? canonicalEntitySelector(mutation.entity as EntitySelector)
: undefined;
switch (mutation.kind) {
case 'none':
return { kind: 'none' };
case 'add':
case 'set':
case 'consume':
case 'derive':
return canonicalObject({
kind: mutation.kind,
pool: mutation.pool,
entity,
value: canonicalExpression(mutation.value),
});
case 'clear':
return canonicalObject({ kind: 'clear', pool: mutation.pool, entity });
case 'transfer':
return canonicalObject({
kind: 'transfer',
pool: mutation.pool,
from: canonicalEntitySelector(mutation.from),
to: canonicalEntitySelector(mutation.to),
value:
mutation.value === undefined
? 'all'
: mutation.value === 'all'
? 'all'
: canonicalExpression(mutation.value),
clearSource: mutation.clearSource ?? true,
});
case 'extend_duration':
return canonicalObject({
kind: 'extend_duration',
target: mutation.target,
seconds: canonicalExpression(mutation.seconds),
});
case 'start_cooldown_on_max':
return canonicalObject({
kind: 'start_cooldown_on_max',
stackPool: mutation.stackPool,
cooldownPool: mutation.cooldownPool,
});
default:
throw new TypeError('Mutations have an unsupported kind.');
}
}
function canonicalTargeting(
targeting: MechanicOutcomeTargeting,
): CanonicalJsonValue {
return canonicalObject({
kind: targeting.kind,
population: 'population' in targeting ? targeting.population : undefined,
center: 'center' in targeting ? targeting.center : undefined,
validTargets:
'validTargets' in targeting ? targeting.validTargets : undefined,
radiusMeters: targeting.radiusMeters,
});
}
function canonicalOutcome(outcome: MechanicOutcome): {
[key: string]: CanonicalJsonValue;
} {
// Outcome IDs are generated identities. Every other field is either typed
// behavior or text consumed by deterministic rendering.
return canonicalObject({
label: outcome.label,
phase: outcome.phase,
kind: outcome.kind,
scope: outcome.scope,
value: canonicalExpression(outcome.value),
basis: outcome.basis,
resource: outcome.resource,
delivery: outcome.delivery ?? 'instant',
durationSeconds: outcome.durationSeconds,
combatTiming: outcome.combatTiming ?? 'current',
attributeId: outcome.attributeId,
statePool: outcome.statePool,
perStack: outcome.perStack ?? false,
minStacks: outcome.minStacks,
maxStacks: outcome.maxStacks,
targeting: outcome.targeting
? canonicalTargeting(outcome.targeting)
: undefined,
probabilityPercent: outcome.probabilityPercent,
});
}
function canonicalClause(clause: MechanicClause): {
[key: string]: CanonicalJsonValue;
} {
// Clause IDs and recipe keys are generated/persistence provenance. The
// remaining fields preserve execution and deterministic rendering order.
return canonicalObject({
label: clause.label,
when: clause.when,
if: clause.if ? canonicalPredicate(clause.if) : undefined,
reducer: canonicalReducer(clause.reducer),
cooldownSeconds: clause.cooldownSeconds,
weight:
clause.weight !== undefined
? canonicalExpression(clause.weight)
: undefined,
procChancePercent: clause.procChancePercent,
requiresAssignedAbilityActive: clause.requiresAssignedAbilityActive,
mutations: clause.mutations.map(canonicalMutation),
outcomes: (clause.outcomes ?? []).map(canonicalOutcome),
});
}
/**
* Builds the versioned semantic payload used by the Mechanic Program hash.
*
* Keyed Scenario Inputs and pools are sets and therefore sorted by their
* canonical semantics. Clauses, mutations, predicate members, and outcomes
* stay in source order because order affects execution or rendered text.
*/
export function mechanicProgramSemanticPayload(
input: MechanicProgramHashInput,
): CanonicalMechanicProgram {
const scenarioInputs = stableSemanticSort(
(input.scenarioInputs ?? []).map(canonicalScenarioInput),
);
const pools = stableSemanticSort((input.pools ?? []).map(canonicalPool));
const payload: CanonicalMechanicProgram = {
kind: MECHANIC_PROGRAM_HASH_KIND,
version: MECHANIC_PROGRAM_HASH_VERSION,
name: input.name,
scenarioInputs,
pools,
clauses: input.clauses.map(canonicalClause),
};
if (input.simulationReadiness !== undefined) {
payload.simulationReadiness = input.simulationReadiness;
}
if (input.sourceVariants !== undefined)
payload.sourceVariants = canonicalizeValue(input.sourceVariants);
return payload;
}
/** Returns canonical JSON bytes as a string, without a hidden domain separator. */
export function canonicalMechanicProgramJson(
input: MechanicProgramHashInput,
): string {
return stableJson(canonicalizeValue(mechanicProgramSemanticPayload(input)));
}
async function sha256Utf8(value: string): Promise<string> {
const crypto = globalThis.crypto;
if (!crypto?.subtle) {
throw new Error('Web Crypto SHA-256 is unavailable.');
}
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
/** Computes the source fingerprint only after the authored contract validates. */
export async function hashMechanicAuthoredSource(
source: MechanicAuthoredSource,
): Promise<MechanicAuthoredFingerprintResult> {
const validation = materializeMechanicAuthoredDescription(source);
if (!validation.ok) return validation;
const payload = mechanicAuthoredFingerprintPayload(source);
if (!payload) {
return {
ok: false,
errors: [
{
code: 'invalid_source',
path: '',
message: 'The validated authored source could not be serialized.',
},
],
};
}
return {
ok: true,
fingerprint: await sha256Utf8(stableJson(canonicalizeValue(payload))),
payload,
};
}
/** Computes lowercase SHA-256 over UTF-8 canonical Mechanic Program JSON. */
export async function hashMechanicProgram(
input: MechanicProgramHashInput,
): Promise<string> {
return sha256Utf8(canonicalMechanicProgramJson(input));
}
/**
* Hashes one owner revision across its explicit ordered program variants.
* Variant labels are presentation metadata; keys, order, and program semantics
* are revision data.
*/
export async function hashMechanicProgramVariants(
input: MechanicProgramVariantsHashInput,
): Promise<string> {
return sha256Utf8(
stableJson(
canonicalizeValue({
kind: MECHANIC_PROGRAM_VARIANTS_HASH_KIND,
version: MECHANIC_PROGRAM_HASH_VERSION,
simulationReadiness: input.simulationReadiness,
variants: input.variants.map((variant) => ({
key: variant.key,
program: mechanicProgramSemanticPayload(variant.program),
})),
}),
),
);
}
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
function lifecycleAbsent(
reason: Extract<MechanicLifecycleResult, { status: 'absent' }>['reason'],
errors?: string[],
): MechanicLifecycleResult {
return errors?.length
? { status: 'absent', reason, errors }
: { status: 'absent', reason };
}
/**
* Classifies one selected model without ever treating readiness as currency.
* Validation and renderability are checked before currency, so malformed
* records fail closed as absent rather than leaking a stale or current model.
*/
export async function classifyMechanicLifecycle(
input: MechanicLifecycleInput,
): Promise<MechanicLifecycleResult> {
const revision = input.selectedRevision;
if (!revision || !revision.program) {
return lifecycleAbsent('no_selected_revision');
}
const program = revision.program;
let validation;
try {
validation = validateMechanicProgram(program);
} catch {
return lifecycleAbsent('invalid_program', [
'The selected mechanic could not be validated safely.',
]);
}
if (!validation.ok)
return lifecycleAbsent('invalid_program', validation.errors);
if (program.clauses.length === 0) {
return lifecycleAbsent(
revision.simulationReadiness === 'captured_only'
? 'empty_captured_program'
: 'empty_program',
);
}
const rendering = renderPublicMechanicPrograms(
[{ key: 'base', label: 'Base', program }],
{ 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 hashMechanicProgram({
...program,
simulationReadiness: revision.simulationReadiness,
...(revision.sourceVariants !== undefined
? { sourceVariants: revision.sourceVariants }
: {}),
});
} catch {
return lifecycleAbsent('hash_unavailable');
}
if (programHash !== revision.programHash) {
return lifecycleAbsent('invalid_program_hash');
}
return {
status: 'current',
fingerprint: input.currentSourceFingerprint,
programHash,
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,
};
}
@@ -0,0 +1,714 @@
import { describe, expect, it } from 'vitest';
import {
renderPublicMechanicPrograms,
type MechanicPublicProgramVariant,
} from './mechanic-public-renderer.js';
import { MECHANIC_EVENT_KINDS } from './mechanic.js';
import type { MechanicProgram } from './mechanic.js';
const baseProgram = (
overrides: Partial<MechanicProgram> = {},
): MechanicProgram => ({
id: 'public-renderer-test',
name: 'Public renderer test',
clauses: [
{
id: 'hit',
label: 'Hit target',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'damage',
label: 'Headshot damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
value: 12.5,
basis: 'flat',
},
],
},
],
...overrides,
});
const variants = (
...programs: Array<[string, MechanicProgram]>
): MechanicPublicProgramVariant[] =>
programs.map(([key, program]) => ({ key, program }));
const render = (
program: MechanicProgram,
options?: {
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
},
) => renderPublicMechanicPrograms([{ key: 'base', program }], options);
describe('public mechanic renderer', () => {
it('renders deterministic, readable prose for ordered variants', () => {
const input = variants(
['I', baseProgram()],
['II', baseProgram({ id: 'public-renderer-test-ii' })],
);
expect(renderPublicMechanicPrograms(input)).toEqual({
ok: true,
variants: [
{
key: 'I',
label: 'Tier I',
text: 'Tier I\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.',
},
{
key: 'II',
label: 'Tier II',
text: 'Tier II\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.',
},
],
});
});
it('preserves the supplied order of variants', () => {
const result = renderPublicMechanicPrograms(
variants(
['II', baseProgram({ id: 'tier-ii' })],
['I', baseProgram({ id: 'tier-i' })],
),
);
expect(result.ok && result.variants.map((variant) => variant.key)).toEqual([
'II',
'I',
]);
});
it('renders every event registry label without exposing the script format', () => {
const program = baseProgram({
clauses: MECHANIC_EVENT_KINDS.map((when) => ({
id: when,
when,
label: when,
reducer: { kind: 'none' as const },
mutations: [{ kind: 'none' as const }],
})),
});
const result = render(program);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.variants[0].text).not.toContain('WHEN ');
expect(result.variants[0].text).not.toContain('EVENT.');
expect(result.variants[0].text).not.toContain('{');
for (const clause of program.clauses) {
expect(result.variants[0].text).toContain(
(
{
shot_fired: 'Shot fired',
weapon_hit: 'Weapon hit',
skill_deployed: 'Skill deployed',
signature_ability_deployed: 'Signature Ability deployed',
skill_hit: 'Skill hit',
skill_multishot_triggered: 'Skill Multi-Shot triggered',
agent_hit: 'Agent hit',
health_damage_taken: 'Health damage taken',
damage_dealt: 'Damage dealt',
enemy_killed: 'Enemy killed',
assist: 'Assist',
ally_downed: 'Ally downed',
agent_downed: 'Agent downed',
ally_rescued: 'Ally rescued',
grenade_thrown: 'Grenade thrown',
medical_kit_used: 'Medical Kit used',
healing_applied: 'Healing applied',
extra_health_granted: 'Extra Health granted',
extra_health_changed: 'Extra Health changed',
movement_snapshot: 'Movement snapshot',
proximity_snapshot: 'Proximity snapshot',
firing_started: 'Firing started',
firing_stopped: 'Firing stopped',
reload_started: 'Reload started',
reload_completed: 'Reload completed',
weapon_swapped: 'Weapon swapped',
tick: 'Simulation tick',
} as Record<string, string>
)[clause.when],
);
}
});
it('covers conditions, references, reducers, state, mutations, and outcome metadata', () => {
const program = baseProgram({
scenarioInputs: [
{
key: 'stacks-to-add',
label: 'Stacks to add',
kind: 'number',
defaultValue: 1.25,
bestCaseValue: 2.5,
min: 0,
max: 3,
},
],
pools: [
{
key: 'stacks',
label: 'Momentum stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 4,
bestCaseValue: 3,
expiresAfterSeconds: 8.75,
},
{
key: 'target-mark',
label: 'Target mark',
scope: 'target',
initial: 0,
min: 0,
max: 1,
},
{
key: 'cooldown',
label: 'Momentum cooldown',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 12,
},
],
clauses: [
{
id: 'advanced',
label: 'Build momentum',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 15.5,
},
{
kind: 'not',
predicate: {
kind: 'exists',
value: { source: 'event', path: 'values.critical' },
},
},
{
kind: 'compare',
left: { source: 'attribute', attributeId: 'firepower' },
operator: 'gte',
right: { source: 'scenario', key: 'stacks-to-add' },
},
],
},
reducer: {
kind: 'count',
threshold: 2,
reset: 'subtract',
groupBy: { source: 'event', path: 'targetId' },
},
cooldownSeconds: 4.5,
weight: {
op: 'clamp',
value: { ref: { source: 'pool', pool: 'stacks' } },
min: 0,
max: 1,
},
procChancePercent: 37.5,
requiresAssignedAbilityActive: true,
mutations: [
{
kind: 'add',
pool: 'stacks',
value: { ref: { source: 'scenario', key: 'stacks-to-add' } },
},
{
kind: 'set',
pool: 'target-mark',
entity: { kind: 'event_target' },
value: 1,
},
{ kind: 'consume', pool: 'stacks', value: 0.5 },
{
kind: 'derive',
pool: 'stacks',
value: { op: 'floor', value: { ref: { source: 'reducer' } } },
},
{
kind: 'transfer',
pool: 'target-mark',
from: { kind: 'event_target' },
to: { kind: 'agent' },
value: 'all',
clearSource: false,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 2.25,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'stacks',
cooldownPool: 'cooldown',
},
],
outcomes: [
{
id: 'buff',
label: 'Momentum buff',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
value: {
op: 'multiply',
left: 2,
right: {
ref: { source: 'attribute', attributeId: 'firepower' },
},
},
basis: 'flat',
attributeId: 'firepower',
statePool: 'stacks',
perStack: true,
minStacks: 1,
maxStacks: 4,
combatTiming: 'current',
probabilityPercent: 88.25,
},
{
id: 'restore',
label: 'Regeneration',
phase: 'after_event',
kind: 'restoration',
scope: 'self_and_allies',
value: 10,
basis: 'max_health_percent',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3.5,
},
{
id: 'cost',
label: 'Ammo cost',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
value: 2,
basis: 'flat',
resource: 'ammo',
delivery: 'instant',
},
{
id: 'reload',
label: 'Reload inactive weapon',
phase: 'on_exit',
kind: 'weapon_reload',
scope: 'inactive_weapon',
value: { op: 'add', left: 1.125, right: 0.25 },
basis: 'flat',
},
{
id: 'area',
label: 'Area strike',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
value: 15,
basis: 'weapon_base_percent',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 4,
},
},
{
id: 'marker',
label: 'Marker',
phase: 'on_enter',
kind: 'marker',
scope: 'assigned_ability',
value: 1,
basis: 'flat',
},
],
},
],
});
const result = render(program, {
resolveAttributeLabel: (id) =>
id === 'firepower' ? 'Firepower' : undefined,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.variants[0].text).toMatchInlineSnapshot(`
"Base
The “Stacks to add” scenario input is a number, from 0, to 3, default 1.25, maximum 2.5.
State “Momentum cooldown” tracks the Agent, starting at 0, from 0 to 1. It expires 12 seconds after it is written. State “Momentum stacks” tracks the Agent, starting at 0, from 0 to 4. The maximum scenario value is 3. It expires 8.75 seconds after it is written. State “Target mark” tracks each target, starting at 0, from 0 to 1.
Build momentum: When Weapon hit occurs, provided that all of these conditions are met: (the event’s Target distance is at most 15.5); (it is not the case that the event’s Critical hit is present); (the Firepower Attribute value is at least the “Stacks to add” scenario input), after 2 matching events grouped by the event’s Target ID while retaining any matches beyond the threshold, only while the assigned ability is active, with a weight of the value of the “Momentum stacks” state for the Agent limited from 0 to 1, with a 37.5% chance to trigger, then unavailable for 4.5 seconds. Add the “Stacks to add” scenario input to the “Momentum stacks” state for the Agent. Set 1 as the “Target mark” state for the event target. Consume 0.5 from the “Momentum stacks” state for the Agent. Derive the whole-number value of the reduced value as the “Momentum stacks” state for the Agent. Transfer all of the “Target mark” state from the event target to the Agent, without clearing the source. Extend the assigned ability by 2.25 seconds. Start the “Momentum cooldown” cooldown when the “Momentum stacks” state reaches its maximum. Momentum buff: while the state is active, modify “Firepower” by the product of 2 and the Firepower Attribute value for the Agent using the “Momentum stacks” state per stack when stacks are from 1 through 4 immediately with a 88.25% chance. Regeneration: after this event, restore a total of 10% of maximum Health to Health for the Agent and allies continuously over 3.5 seconds. Ammo cost: when this event occurs, spend 2 of Ammo from the Agent immediately. Reload inactive weapon: when the state ends, reload the inactive weapon after the sum of 1.125 and 0.25 seconds. Area strike: when this event occurs, deal damage equal to 15% of weapon base damage to the target, affecting valid enemies within 4 meters of the event target immediately. Marker: when the state begins, record a marker of 1 for the assigned ability immediately."
`);
});
it('renders a custom variant label and resolves all attribute references', () => {
const result = renderPublicMechanicPrograms(
[
{
key: 'rank-1',
label: 'Rank 1',
program: baseProgram({
clauses: [
{
...baseProgram().clauses[0],
if: {
kind: 'exists',
value: { source: 'attribute', attributeId: 'armor' },
},
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
kind: 'attribute_modifier',
attributeId: 'armor',
scope: 'self',
},
],
},
],
}),
},
],
{ resolveAttributeLabel: (id) => (id === 'armor' ? 'Armor' : null) },
);
expect(result).toMatchObject({
ok: true,
variants: [{ key: 'rank-1', label: 'Rank 1' }],
});
if (result.ok) expect(result.variants[0].text).toContain('“Armor”');
});
it('fails closed for an invalid program before attempting to render it', () => {
const invalid = baseProgram({
clauses: [{ ...baseProgram().clauses[0], when: 'not-an-event' as never }],
});
const result = render(invalid);
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_program', path: 'variants[base]' }],
});
});
it('validates true predicates instead of dropping their structure', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
if: { kind: 'true', unexpected: true } as never,
},
],
});
const result = render(program);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].if.unexpected',
},
],
});
});
it('rejects overlapping or non-array input shapes', () => {
const result = renderPublicMechanicPrograms({
variants: [{ key: 'base', program: baseProgram() }],
} as never);
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_program', path: 'variants' }],
});
});
it('rejects combat references outside the combat reference registry', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
value: {
ref: { source: 'combat', path: 'maxHealth' },
},
},
],
},
],
}),
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].value.ref.path',
},
],
});
});
it('fails closed when an Attribute ID cannot be resolved', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
kind: 'attribute_modifier',
attributeId: 'missing-attribute',
},
],
},
],
}),
{ resolveAttributeLabel: () => undefined },
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unresolved_attribute',
path: 'variants[base].clauses[0].outcomes[0].attributeId',
},
],
});
});
it('does not render ignored combat timing on flat outcomes', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
basis: 'flat',
combatTiming: 'pre_event',
},
],
},
],
});
const result = render(program);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.variants[0].text).not.toContain('before this event');
expect(result.variants[0].text).not.toContain('using current values');
}
});
it('fails closed for targeting the runtime does not expand', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
targeting: {
kind: 'within_radius',
population: 'enemies',
radiusMeters: 4,
},
},
],
},
],
}),
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].targeting',
},
],
});
});
it('sorts set-like inputs and pools by semantic key', () => {
const first = baseProgram({
scenarioInputs: [
{
key: 'z-input',
label: 'Z input',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
{
key: 'a-input',
label: 'A input',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{ key: 'z-pool', label: 'Z pool', scope: 'agent', initial: 0 },
{ key: 'a-pool', label: 'A pool', scope: 'agent', initial: 0 },
],
});
const second = baseProgram({
scenarioInputs: [...first.scenarioInputs!].reverse(),
pools: [...first.pools!].reverse(),
});
const firstResult = render(first);
const secondResult = render(second);
expect(firstResult.ok && secondResult.ok).toBe(true);
if (firstResult.ok && secondResult.ok)
expect(firstResult.variants[0].text).toBe(secondResult.variants[0].text);
});
it('requires renderable labels instead of humanizing generated IDs', () => {
const missingVariantLabel = renderPublicMechanicPrograms([
{ key: 'generated-variant-id', program: baseProgram() },
]);
expect(missingVariantLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[generated-variant-id].label',
},
],
});
const missingClauseLabel = render(
baseProgram({
clauses: [{ ...baseProgram().clauses[0], label: undefined }],
}),
);
expect(missingClauseLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].label',
},
],
});
const missingOutcomeLabel = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
label: undefined as never,
},
],
},
],
}),
);
expect(missingOutcomeLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].label',
},
],
});
const missingPoolLabel = render(
baseProgram({
pools: [
{
key: 'generated-pool-id',
label: undefined as never,
scope: 'agent',
initial: 0,
},
],
}),
);
expect(missingPoolLabel).toMatchObject({
ok: false,
errors: [
{ code: 'unsupported_fragment', path: 'variants[base].pools[0].label' },
],
});
});
it('fails closed for unsupported fields instead of leaking them', () => {
const program = baseProgram();
(
program.clauses[0] as MechanicProgram['clauses'][number] & {
script?: string;
}
).script = 'WHEN raw';
const result = render(program);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].script',
},
],
});
});
it('does not round represented numeric values or depend on locale settings', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{ ...baseProgram().clauses[0].outcomes![0], value: 1.23456789 },
],
},
],
});
const result = render(program);
expect(result.ok).toBe(true);
if (result.ok) expect(result.variants[0].text).toContain('1.23456789');
});
});
File diff suppressed because it is too large Load Diff
+98
View File
@@ -1355,6 +1355,104 @@ describe('structured mechanic runtime', () => {
expect(maximum.finalState[0].value).toBe(4);
});
it('preserves an explicit zero clause weight', () => {
const result = evaluateMechanicProgram(
{
id: 'zero-weight',
name: 'Zero weight',
clauses: [
{
id: 'zero-weight-clause',
when: 'agent_hit',
reducer: { kind: 'none' },
weight: 0,
mutations: [],
},
],
},
[{ kind: 'agent_hit', at: 0 }],
custom(1),
);
expect(result.emissions).toHaveLength(1);
expect(result.emissions[0].weight).toBe(0);
expect(result.estimatedActivationCount).toBe(0);
});
it.each([0, 0.5])(
'keeps a weighted reload delay intact at weight %s',
(weight) => {
const result = evaluateMechanicProgram(
{
id: 'weighted-reload',
name: 'Weighted reload',
clauses: [
{
id: 'weighted-reload-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
weight,
mutations: [],
outcomes: [
{
id: 'reload',
label: 'Reload',
phase: 'on_event',
kind: 'weapon_reload',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
},
],
},
],
},
[{ kind: 'weapon_hit', at: 1 }],
custom(10),
);
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({
at: 6,
weight,
resolvedValue: 5,
appliedValue: 5,
});
},
);
it('preserves a zero weight for maximum-mode passive outcomes', () => {
const result = evaluateMechanicProgram(
{
id: 'zero-weight-maximum',
name: 'Zero weight maximum',
clauses: [
{
id: 'zero-weight-maximum-clause',
label: 'Maximum passive',
when: 'weapon_hit',
reducer: { kind: 'none' },
weight: 0,
mutations: [],
outcomes: [
{
id: 'marker',
label: 'Passive marker',
phase: 'while_active',
kind: 'marker',
scope: 'self',
value: 1,
basis: 'flat',
},
],
},
],
},
[],
{ mode: 'maximum', encounterSeconds: 10 },
);
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({ weight: 0, appliedValue: 0 });
});
it('derives proximity stacks directly from a builder-controlled snapshot', () => {
const program: MechanicProgram = {
id: 'nearby-allies',
+13 -6
View File
@@ -1633,8 +1633,10 @@ function emitOutcomes(
}
const targetIds = outcomeTargetIds(clause, outcome, context);
if (!targetIds) continue;
const emittedAt =
outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at;
if (outcome.kind === 'weapon_reload') {
appliedValue = Math.max(0, resolvedValue);
}
const emittedAt = outcome.kind === 'weapon_reload' ? at + appliedValue : at;
for (const targetId of targetIds) {
emissions.push({
clauseId: clause.id,
@@ -3115,6 +3117,10 @@ export function evaluateMechanicProgram(
.filter((pool): pool is string => Boolean(pool))
.map((pool) => [pool, stateValue(pool, undefined, context)]),
);
const weight =
clause.weight !== undefined
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
appendScheduledOutcomes(
outcomes,
emitOutcomes(
@@ -3126,7 +3132,7 @@ export function evaluateMechanicProgram(
},
'after',
0,
1,
weight,
before,
context,
continuousDeliveries,
@@ -3248,9 +3254,10 @@ export function evaluateMechanicProgram(
for (const reduction of reduced) {
if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue;
const context = { ...baseContext, reducerValue: reduction.value };
const weight = clause.weight
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const weight =
clause.weight !== undefined
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const relevantPools = new Set(
(clause.outcomes ?? [])
.map((outcome) => outcome.statePool)
+38 -6
View File
@@ -7,15 +7,20 @@
import type { DatabaseItem } from '$lib/database/items';
import MarkdownContent from './MarkdownContent.svelte';
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
import DatabaseMechanicGroupCard from './database/DatabaseMechanicGroup.svelte';
import DatabaseMechanicStatusBadge from './database/DatabaseMechanicStatusBadge.svelte';
import { databaseMechanicGroupStatus, type DatabaseMechanicGroup } from '$lib/database/items';
let {
item,
hideDiscussionUtility = false,
anchor = true
anchor = true,
detail = false
}: {
item: DatabaseItem & { gameIcon?: GameIconName };
hideDiscussionUtility?: boolean;
anchor?: boolean;
detail?: boolean;
} = $props();
let protocolLevel = $state(1);
let talentUpgrades = $state(0);
@@ -29,6 +34,14 @@
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
});
const mechanicGroups = () => item.mechanicGroups ?? [];
const hasMechanicGroups = () => mechanicGroups().length > 0;
const isSpecializationMechanic = (group: DatabaseMechanicGroup) => group.ownerType === 'specialization_tree_rank';
const hasSpecializationMechanics = () => mechanicGroups().some(isSpecializationMechanic);
const primaryMechanicGroup = () => {
const groups = mechanicGroups();
return groups.length === 1 && !isSpecializationMechanic(groups[0]) ? groups[0] : null;
};
</script>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
@@ -39,7 +52,12 @@
</div>
<div class="database-item-identity">
<small>{item.kicker}</small>
<h2>{item.name}</h2>
<div class="database-item-name-line">
<h2>{item.name}</h2>
{#if primaryMechanicGroup()}
<DatabaseMechanicStatusBadge status={databaseMechanicGroupStatus(primaryMechanicGroup()!)} />
{/if}
</div>
</div>
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
<div class="database-header-status">
@@ -53,7 +71,7 @@
{#if item.protocolPreview}
<section class="database-protocol-preview">
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
{#if item.protocolPreview.talentDescription}
{#if item.protocolPreview.talentDescription && !hasMechanicGroups()}
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
{/if}
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
@@ -86,15 +104,29 @@
{/each}
</div>
{/if}
{#if item.variant === 'talent'}
{#if hasMechanicGroups() && !hasSpecializationMechanics()}
<div class="database-mechanic-groups">
{#each mechanicGroups() as group (group.id)}
<DatabaseMechanicGroupCard {group} {detail}/>
{/each}
</div>
{/if}
{#if item.variant === 'talent' && !hasMechanicGroups()}
<div class="database-talent-tiers">
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
{#if item.secondaryDescription}<section><span>TIER II</span><p>{item.secondaryDescription}</p></section>{/if}
</div>
{:else if !item.protocolPreview}
{:else if !item.protocolPreview && (!hasMechanicGroups() || hasSpecializationMechanics())}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
{#if hasSpecializationMechanics()}
<div class="database-mechanic-groups database-specialization-effect-groups">
{#each mechanicGroups().filter(isSpecializationMechanic) as group (group.id)}
<DatabaseMechanicGroupCard {group} {detail} showIdentity/>
{/each}
</div>
{/if}
{#if item.afterLines?.length}
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
+4 -3
View File
@@ -18,6 +18,7 @@
let closeButton = $state<HTMLButtonElement | null>(null);
let dialog = $state<HTMLDivElement | null>(null);
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
if (!item.preview) return;
selected = item;
trigger = button;
await tick();
@@ -32,8 +33,8 @@
};
const trapDialogFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
.filter((element) => !element.hasAttribute('hidden'));
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),input:not([type="hidden"]):not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]:not([tabindex="-1"])')]
.filter((element) => !element.hasAttribute('hidden') && element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
@@ -65,7 +66,7 @@
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
<DatabaseItemCard item={selected.preview}/>
<DatabaseItemCard item={selected.preview} anchor={false} detail/>
</div>
</div>
{/if}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+292 -156
View File
@@ -1,5 +1,16 @@
<script lang="ts">
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
import {
formatMechanicToken,
formatMechanicValue,
normalizePhase2OwnerPreview,
parseProposalPreviewPayload,
safePreviewValue,
isPreviewFieldHidden,
type AuthoredSourcePreview,
type AttachmentPreview,
type MechanicPreviewState,
type ProposalPreviewEntry
} from './ProposalChangePreview.helpers';
let {
module,
@@ -13,8 +24,10 @@
currentSnapshot?: Record<string, unknown> | null;
} = $props();
const entries = $derived(JSON.parse(payload) as Entry[]);
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
const parsedPayload = $derived(parseProposalPreviewPayload(payload));
const entries = $derived(parsedPayload.entries as ProposalPreviewEntry[]);
const proposed = $derived(parsedPayload.fields);
const phase2OwnerPreview = $derived(normalizePhase2OwnerPreview(module, action, payload, currentSnapshot));
const label = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
const jsonValue = (value: unknown) => {
@@ -43,16 +56,8 @@
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const specializationFields = $derived(
action === 'saveProgression'
? [
@@ -116,23 +121,80 @@
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
const phase2Record = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const phase2TargetDisplay = (side: 'current' | 'proposed') =>
side === 'current'
? phase2OwnerPreview?.canonicalTargetId
: phase2OwnerPreview?.canonicalTargetId ?? 'ASSIGNED ON APPROVAL';
const phase2HasCurrentTarget = $derived(Boolean(phase2OwnerPreview?.canonicalTargetId));
const ownerIdentityFields = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
const value = (key: string) => side === 'current' ? phase2Record?.[key] : proposed[key];
if (family === 'talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'CATEGORY', value: value('category') },
{ label: 'OWNER TYPE', value: side === 'current' ? (currentSnapshot?.authoredSource as any)?.owner?.type : 'talent' },
{ label: 'OWNER ID', value: phase2TargetDisplay(side) }
];
if (family === 'protocol-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'QUALITY', value: value('quality') },
{ label: 'ATTRIBUTE TYPE', value: side === 'current' ? phase2Record?.primary_attribute_type : proposed.attributeType },
{ label: 'PRIMARY ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.primaryEffect as any)?.stat_attribute_id : proposed.primaryStatId },
{ label: 'TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'skill-chip-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION ABILITY', value: side === 'current' ? phase2Record?.specialization_ability_id : proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.twoPiece as any)?.stat_attribute_id : proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', value: side === 'current' ? `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}` : `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'specialization-node') return [
{ label: 'NAME', value: value('name') },
{ label: 'FOCUS', value: side === 'current' ? phase2Record?.focus_id : proposed.focusId },
{ label: 'ROW', value: side === 'current' ? phase2Record?.row_number : proposed.rowNumber },
{ label: 'COLUMN', value: side === 'current' ? phase2Record?.column_number : proposed.columnNumber },
{ label: 'MAXIMUM RANK', value: side === 'current' ? phase2Record?.max_rank : proposed.maxRank },
{ label: 'RANK DEFINITIONS', value: side === 'current' ? (currentSnapshot?.ranks as any[])?.length ?? 0 : (jsonValue(proposed.ranksJson) as any[])?.length ?? 0 }
];
return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION', value: side === 'current' ? phase2Record?.specialization_id : proposed.specializationId },
{ label: 'DESCRIPTION TEMPLATE', value: side === 'current' ? phase2Record?.description_template : proposed.descriptionTemplate },
{ label: 'ATTACHMENTS', value: side === 'current' ? (currentSnapshot?.attachments as any[])?.length ?? 0 : phase2OwnerPreview?.proposedAttachments.length ?? 0 }
];
};
const ownerRelationshipRows = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
if (family === 'talent') {
if (side === 'proposed') return [`CATEGORY · ${shown(proposed.category)}`, `RECORD ID · ${shown(phase2TargetDisplay(side))}`];
const relationships = (currentSnapshot?.relationships ?? {}) as any;
return [
`WEAPON SCOPE · ${shown(relationships.weaponScope?.scope)}`,
`WEAPON SLOT · ${shown(relationships.weaponSlot?.slot)}`,
`OWNER WEAPON · ${shown(relationships.ownerWeapon?.id)}${relationships.ownerWeapon?.name ? ` · ${relationships.ownerWeapon.name}` : ''}`,
`GEAR TALENTS · ${(relationships.gearTalents ?? []).map((item: any) => item.gear_id).join(', ') || 'NONE'}`,
`WEAPON TALENT OPTIONS · ${(relationships.weaponTalentOptions ?? []).map((item: any) => `${item.weapon_id} · SLOT ${item.talent_slot}`).join(', ') || 'NONE'}`
];
}
if (family === 'protocol-talent') return side === 'current'
? [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'skill-chip-talent') return side === 'current'
? [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'specialization-node') return side === 'current'
? [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(phase2Record?.focus_id)}`]
: [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(proposed.focusId)}`];
return side === 'current'
? [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `ATTACHMENT IDS · ${(currentSnapshot?.attachments as any[] ?? []).map((item: any) => item.id).join(', ') || 'NONE'}`]
: [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `FAN-OUT ATTACHMENTS · ${phase2OwnerPreview?.proposedAttachments.length ?? 0}`];
};
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
const conditionalRuleSummary = (effect: any) => {
const rule = effect.conditionalRule ?? {};
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
const outcomes = clauses.reduce((total: number, clause: any) =>
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
};
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
const storedJson = (value: unknown, fallback: unknown) => {
if (typeof value !== 'string') return value ?? fallback;
try { return JSON.parse(value); } catch { return fallback; }
};
const attackProfileLines = (attack: Record<string, unknown> | null) => {
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
const delivery = String(attack.delivery ?? '').toLowerCase();
@@ -156,90 +218,100 @@
}
return lines;
};
const conditionalRuleLines = (source: unknown) => {
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
const wrapper = source as Record<string, any>;
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
const rule = persisted ? wrapper.rule : wrapper;
const inputs = persisted
? storedJson(rule.scenario_inputs_json, [])
: rule.scenarioInputs;
const pools = persisted ? wrapper.statePools : rule.pools;
const clauses = persisted ? wrapper.clauses : rule.clauses;
const lines = [
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
];
if (Array.isArray(inputs)) {
for (const input of inputs) {
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
}
}
if (Array.isArray(pools)) {
for (const pool of pools) {
const poolKey = pool.key ?? pool.state_key;
const scope = pool.scope ?? pool.state_scope;
const maximum = pool.max ?? pool.maximum_value;
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
}
}
if (Array.isArray(clauses)) {
for (const [index, clause] of clauses.entries()) {
const event = clause.event ?? clause.event_kind;
const recipe = clause.recipeKey ?? clause.recipe_key;
const reducer = persisted
? storedJson(clause.reducer_json, { kind: 'none' })
: clause.reducer;
const mutations = persisted
? storedJson(clause.mutations_json, [])
: clause.mutations;
const branchOutcomes = persisted
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
: clause.outcomes ?? [];
lines.push([
`BRANCH ${index + 1}`,
readableToken(event),
recipe ? `RECIPE ${readableToken(recipe)}` : '',
`REDUCER ${readableToken(reducer?.kind)}`,
clause.cooldownSeconds ?? clause.cooldown_seconds
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
: '',
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
`${branchOutcomes.length} OUTCOMES`
].filter(Boolean).join(' · '));
for (const outcome of branchOutcomes) {
if (persisted) {
lines.push([
`↳ ${readableToken(outcome.variant_key)}`,
readableToken(outcome.application_kind),
readableToken(outcome.effect_scope),
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
readableToken(outcome.application_phase)
].join(' · '));
continue;
}
const variants = outcome.variants && typeof outcome.variants === 'object'
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
).join(' · ')
: 'NO VARIANTS';
lines.push([
`↳ ${shown(outcome.label ?? outcome.key)}`,
readableToken(outcome.kind),
readableToken(outcome.scope),
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
variants,
readableToken(outcome.phase)
].join(' · '));
}
}
}
return lines;
};
</script>
{#snippet authoredSourceBlock(source: AuthoredSourcePreview | null)}
{#if source}
<section class="proposal-mechanic-section authored-source-preview">
<header><span>AUTHORED SOURCE · VERSION {shown(source.version)}</span><b>{source.variants.length} ORDERED VARIANTS</b></header>
<div class="proposal-source-template"><span>TEMPLATE</span><p>{source.template}</p></div>
{#if source.specialization}
<div class="proposal-source-identity">
<div><span>RANK DESCRIPTION</span><p>{shown(source.specialization.rankDescription)}</p></div>
<div><span>REUSABLE EFFECT ID</span><p>{shown(source.specialization.effectId)}</p></div>
<div><span>REUSABLE EFFECT TEMPLATE</span><p>{shown(source.specialization.effectTemplate)}</p></div>
</div>
{/if}
<div class="proposal-source-variants">
{#each source.variants as variant, variantIndex}
<article>
<header><span>VARIANT {variantIndex + 1} · {variant.key}</span><b>{variant.slots.length} SLOTS</b></header>
<p class="proposal-materialized-text">{variant.materialized || 'NOT MATERIALIZED'}</p>
{#each variant.slots as slot, slotIndex}
<small>SLOT {slotIndex + 1} · ORDER {slot.order} · {slot.key} = {shown(slot.value)}{slot.unit ? ` · UNIT ${slot.unit}` : ''}{slot.attributeId ? ` · ATTRIBUTE ${slot.attributeId}` : ''}</small>
{:else}<small>NO TEMPLATE SLOTS</small>{/each}
</article>
{:else}<p class="proposal-structured-empty">NO AUTHORED VARIANTS</p>{/each}
</div>
</section>
{/if}
{/snippet}
{#snippet mechanicStateBlock(state: MechanicPreviewState, heading: string)}
<section class="proposal-mechanic-section mechanic-state-preview">
<header><span>{heading}</span><b>{state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : state.modelDraft ? 'MODEL SUBMISSION' : 'NO MODEL SELECTED'}</b></header>
<div class="proposal-model-summary">
<div><span>SELECTION STATE</span><strong>{state.selectedRevision ? formatMechanicToken(state.selectedRevisionStatus) : state.modelDraft ? 'PENDING REVISION' : 'ABSENT'}</strong></div>
<div><span>READINESS</span><strong>{formatMechanicToken((state.selectedRevision ?? state.modelDraft)?.readiness)}</strong></div>
<div><span>SCENARIO INPUTS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.scenarioInputs.length ?? 0}</strong></div>
<div><span>STATE POOLS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.pools.length ?? 0}</strong></div>
<div><span>CLAUSES</span><strong>{(state.selectedRevision ?? state.modelDraft)?.clauses.length ?? 0}</strong></div>
</div>
{#if state.selectedRevision ?? state.modelDraft}
{@const model = (state.selectedRevision ?? state.modelDraft)!}
<div class="proposal-graph-summary">
<section><header><span>SCENARIO INPUTS</span><b>{model.scenarioInputs.length}</b></header>
{#each model.scenarioInputs as input}<p>INPUT · {input.label} · {formatMechanicToken(input.kind)} · {formatMechanicToken(input.readiness)}{input.min !== null || input.max !== null ? ` · RANGE ${shown(input.min)}–${shown(input.max)}` : ''}{input.defaultValue !== null ? ` · DEFAULT ${shown(input.defaultValue)}` : ''}</p>{:else}<p>NO SCENARIO INPUTS</p>{/each}
</section>
<section><header><span>STATE POOLS</span><b>{model.pools.length}</b></header>
{#each model.pools as pool}<p>POOL · {pool.key} · {pool.label} · KIND {formatMechanicToken(pool.stateKind)} · {formatMechanicToken(pool.scope)} · INITIAL {shown(pool.initial)} · RANGE {shown(pool.min)}–{shown(pool.max)}{pool.bestCaseValue !== null ? ` · BEST ${shown(pool.bestCaseValue)}` : ''}{pool.expiresAfterSeconds !== null ? ` · EXPIRES ${shown(pool.expiresAfterSeconds)}s` : ''}</p>{:else}<p>NO STATE POOLS</p>{/each}
</section>
<section><header><span>CLAUSE GRAPH</span><b>{model.clauses.length}</b></header>
{#each model.clauses as clause, clauseIndex}
<article class="proposal-clause">
<p><b>CLAUSE {clauseIndex + 1} · {clause.label}</b> · EVENT {formatMechanicToken(clause.event)}{clause.recipeKey ? ` · RECIPE ${formatMechanicToken(clause.recipeKey)}` : ''} · REDUCER {formatMechanicToken((clause.reducer as any)?.kind)}{clause.cooldownSeconds !== null ? ` · COOLDOWN ${shown(clause.cooldownSeconds)}s` : ''}</p>
<small>CONDITION · {formatMechanicValue(clause.condition)} · MUTATIONS {clause.mutations.length}</small>
{#each clause.mutations as mutation, mutationIndex}<small>MUTATION {mutationIndex + 1} · {formatMechanicValue(mutation)}</small>{/each}
{#each clause.outcomes as outcome, outcomeIndex}
<div class="proposal-outcome"><small>OUTCOME {outcomeIndex + 1} · {outcome.label} · {formatMechanicToken(outcome.kind)} · {formatMechanicToken(outcome.scope)} · {formatMechanicToken(outcome.phase)} · BASIS {formatMechanicToken(outcome.basis)}{outcome.attributeId ? ` · ATTRIBUTE ${outcome.attributeId}` : ''}{outcome.statePool ? ` · POOL ${outcome.statePool}` : ''}{outcome.resource ? ` · RESOURCE ${formatMechanicToken(outcome.resource)}` : ''}{outcome.perStack !== null ? ` · PER STACK ${outcome.perStack ? 'YES' : 'NO'}` : ''}</small>{#each outcome.variants as variant}<small>VARIANT {variant.key} · VALUE {formatMechanicValue(variant.value)}{variant.expression !== null ? ` · EXPRESSION ${formatMechanicValue(variant.expression)}` : ''}</small>{/each}</div>
{/each}
</article>
{:else}<p>NO CLAUSES</p>{/each}
</section>
</div>
{:else}<p class="proposal-structured-empty">NO IMMUTABLE MODEL REVISION IS SELECTED.</p>{/if}
</section>
{/snippet}
{#snippet projectionBlock(state: MechanicPreviewState)}
<section class="proposal-mechanic-section proposal-projection-preview">
<header><span>PUBLIC PROJECTION</span><b>{formatMechanicToken(state.projection.status)}</b></header>
{#if state.projection.variants.length}
<p class="proposal-projection-meta">PROJECTION SOURCE · {state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : 'AUTHORED FALLBACK'}</p>
{#each state.projection.variants as variant}<p class="proposal-projection-variant"><b>{variant.key}</b><span>STATUS {formatMechanicToken(variant.status)} · DISPLAY SOURCE {formatMechanicToken(variant.displaySource)}</span><strong>{variant.text || 'NO PUBLIC TEXT'}</strong></p>{/each}
{:else}<p class="proposal-structured-empty">NO PUBLIC PROJECTION AVAILABLE.</p>{/if}
</section>
{/snippet}
{#snippet mechanicBlocks(state: MechanicPreviewState, heading: string)}
{@render authoredSourceBlock(state.authoredSource)}
{@render mechanicStateBlock(state, heading)}
{@render projectionBlock(state)}
{/snippet}
{#snippet attachmentBlock(attachments: AttachmentPreview[], heading: string)}
<section class="proposal-mechanic-section attachment-preview">
<header><span>{heading}</span><b>{attachments.length} ATTACHMENTS</b></header>
{#each attachments as attachment, attachmentIndex}
<article>
<header><span>ATTACHMENT {attachmentIndex + 1} · {attachment.attachmentId}</span><b>RANK {shown(attachment.rank)} · ORDER {shown(attachment.effectOrder)}</b></header>
<p>NODE ID · {shown(attachment.nodeId)} · COMBAT EFFECT ID · {shown(attachment.effectId)}</p>
{@render mechanicBlocks(attachment.mechanic, 'ATTACHMENT MECHANIC')}
</article>
{:else}<p class="proposal-structured-empty">NO ATTACHMENTS</p>{/each}
</section>
{/snippet}
<section class="proposal-change-preview" aria-label="Proposed data comparison">
<div class="proposal-preview-head">
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
@@ -281,6 +353,42 @@
</div>
</article>
</div>
{:else if phase2OwnerPreview}
<div class="proposal-compare-grid phase2-owner-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {phase2OwnerPreview.family.toUpperCase()}</span><b>{phase2HasCurrentTarget ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if phase2HasCurrentTarget}
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('current', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>LIVE</b></header>{#each ownerRelationshipRows('current', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.currentMechanic, 'MECHANIC MODEL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)) as effect}<small>{effectSummary(effect)}</small>{/each}<small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.currentAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT' : 'NODE ATTACHMENTS')}
{/if}
{:else}<div class="proposal-empty-state"><b>NO LIVE {phase2OwnerPreview.family.toUpperCase()} RECORD</b><span>The proposed owner state will be created on approval.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {phase2OwnerPreview.family.toUpperCase()}</span><b>AFTER</b></header>
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('proposed', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>PROPOSED</b></header>{#each ownerRelationshipRows('proposed', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if phase2OwnerPreview.selectionIntent}<p class="proposal-selection-intent">MODEL REQUEST · {formatMechanicToken(phase2OwnerPreview.selectionIntent)}</p>{/if}
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.proposedMechanic, 'MECHANIC MODEL AFTER APPROVAL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.proposedAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT AFTER APPROVAL' : 'NODE ATTACHMENTS AFTER APPROVAL')}
{/if}
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
@@ -292,35 +400,6 @@
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
</article>
</div>
{:else if module === 'calculations'}
<div class="proposal-compare-grid calculation-proposal-preview">
<article class="proposal-editor-preview before">
@@ -333,7 +412,7 @@
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
@@ -352,12 +431,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
@@ -385,18 +458,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
@@ -411,19 +472,94 @@
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each entries as entry}
{@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''}
{#if !isPreviewFieldHidden(entry.name)}
<div>
<span>{label(entry.name)}</span>
{#if entry.kind === 'file'}
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
{:else if jsonValue(entry.value)}
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{entry.value}</small>
{:else if jsonValue(safeValue)}
<strong>{Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{safeValue}</small>
{:else}
<strong>{shown(entry.value)}</strong>
<strong>{shown(safeValue)}</strong>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
</section>
<style>
.phase2-owner-preview { align-items: start; }
.owner-identity-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-relationships,
.proposal-mechanic-section { display: grid; gap: 5px; padding: 0 8px 8px; }
.proposal-relationships > header,
.proposal-mechanic-section > header,
.proposal-source-variants article > header,
.attachment-preview article > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 29px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-relationships > header span,
.proposal-relationships > header b,
.proposal-mechanic-section > header span,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header span,
.proposal-source-variants article > header b,
.attachment-preview article > header span,
.attachment-preview article > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-relationships > header b,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header b,
.attachment-preview article > header b { color: var(--shd-orange); }
.proposal-relationships p,
.proposal-selection-intent,
.proposal-mechanic-section > p,
.proposal-mechanic-section > article > p,
.proposal-source-variants article > small,
.attachment-preview article > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-source-template { display: grid; gap: 4px; padding: 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity { display: grid; gap: 5px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.proposal-source-identity > div { display: grid; gap: 4px; min-width: 0; padding: 7px 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-identity p { margin: 0; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-template span,
.proposal-model-summary span,
.proposal-projection-meta,
.proposal-projection-variant span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-template p,
.proposal-materialized-text { margin: 0; color: var(--shd-text); font-size: var(--shd-type-body); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-variants,
.proposal-graph-summary { display: grid; gap: 5px; }
.proposal-source-variants article { display: grid; gap: 4px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-source-variants article > small { background: #111415; }
.proposal-model-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.proposal-model-summary > div { display: grid; gap: 4px; min-width: 0; padding: 7px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-model-summary strong { color: var(--shd-text); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-graph-summary > section { display: grid; gap: 4px; }
.proposal-graph-summary > section > header { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-graph-summary > section > header span,
.proposal-graph-summary > section > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-graph-summary > section > header b { color: var(--shd-orange); }
.proposal-graph-summary > section > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause { display: grid; gap: 3px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-clause > p,
.proposal-clause > small,
.proposal-outcome > small { margin: 0; padding: 5px 6px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause > p { color: var(--shd-text); }
.proposal-outcome { display: grid; gap: 3px; margin-left: 8px; }
.proposal-projection-preview { gap: 5px; }
.proposal-projection-meta { margin: 0; padding: 0 2px; }
.proposal-projection-variant { display: grid; grid-template-columns: 42px 1fr; gap: 5px; margin: 0; padding: 6px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-projection-variant > b { color: var(--shd-orange); font-size: var(--shd-type-telemetry); }
.proposal-projection-variant > strong { grid-column: 1 / -1; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; }
.attachment-preview { padding-top: 0; }
.attachment-preview > article { display: grid; gap: 5px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.attachment-preview > article .proposal-mechanic-section { padding-right: 0; padding-left: 0; }
.proposal-structured-empty { margin: 0; padding: 8px; border: 1px dashed var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); text-align: center; }
@media (max-width: 800px) {
.owner-identity-fields, .proposal-model-summary, .proposal-source-identity { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-model-summary > div:last-child { grid-column: span 2; }
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
import {
databaseMechanicGroupDisplaySourceLabel,
databaseMechanicGroupStatus,
databaseMechanicStatusLabel,
type DatabaseMechanicGroup
} from '$lib/database/items';
import DatabaseMechanicStatusBadge from './DatabaseMechanicStatusBadge.svelte';
let {
group,
detail = false,
showIdentity = false
}: {
group: DatabaseMechanicGroup;
detail?: boolean;
showIdentity?: boolean;
} = $props();
const status = $derived(databaseMechanicGroupStatus(group));
const displaySource = $derived(databaseMechanicGroupDisplaySourceLabel(group));
</script>
<section
class="database-mechanic-group"
class:detail
class:specialization-effect={showIdentity}
data-status={status}
aria-label={`${group.label}: ${databaseMechanicStatusLabel(status)}`}
>
{#if showIdentity}
<header class="database-mechanic-group-identity">
<div>
<small>{group.label}</small>
{#if group.context}<strong>{group.context}</strong>{/if}
</div>
<DatabaseMechanicStatusBadge {status}/>
</header>
{:else if group.context}
<p class="database-mechanic-group-context">{group.context}</p>
{/if}
<div class="database-mechanic-variants">
{#each group.variants as variant (variant.label)}
<section class="database-mechanic-variant" data-status={variant.status}>
<div class="database-mechanic-variant-label"><span>{variant.label}</span></div>
<div class="database-mechanic-behavior">
<small>{variant.status === 'modeled' ? 'MODELED BEHAVIOR' : 'AUTHORED DESCRIPTION'}</small>
<p aria-label={variant.behaviorText} title={detail ? undefined : variant.behaviorText}>{variant.behaviorText}</p>
</div>
</section>
{/each}
</div>
{#if detail}
<dl class="database-mechanic-display-source">
<div><dt>DISPLAY SOURCE</dt><dd>{displaySource}</dd></div>
</dl>
{/if}
</section>
@@ -0,0 +1,12 @@
<script lang="ts">
import {
databaseMechanicStatusLabel,
type DatabaseMechanicStatus
} from '$lib/database/items';
let { status }: { status: DatabaseMechanicStatus } = $props();
</script>
<strong class="database-mechanic-status" class:not-modeled={status === 'not_modeled'}>
{databaseMechanicStatusLabel(status)}
</strong>
@@ -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>
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import {
databaseMechanicGroupDisplaySourceLabel,
databaseMechanicGroupStatus,
databaseMechanicStatusLabel,
type DatabaseMechanicGroup,
} from './items';
const group = (
variants: DatabaseMechanicGroup['variants'],
): DatabaseMechanicGroup => ({
id: 'owner-1',
ownerType: 'talent',
label: 'Weapon Talent',
variants,
});
describe('database mechanic presentation', () => {
it('uses one modeled status and display source for a fully modeled owner', () => {
const value = group([
{
label: 'Tier I',
behaviorText: 'Gain 10% Weapon Damage.',
status: 'modeled',
displaySource: 'model',
},
{
label: 'Tier II',
behaviorText: 'Gain 20% Weapon Damage.',
status: 'modeled',
displaySource: 'model',
},
]);
expect(databaseMechanicGroupStatus(value)).toBe('modeled');
expect(databaseMechanicStatusLabel(databaseMechanicGroupStatus(value))).toBe('MODELED');
expect(databaseMechanicGroupDisplaySourceLabel(value)).toBe('MODELED BEHAVIOR');
});
it('falls back to the neutral public status when an owner is incomplete', () => {
const value = group([
{
label: 'Tier I',
behaviorText: 'Gain 10% Weapon Damage.',
status: 'modeled',
displaySource: 'model',
},
{
label: 'Tier II',
behaviorText: 'Gain 20% Weapon Damage.',
status: 'not_modeled',
displaySource: 'authored',
},
]);
expect(databaseMechanicGroupStatus(value)).toBe('not_modeled');
expect(databaseMechanicStatusLabel(databaseMechanicGroupStatus(value))).toBe('NOT MODELED');
expect(databaseMechanicGroupDisplaySourceLabel(value)).toBe('AUTHORED DESCRIPTION');
});
});
+48
View File
@@ -2,6 +2,53 @@ import type { GameIconName } from '$lib/game-icons';
import type { GameDataTargetType } from '$lib/game-data-targets';
export type DatabaseBadge = { label: string; tone?: string };
/** Public, serializable mechanic presentation for one source owner. */
export type DatabaseMechanicOwnerType =
| 'talent'
| 'os_protocol_talent'
| 'skill_chip_talent'
| 'specialization_tree_rank';
export type DatabaseMechanicStatus = 'modeled' | 'not_modeled';
export type DatabaseMechanicDisplaySource = 'model' | 'authored';
export type DatabaseMechanicVariant = {
label: string;
behaviorText: string;
status: DatabaseMechanicStatus;
displaySource: DatabaseMechanicDisplaySource;
};
export type DatabaseMechanicGroup = {
/** Stable owner identity for keyed rendering and deep-linkable detail. */
id: string;
ownerType: DatabaseMechanicOwnerType;
label: string;
context?: string;
variants: DatabaseMechanicVariant[];
};
export const databaseMechanicStatusLabel = (status: DatabaseMechanicStatus) =>
status === 'modeled' ? 'MODELED' : 'NOT MODELED';
/** A complete public owner projection has one status across its variants. */
export const databaseMechanicGroupStatus = (
group: DatabaseMechanicGroup,
): DatabaseMechanicStatus =>
group.variants.length > 0 &&
group.variants.every((variant) => variant.status === 'modeled')
? 'modeled'
: 'not_modeled';
export const databaseMechanicGroupDisplaySourceLabel = (
group: DatabaseMechanicGroup,
) =>
databaseMechanicGroupStatus(group) === 'modeled'
? 'MODELED BEHAVIOR'
: 'AUTHORED DESCRIPTION';
export const isDatabaseMechanicStatus = (
value: unknown,
): value is DatabaseMechanicStatus =>
value === 'modeled' || value === 'not_modeled';
export type DatabaseMeter = { label: string; value: number };
export type DatabaseWeaponModSlot = {
slot: string;
@@ -43,6 +90,7 @@ export type DatabaseItem = {
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
weaponModSlots?: DatabaseWeaponModSlot[];
mechanicGroups?: DatabaseMechanicGroup[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Database } from '@tursodatabase/database';
import type { MechanicRuntimeSelection } from './mechanic-runtime-selection';
const mocks = vi.hoisted(() => ({
loadSelections: vi.fn(),
}));
vi.mock('./mechanic-runtime-selection', async (importOriginal) => ({
...(await importOriginal<typeof import('./mechanic-runtime-selection')>()),
loadSelectedMechanicRuntimesForOwnerReferences: mocks.loadSelections,
}));
import { loadBuildMechanicRuntimes } from './build-mechanic-runtime';
import { mechanicRuntimeOwnerReferenceKey } from './mechanic-runtime-selection';
const runtime = (sourceId: string): MechanicRuntimeSelection => ({
owner: { type: 'talent', id: sourceId, scope: 'standard', hasTierII: true },
sourceId,
sourceFingerprint: 'a'.repeat(64),
revisionId: `${sourceId}-revision`,
revisionNumber: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
variants: [
{
key: 'base',
label: 'BASE',
program: { id: `mechanic-runtime:${sourceId}:base`, name: sourceId, clauses: [] },
},
],
});
function emptyDatabase() {
return {} as Database;
}
describe('Build mechanic runtime hydration', () => {
beforeEach(() => {
mocks.loadSelections.mockReset();
});
it('maps every owner family through the selected-runtime reader using attachment ownership', async () => {
const database = emptyDatabase();
const selections = new Map<string, MechanicRuntimeSelection | null>([
[
mechanicRuntimeOwnerReferenceKey({
type: 'talent',
id: 'talent-1',
}),
runtime('talent-source'),
],
[
mechanicRuntimeOwnerReferenceKey({
type: 'os_protocol_talent',
id: 'protocol-talent-1',
}),
runtime('protocol-source'),
],
[
mechanicRuntimeOwnerReferenceKey({
type: 'skill_chip_talent',
id: 'chip-talent-1',
}),
runtime('chip-source'),
],
[
mechanicRuntimeOwnerReferenceKey({
type: 'specialization_tree_rank',
id: 'attachment-1',
}),
runtime('attachment-source'),
],
]);
mocks.loadSelections.mockResolvedValueOnce(selections);
const hydrated = await loadBuildMechanicRuntimes(database, {
talentIds: ['talent-1', 'not-authored'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1'],
});
expect(mocks.loadSelections).toHaveBeenCalledWith(database, [
{ type: 'talent', id: 'talent-1' },
{ type: 'talent', id: 'not-authored' },
{ type: 'os_protocol_talent', id: 'protocol-talent-1' },
{ type: 'skill_chip_talent', id: 'chip-talent-1' },
{ type: 'specialization_tree_rank', id: 'attachment-1' },
]);
expect(hydrated).toMatchObject({
talents: {
'talent-1': { sourceId: 'talent-source' },
'not-authored': null,
},
protocolTalents: {
'protocol-talent-1': { sourceId: 'protocol-source' },
},
skillChipTalents: { 'chip-talent-1': { sourceId: 'chip-source' } },
specializationAttachments: {
'attachment-1': { sourceId: 'attachment-source' },
},
});
expect(
hydrated.specializationAttachments['combat-effect-reusable'],
).toBeUndefined();
expect(JSON.parse(JSON.stringify(hydrated))).toEqual(hydrated);
});
it('returns null capabilities when no selected source can be loaded', async () => {
const database = emptyDatabase();
const hydrated = await loadBuildMechanicRuntimes(database, {
talentIds: ['talent-1'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1'],
});
expect(hydrated).toEqual({
talents: { 'talent-1': null },
protocolTalents: { 'protocol-talent-1': null },
skillChipTalents: { 'chip-talent-1': null },
specializationAttachments: { 'attachment-1': null },
});
expect(mocks.loadSelections).toHaveBeenCalledOnce();
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { Database } from '@tursodatabase/database';
import {
loadSelectedMechanicRuntimesForOwnerReferences,
mechanicRuntimeOwnerReferenceKey,
type MechanicRuntimeSelection,
} from './mechanic-runtime-selection';
export type BuildMechanicRuntimeOwnerIds = {
talentIds: readonly string[];
protocolTalentIds: readonly string[];
skillChipTalentIds: readonly string[];
specializationAttachmentIds: readonly string[];
};
export type BuildMechanicRuntimes = {
talents: Record<string, MechanicRuntimeSelection | null>;
protocolTalents: Record<string, MechanicRuntimeSelection | null>;
skillChipTalents: Record<string, MechanicRuntimeSelection | null>;
specializationAttachments: Record<string, MechanicRuntimeSelection | null>;
};
function uniqueNonEmptyIds(ids: readonly string[]) {
return [...new Set(ids.filter((id) => id.trim().length > 0))];
}
function emptyRuntimeMap(ids: readonly string[]) {
return Object.fromEntries(ids.map((id) => [id, null])) as Record<
string,
MechanicRuntimeSelection | null
>;
}
/**
* Hydrates the only executable capability exposed to Build surfaces. One
* bounded bulk read resolves source metadata, selected revisions, graph rows,
* and active attributes. Every owner still receives an independent null when
* its source cannot be validated.
*/
export async function loadBuildMechanicRuntimes(
database: Database,
input: BuildMechanicRuntimeOwnerIds,
): Promise<BuildMechanicRuntimes> {
const talentIds = uniqueNonEmptyIds(input.talentIds);
const protocolTalentIds = uniqueNonEmptyIds(input.protocolTalentIds);
const skillChipTalentIds = uniqueNonEmptyIds(input.skillChipTalentIds);
const specializationAttachmentIds = uniqueNonEmptyIds(
input.specializationAttachmentIds,
);
const runtimes: BuildMechanicRuntimes = {
talents: emptyRuntimeMap(talentIds),
protocolTalents: emptyRuntimeMap(protocolTalentIds),
skillChipTalents: emptyRuntimeMap(skillChipTalentIds),
specializationAttachments: emptyRuntimeMap(specializationAttachmentIds),
};
const owners = [
...talentIds.map((id) => ({ type: 'talent' as const, id })),
...protocolTalentIds.map((id) => ({
type: 'os_protocol_talent' as const,
id,
})),
...skillChipTalentIds.map((id) => ({
type: 'skill_chip_talent' as const,
id,
})),
...specializationAttachmentIds.map((id) => ({
type: 'specialization_tree_rank' as const,
id,
})),
];
if (!owners.length) return runtimes;
try {
const selections = await loadSelectedMechanicRuntimesForOwnerReferences(
database,
owners,
);
for (const id of talentIds) {
runtimes.talents[id] =
selections.get(mechanicRuntimeOwnerReferenceKey({ type: 'talent', id })) ??
null;
}
for (const id of protocolTalentIds) {
runtimes.protocolTalents[id] =
selections.get(
mechanicRuntimeOwnerReferenceKey({
type: 'os_protocol_talent',
id,
}),
) ?? null;
}
for (const id of skillChipTalentIds) {
runtimes.skillChipTalents[id] =
selections.get(
mechanicRuntimeOwnerReferenceKey({
type: 'skill_chip_talent',
id,
}),
) ?? null;
}
for (const id of specializationAttachmentIds) {
runtimes.specializationAttachments[id] =
selections.get(
mechanicRuntimeOwnerReferenceKey({
type: 'specialization_tree_rank',
id,
}),
) ?? null;
}
} catch {
// An unavailable metadata read has no executable fallback at this boundary.
}
return runtimes;
}
File diff suppressed because it is too large Load Diff
+726 -72
View File
@@ -5,6 +5,37 @@ import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
import type { Database } from '@tursodatabase/database';
import { GameDataIconValidationError } from '$lib/server/game-data-icons';
import { ManagedImageValidationError } from '$lib/server/managed-images';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
readTalentFormMedia,
saveTalentFromFormInTransaction,
TalentNotFoundError,
type SaveTalentInTransactionResult,
} from '../../routes/isac/talents/talent-save';
import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources';
import {
parseProtocolForm,
ProtocolFormValidationError,
ProtocolNotFoundError,
saveProtocolInTransaction,
} from '../../routes/isac/protocols/protocol-save';
import {
_saveSkillChipInTransaction,
parseSkillChipForm,
SkillChipFormValidationError,
SkillChipNotFoundError,
} from '../../routes/isac/skillchips/skillchip-save';
import {
parseSpecializationCombatEffectForm,
parseSpecializationNodeForm,
saveSpecializationCombatEffectInTransaction,
saveSpecializationNodeInTransaction,
SpecializationFormValidationError,
} from '../../routes/isac/specializations/specialization-save';
export const DATA_MODULES = [
'configuration',
@@ -93,7 +124,22 @@ export function proposalActionResult(
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
}
type SerializedEntry =
export const MAX_PROPOSAL_ENTRIES = 300;
export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000;
export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024;
export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024;
const MAX_PROPOSAL_FILE_BASE64_CHARS =
4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3);
export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200;
export const MAX_PROPOSAL_FILE_NAME_CHARS = 255;
export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255;
const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
const PROPOSAL_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
export type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| {
name: string;
@@ -103,58 +149,647 @@ type SerializedEntry =
type: string;
};
type PendingDefinition = {
export class ProposalPayloadValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProposalPayloadValidationError';
}
}
export type PendingDefinition = {
module: DataModule;
action: string;
kind: string;
fields?: string[];
};
async function serialize(form: FormData): Promise<SerializedEntry[]> {
function validateProposalFieldName(name: string) {
if (
Array.from(name).length > MAX_PROPOSAL_FIELD_NAME_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(name)
)
throw new ProposalPayloadValidationError(
`Proposal field names must be at most ${MAX_PROPOSAL_FIELD_NAME_CHARS} characters and contain no control characters.`,
);
}
function validateProposalFileMetadata(fileName: string, type: string) {
if (
Array.from(fileName).length > MAX_PROPOSAL_FILE_NAME_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(fileName)
)
throw new ProposalPayloadValidationError(
`Proposal file names must be at most ${MAX_PROPOSAL_FILE_NAME_CHARS} characters and contain no control characters.`,
);
if (
Array.from(type).length > MAX_PROPOSAL_FILE_TYPE_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(type)
)
throw new ProposalPayloadValidationError(
`Proposal file types must be at most ${MAX_PROPOSAL_FILE_TYPE_CHARS} characters and contain no control characters.`,
);
}
function decodeSerializedProposalFile(value: string) {
if (value.length > MAX_PROPOSAL_FILE_BASE64_CHARS)
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
if (!PROPOSAL_BASE64_PATTERN.test(value))
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
const bytes = Buffer.from(value, 'base64');
if (!bytes.length || bytes.toString('base64') !== value)
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
return bytes;
}
export function validateSerializedProposalEntries(entries: SerializedEntry[]) {
if (!Array.isArray(entries))
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
if (entries.length > MAX_PROPOSAL_ENTRIES)
throw new ProposalPayloadValidationError(
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
);
let textBytes = 0;
let fileBytes = 0;
for (const entry of entries) {
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string')
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
validateProposalFieldName(entry.name);
if (entry.kind === 'text') {
if (typeof entry.value !== 'string')
throw new ProposalPayloadValidationError(
'Proposal entries are invalid.',
);
if (Array.from(entry.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
throw new ProposalPayloadValidationError(
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
);
textBytes += Buffer.byteLength(entry.value, 'utf8');
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
throw new ProposalPayloadValidationError(
'The aggregate proposal text exceeds the safe size limit.',
);
continue;
}
if (entry.kind !== 'file')
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
if (
typeof entry.value !== 'string' ||
typeof entry.fileName !== 'string' ||
typeof entry.type !== 'string'
)
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
validateProposalFileMetadata(entry.fileName, entry.type);
const bytes = decodeSerializedProposalFile(entry.value);
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
fileBytes += bytes.length;
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
}
}
export async function serializeProposal(
form: FormData,
): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
let entryCount = 0;
let textBytes = 0;
let fileBytes = 0;
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0)
entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type,
});
entryCount += 1;
if (entryCount > MAX_PROPOSAL_ENTRIES)
throw new ProposalPayloadValidationError(
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
);
validateProposalFieldName(name);
if (typeof value === 'string') {
if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
throw new ProposalPayloadValidationError(
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
);
textBytes += Buffer.byteLength(value, 'utf8');
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
throw new ProposalPayloadValidationError(
'The aggregate proposal text exceeds the safe size limit.',
);
entries.push({ name, kind: 'text', value });
continue;
}
validateProposalFileMetadata(value.name, value.type);
if (value.size === 0) continue;
if (value.size > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
const bytes = Buffer.from(await value.arrayBuffer());
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
fileBytes += bytes.length;
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
entries.push({
name,
kind: 'file',
value: bytes.toString('base64'),
fileName: value.name,
type: value.type,
});
}
return entries;
}
export function parseSerializedProposalEntries(
payload: string,
): SerializedEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
validateSerializedProposalEntries(parsed as SerializedEntry[]);
} catch (error) {
if (error instanceof ProposalPayloadValidationError) throw error;
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
}
return parsed as SerializedEntry[];
}
export function deserializeProposal(payload: string): FormData {
const entries = parseSerializedProposalEntries(payload);
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
for (const entry of entries) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else
form.append(
entry.name,
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
new Blob([decodeSerializedProposalFile(entry.value)], {
type: entry.type,
}),
entry.fileName,
);
}
return form;
}
export class ProposalNotPendingError extends Error {
constructor() {
super('That pending proposal no longer exists.');
this.name = 'ProposalNotPendingError';
}
}
export class ProposalDependenciesBlockedError extends Error {
readonly dependencies: string;
constructor(dependencies: string) {
super(`Approve this proposal's dependencies first: ${dependencies}.`);
this.name = 'ProposalDependenciesBlockedError';
this.dependencies = dependencies;
}
}
export class ProposalDependencyCycleError extends Error {
readonly proposalId: string;
readonly dependencyId: string;
constructor(proposalId: string, dependencyId: string) {
super(
`Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`,
);
this.name = 'ProposalDependencyCycleError';
this.proposalId = proposalId;
this.dependencyId = dependencyId;
}
}
export class ProposalCanonicalValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ProposalCanonicalValidationError';
this.status = status;
}
}
const safeValidationMessage = (error: unknown) => {
const message = error instanceof Error ? error.message : '';
return message && message.length <= 500
? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim()
: 'The proposal did not pass current canonical validation.';
};
function canonicalFailure(
error: unknown,
): ProposalCanonicalValidationError | null {
if (error instanceof ProposalCanonicalValidationError)
return new ProposalCanonicalValidationError(
error.status,
safeValidationMessage(error),
);
if (error instanceof ProposalPayloadValidationError)
return new ProposalCanonicalValidationError(
400,
'The stored proposal payload is invalid.',
);
if (error instanceof MechanicSourceFingerprintConflictError)
return new ProposalCanonicalValidationError(
409,
safeValidationMessage(error),
);
if (
error instanceof GameDataIconValidationError ||
error instanceof ManagedImageValidationError
)
return new ProposalCanonicalValidationError(
400,
safeValidationMessage(error),
);
if (
error instanceof ProtocolFormValidationError ||
error instanceof SkillChipFormValidationError ||
error instanceof SpecializationFormValidationError
)
return new ProposalCanonicalValidationError(
error.status,
safeValidationMessage(error),
);
if (
error instanceof ProtocolNotFoundError ||
error instanceof TalentNotFoundError ||
error instanceof SkillChipNotFoundError
)
return new ProposalCanonicalValidationError(
404,
safeValidationMessage(error),
);
if (
error instanceof Error &&
/no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test(
error.message,
)
)
return new ProposalCanonicalValidationError(
409,
safeValidationMessage(error),
);
return null;
}
const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([
'The specialization tree node no longer exists.',
'The specialization combat effect no longer exists.',
]);
const SPECIALIZATION_CONFLICT_ERRORS = new Set([
'The node identity does not match the supplied record.',
'A saved tree node cannot be moved to another focus.',
'A combat-effect attachment was submitted more than once.',
'A submitted combat-effect attachment does not belong to this node.',
'A new combat-effect attachment reused an existing ID.',
'The combat-effect identity does not match the supplied record.',
'A saved combat effect cannot be moved to another specialization.',
]);
function classifySpecializationTransactionError(error: unknown) {
if (error instanceof SpecializationFormValidationError) return error;
if (error instanceof MechanicSourceFingerprintConflictError) return error;
if (!(error instanceof Error)) return null;
const message = error.message.trim();
if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message))
return new SpecializationFormValidationError(
404,
safeValidationMessage(error),
);
if (SPECIALIZATION_CONFLICT_ERRORS.has(message))
return new SpecializationFormValidationError(
409,
safeValidationMessage(error),
);
const isKnownValidationMessage =
message === 'Every conditional effect requires an authored description.' ||
message ===
'The authored mechanic owner does not match its attachment.' ||
message === 'One or more conditional effects are unavailable for this specialization.' ||
message === 'One or more authored description Attributes are unavailable.' ||
message.startsWith('The authored template ') ||
message.startsWith('Clause ') ||
message.startsWith('Conditional mechanic ');
return isKnownValidationMessage
? new SpecializationFormValidationError(400, safeValidationMessage(error))
: null;
}
function enforcePhase2ProposalIdentity(
proposal: Record<string, unknown>,
form: FormData,
) {
const entityId = String(proposal.entity_id ?? '').trim();
if (entityId) {
form.set('id', entityId);
form.delete('_proposedEntityId');
return;
}
const draftEntityId = String(proposal.draft_entity_id ?? '').trim();
if (!draftEntityId)
throw new ProposalCanonicalValidationError(
400,
'The proposal does not have a stored draft identity.',
);
form.delete('id');
form.set('_proposedEntityId', draftEntityId);
}
async function replayPhase2Owner(
database: Database,
proposal: Record<string, unknown>,
reviewerId: string,
form: FormData,
proposalId: string,
journal: MediaMutationJournal,
) {
const mutationContext = { origin: 'proposal' as const, proposalId };
switch (`${String(proposal.module)}:${String(proposal.action)}`) {
case 'talents:saveTalent': {
const media = await readTalentFormMedia(form);
const result: SaveTalentInTransactionResult =
await saveTalentFromFormInTransaction(database, {
form,
operatorId: reviewerId,
proposedEntityId:
String(proposal.draft_entity_id ?? '').trim() || undefined,
...media,
mutationContext,
journal,
});
if (!result.ok)
throw new ProposalCanonicalValidationError(
result.status,
result.errors[0]?.message ?? 'The talent could not be saved.',
);
return { entityId: result.talentId, mode: result.talentMode };
}
case 'protocols:save': {
const input = await parseProtocolForm(
database,
form,
reviewerId,
mutationContext,
);
const result = await saveProtocolInTransaction(database, {
...input,
journal,
});
return { entityId: result.protocolId, mode: result.mode };
}
case 'skillchips:save': {
const input = await parseSkillChipForm(
database,
form,
reviewerId,
mutationContext,
);
const result = await _saveSkillChipInTransaction(database, {
...input,
journal,
});
return { entityId: result.setId, mode: result.mode };
}
case 'specializations:saveNode': {
const input = await parseSpecializationNodeForm(
database,
form,
reviewerId,
mutationContext,
);
try {
const result = await saveSpecializationNodeInTransaction(database, {
...input,
journal,
});
return {
entityId: input.nodeId,
mode: input.id ? 'edit' : 'add',
result,
};
} catch (error) {
throw classifySpecializationTransactionError(error) ?? error;
}
}
case 'specializations:saveCombatEffect': {
const input = await parseSpecializationCombatEffectForm(
database,
form,
reviewerId,
mutationContext,
);
try {
const result = await saveSpecializationCombatEffectInTransaction(
database,
input,
);
return {
entityId: input.effectId,
mode: input.id ? 'edit' : 'add',
result,
};
} catch (error) {
throw classifySpecializationTransactionError(error) ?? error;
}
}
default:
throw new Error('Unsupported Phase 2 proposal action.');
}
}
export type Phase2ApprovalResult = {
proposal: Record<string, unknown>;
entityId: string;
mode: string;
};
/** Runs one Phase 2 owner replay and proposal decision in one immediate tx. */
export async function approvePhase2Proposal(
database: Database,
reviewerId: string,
proposalId: string,
note: string | null,
): Promise<Phase2ApprovalResult> {
const journal = new MediaMutationJournal();
try {
const operation = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(proposalId);
if (!proposal) throw new ProposalNotPendingError();
const claimed = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='approved' WHERE id=? AND status='pending'",
)
).run(proposalId);
if (!Number(claimed?.changes ?? 0)) throw new ProposalNotPendingError();
const unresolved = await (
await tx.prepare(`SELECT dependency.id,dependency.summary,dependency.status
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
).all(proposalId);
if (unresolved.length) {
const names = unresolved
.map((item) => `${item.summary} (${item.status})`)
.join(', ');
throw new ProposalDependenciesBlockedError(names);
}
let result: { entityId: string; mode: string };
try {
const form = deserializeProposal(String(proposal.payload_json));
enforcePhase2ProposalIdentity(proposal as Record<string, unknown>, form);
result = await replayPhase2Owner(
tx,
proposal,
reviewerId,
form,
proposalId,
journal,
);
} catch (error) {
const failure = canonicalFailure(error);
if (failure) throw failure;
throw error;
}
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET applied_entity_id=?,reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=? AND status='approved'",
)
).run(result.entityId, reviewerId, note || null, now, now, proposalId);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewerId,
category: 'data_moderation',
action: 'proposal.approved',
entityId: proposalId,
before: { status: 'pending' },
after: {
status: 'approved',
module: proposal.module,
action: proposal.action,
proposedBy: proposal.proposed_by,
appliedEntityId: result.entityId,
},
database: tx,
});
return { proposal, entityId: result.entityId, mode: result.mode };
});
const result = await operation.immediate();
journal.clear();
return result;
} catch (error) {
await journal.rollback(error);
throw error;
}
}
export async function failPhase2Proposal(
database: Database,
reviewerId: string,
proposalId: string,
message: string,
) {
const operation = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(proposalId);
if (!proposal) throw new ProposalNotPendingError();
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
)
).run(reviewerId, message, now, proposalId);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewerId,
category: 'data_moderation',
action: 'proposal.failed',
entityId: proposalId,
before: { status: 'pending' },
after: { status: 'failed', messagePresent: Boolean(message) },
database: tx,
});
return proposal as Record<string, unknown>;
});
return operation.immediate();
}
export async function refreshProposalDependencies(
proposalId: string,
payload: string,
suppliedDatabase?: Database,
) {
const database = await getDatabase();
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const textValues = serializedTextValues(payload);
// A malformed persisted payload cannot provide reliable dependency evidence.
// Leave existing links intact rather than mutating state during recovery.
if (!textValues) return [];
const database = suppliedDatabase ?? (await getDatabase());
const text = textValues.map((entry) => entry.value).join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE id<>? AND draft_entity_id IS NOT NULL`)
WHERE id<>? AND status='pending' AND draft_entity_id IS NOT NULL`)
).all(proposalId);
const dependencies = candidates.filter((candidate) =>
text.includes(String(candidate.draft_entity_id)),
);
const existingLinks = await (
await database.prepare(
'SELECT proposal_id,depends_on_proposal_id FROM data_change_proposal_dependencies',
)
).all();
const links = new Map<string, string[]>();
for (const link of existingLinks) {
const proposalLinks = links.get(String(link.proposal_id)) ?? [];
proposalLinks.push(String(link.depends_on_proposal_id));
links.set(String(link.proposal_id), proposalLinks);
}
const reachesProposal = (start: string) => {
const visited = new Set<string>();
const toVisit = [start];
while (toVisit.length) {
const current = toVisit.pop()!;
if (current === proposalId) return true;
if (visited.has(current)) continue;
visited.add(current);
toVisit.push(...(links.get(current) ?? []));
}
return false;
};
for (const dependency of dependencies) {
const dependencyId = String(dependency.id);
if (reachesProposal(dependencyId))
throw new ProposalDependencyCycleError(proposalId, dependencyId);
}
await (
await database.prepare(
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
@@ -170,11 +805,19 @@ export async function refreshProposalDependencies(
return dependencies.map((dependency) => String(dependency.id));
}
const serializedTextValues = (payload: string) =>
(JSON.parse(payload) as SerializedEntry[]).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
const serializedTextValues = (
payload: string,
): Array<Extract<SerializedEntry, { kind: 'text' }>> | null => {
try {
return parseSerializedProposalEntries(payload).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
} catch (error) {
if (error instanceof ProposalPayloadValidationError) return null;
throw error;
}
};
export async function getPendingEntities(
_userId: string,
@@ -211,24 +854,30 @@ export async function getPendingEntities(
(item) => item.module === row.module && item.action === row.action,
)!;
const values = serializedTextValues(String(row.payload_json));
const unusable = values === null;
const safeValues = values ?? [];
const field = (name: string) =>
values.find((entry) => entry.name === name)?.value ?? '';
safeValues.find((entry) => entry.name === name)?.value ?? '';
const fieldValue = (name: string) => {
const matches = values
const matches = safeValues
.filter((entry) => entry.name === name)
.map((entry) => entry.value);
return matches.length > 1 ? matches : (matches[0] ?? '');
};
return {
id: String(row.draft_entity_id),
id: String(row.draft_entity_id ?? row.id),
proposalId: String(row.id),
pending: true,
active: 1,
...(unusable ? { unusable: true } : {}),
kind: definition.kind,
name: field('name'),
name: unusable ? 'UNUSABLE PROPOSAL' : field('name'),
proposerName: String(row.proposer_name),
...Object.fromEntries(
(definition.fields ?? []).map((name) => [name, fieldValue(name)]),
(definition.fields ?? []).map((name) => [
name,
unusable ? '' : fieldValue(name),
]),
),
};
});
@@ -258,58 +907,63 @@ export async function queueDataProposal(
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const payload = JSON.stringify(await serializeProposal(form));
const database = await getDatabase();
await (
await database.prepare(`INSERT INTO data_change_proposals
const createProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
await (
await tx.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
).run(
id,
module,
action,
entityId,
draftEntityId,
summary,
contributorNote || null,
payload,
user.id,
now,
);
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
).run(
id,
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNote: contributorNote || null,
},
contributorNote || null,
payload,
user.id,
now,
);
const text = (serializedTextValues(payload) ?? [])
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await tx.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await tx.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNotePresent: Boolean(contributorNote),
},
database: tx,
});
});
await createProposal.immediate();
await notifyOversightReview({
category: 'data_changes',
itemId: id,
+23
View File
@@ -0,0 +1,23 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import { configureDatabase } from './database';
describe('database connection configuration', () => {
it('enables and enforces foreign keys on application connections', async () => {
const database = await configureDatabase(await connect(':memory:'));
try {
expect(
await (await database.prepare('PRAGMA foreign_keys')).get(),
).toEqual({ foreign_keys: 1 });
await database.exec('CREATE TABLE parent(id TEXT PRIMARY KEY)');
await database.exec(
'CREATE TABLE child(parent_id TEXT REFERENCES parent(id))',
);
await expect(
database.exec("INSERT INTO child(parent_id) VALUES('missing')"),
).rejects.toThrow();
} finally {
await database.close();
}
});
});
+13 -1
View File
@@ -4,6 +4,18 @@ import { resolve } from 'node:path';
let databasePromise: Promise<Database> | undefined;
export async function configureDatabase(database: Database): Promise<Database> {
await database.exec('PRAGMA foreign_keys=ON');
const state = await (
await database.prepare('PRAGMA foreign_keys')
).get();
if (Number(state?.foreign_keys) !== 1) {
await database.close();
throw new Error('Database foreign-key enforcement could not be enabled.');
}
return database;
}
/**
* Returns the process-wide embedded Turso connection.
*
@@ -15,7 +27,7 @@ export function getDatabase(): Promise<Database> {
env.TDR_DATABASE_PATH || './data/shd-build-development.db';
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
databasePromise = connect(resolve(configuredPath)).then(configureDatabase);
}
return databasePromise;
+123 -38
View File
@@ -8,6 +8,12 @@ import {
removeBackgroundPreview,
} from './remove-background';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
import {
acquireMediaPathLock,
captureMediaFile,
restoreMediaFile,
type MediaMutationJournal,
} from './media-journal';
export const GAME_ICON_TYPES = [
'talent',
@@ -374,54 +380,124 @@ export async function saveGameDataIcon(
entityId: string,
output: Buffer,
actorId: string,
journal?: MediaMutationJournal,
): Promise<string> {
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
const filePath = storagePath(key);
let release: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
try {
if (journal) {
await journal.lockPath(filePath);
const previous = await captureMediaFile(filePath);
journal.register(() => restoreMediaFile(filePath, previous));
} else {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
release = await acquireMediaPathLock(filePath);
}
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(filePath, output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
).run(type, entityId, key, output.length, now);
await registerLibraryIcon(
database,
type,
entityId,
key,
output.length,
now,
actorId,
);
return now;
).run(type, entityId, key, output.length, now);
await registerLibraryIcon(
database,
type,
entityId,
key,
output.length,
now,
actorId,
);
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return now;
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
release?.();
}
}
export async function deleteGameDataIcon(
database: Database,
type: GameDataIconType,
entityId: string,
journal?: MediaMutationJournal,
): Promise<void> {
const icon = await (
await database.prepare(
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).run(type, entityId);
await (
await database.prepare(
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch(
(error: NodeJS.ErrnoException) => {
const canonicalPath = storagePath(storageKey(type, entityId));
let releaseCanonical: (() => void) | undefined;
let releaseFile: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
try {
if (journal) await journal.lockPath(canonicalPath);
else {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
releaseCanonical = await acquireMediaPathLock(canonicalPath);
}
const icon = await (
await database.prepare(
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
const filePath =
typeof icon?.storage_key === 'string'
? storagePath(icon.storage_key)
: null;
if (filePath && path.resolve(filePath) !== path.resolve(canonicalPath)) {
if (journal) await journal.lockPath(filePath);
else releaseFile = await acquireMediaPathLock(filePath);
}
if (journal) {
for (const pathToCapture of [canonicalPath, filePath]) {
if (!pathToCapture) continue;
const previous = await captureMediaFile(pathToCapture);
journal.register(() => restoreMediaFile(pathToCapture, previous));
}
}
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).run(type, entityId);
await (
await database.prepare(
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).run(type, entityId);
if (filePath) {
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
},
);
});
}
if (!filePath || path.resolve(filePath) !== path.resolve(canonicalPath)) {
await unlink(canonicalPath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
releaseFile?.();
releaseCanonical?.();
}
}
@@ -462,10 +538,19 @@ export async function applyIconMutation(
entityId: string,
mutation: { output: Buffer | null; remove: boolean },
actorId: string,
journal?: MediaMutationJournal,
): Promise<void> {
if (mutation.output)
await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
await saveGameDataIcon(
database,
type,
entityId,
mutation.output,
actorId,
journal,
);
else if (mutation.remove)
await deleteGameDataIcon(database, type, entityId, journal);
else {
const icon = await (
await database.prepare(
+4 -4
View File
@@ -160,7 +160,7 @@ describe('deleteManagedImage', () => {
});
expect(database.images.has(imageId)).toBe(false);
expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
expect(rmMock).toHaveBeenCalledOnce();
},
);
@@ -214,7 +214,7 @@ describe('deleteManagedImage', () => {
expect(deleted?.sourceIcon).toBeNull();
expect(database.images.has(imageId)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
});
it('fails closed when the library and source rows reference different files', async () => {
@@ -236,7 +236,7 @@ describe('deleteManagedImage', () => {
).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.icons.has(`talent:${sourceId}`)).toBe(true);
expect(database.executed).toEqual([]);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']);
expect(rmMock).not.toHaveBeenCalled();
});
@@ -258,7 +258,7 @@ describe('deleteManagedImage', () => {
deleteManagedImage(database as never, imageId),
).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.executed).toEqual([]);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']);
expect(rmMock).not.toHaveBeenCalled();
},
);
+331 -103
View File
@@ -4,6 +4,12 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
import {
acquireMediaPathLock,
captureMediaFile,
restoreMediaFile,
type MediaMutationJournal,
} from './media-journal';
import {
BackgroundRemovalError,
removeBackgroundPreview,
@@ -173,12 +179,48 @@ export async function processManagedImage(
}
}
export async function saveManagedImageFile(id: string, output: Buffer) {
async function saveManagedImageFileUnlocked(
key: string,
filePath: string,
output: Buffer,
journal?: MediaMutationJournal,
) {
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(filePath, output, { flag: 'wx', mode: 0o600 });
if (journal) {
try {
journal.register(() => rm(filePath, { force: true }));
} catch (error) {
try {
await rm(filePath, { force: true });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'Unable to register or clean up the managed image.',
);
}
throw error;
}
}
return key;
}
export async function saveManagedImageFile(
id: string,
output: Buffer,
journal?: MediaMutationJournal,
) {
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
const key = `library-${id}.webp`;
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
return key;
const filePath = storagePath(key);
let release: (() => void) | undefined;
if (journal) await journal.lockPath(filePath);
else release = await acquireMediaPathLock(filePath);
try {
return await saveManagedImageFileUnlocked(key, filePath, output, journal);
} finally {
release?.();
}
}
export async function readGameDataRenderMutation(form: FormData): Promise<{
@@ -193,6 +235,45 @@ export async function readGameDataRenderMutation(form: FormData): Promise<{
};
}
type ManagedImageRow = Record<string, unknown>;
type CurrentRenderPathLock = {
current: ManagedImageRow | null;
filePath: string;
release?: () => void;
};
async function lockCurrentGameDataRender(
database: Database,
sourceType: string,
entityId: string,
initialStorageKey: string,
journal?: MediaMutationJournal,
): Promise<CurrentRenderPathLock> {
let filePath = storagePath(initialStorageKey);
let release: (() => void) | undefined;
for (;;) {
if (journal) await journal.lockPath(filePath);
else release = await acquireMediaPathLock(filePath);
const current = (await (
await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).get(sourceType, entityId)) as ManagedImageRow | null;
if (!current) return { current: null, filePath, release };
if (typeof current.storage_key !== 'string') {
release?.();
throw new Error('Invalid managed image storage key');
}
const currentPath = storagePath(current.storage_key);
if (path.resolve(currentPath) === path.resolve(filePath))
return { current, filePath: currentPath, release };
release?.();
release = undefined;
filePath = currentPath;
}
}
export async function applyGameDataRender(
database: Database,
type: GameDataRenderType,
@@ -204,6 +285,7 @@ export async function applyGameDataRender(
processed?: { output: Buffer; width: number; height: number };
},
actorId: string,
journal?: MediaMutationJournal,
) {
const sourceType = `${type}_render`;
const existing = await (
@@ -214,12 +296,52 @@ export async function applyGameDataRender(
if (!mutation.file && !mutation.remove) return existing ?? null;
if (mutation.remove) {
if (!existing) return null;
await (
await database.prepare('DELETE FROM managed_images WHERE id=?')
).run(existing.id);
if (typeof existing.storage_key === 'string')
await rm(storagePath(existing.storage_key), { force: true });
return null;
if (typeof existing.storage_key !== 'string') {
await (
await database.prepare('DELETE FROM managed_images WHERE id=?')
).run(existing.id);
return null;
}
let lock: CurrentRenderPathLock | undefined;
let transactionStarted = false;
let committed = false;
try {
if (!journal) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
}
lock = await lockCurrentGameDataRender(
database,
sourceType,
entityId,
existing.storage_key,
journal,
);
const activeLock = lock;
if (activeLock.current) {
if (journal) {
const previous = await captureMediaFile(activeLock.filePath);
journal.register(() =>
restoreMediaFile(activeLock.filePath, previous),
);
}
await (
await database.prepare('DELETE FROM managed_images WHERE id=?')
).run(activeLock.current.id);
await rm(activeLock.filePath, { force: true });
}
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return null;
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
lock?.release?.();
}
}
const processed =
mutation.processed ??
@@ -242,65 +364,137 @@ export async function applyGameDataRender(
specialization_node: 'Specializations',
};
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
if (existing) {
await writeFile(
storagePath(String(existing.storage_key)),
processed.output,
{ mode: 0o600 },
);
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
if (existing && typeof existing.storage_key !== 'string')
throw new Error('Invalid managed image storage key');
if (existing && typeof existing.storage_key === 'string') {
let lock: CurrentRenderPathLock | undefined;
let transactionStarted = false;
let committed = false;
try {
if (!journal) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
}
lock = await lockCurrentGameDataRender(
database,
sourceType,
entityId,
existing.storage_key,
journal,
);
const activeLock = lock;
if (activeLock.current) {
if (journal) {
const previous = await captureMediaFile(activeLock.filePath);
journal.register(() =>
restoreMediaFile(activeLock.filePath, previous),
);
}
await writeFile(activeLock.filePath, processed.output, {
mode: 0o600,
});
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
).run(
category[type],
tags,
mutation.file!.name,
processed.output.length,
processed.width,
processed.height,
now,
activeLock.current.id,
);
const result =
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(activeLock.current.id)) ?? null;
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return result;
}
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
lock?.release?.();
}
}
const id = randomUUID();
const storageKey = `library-${id}.webp`;
const filePath = storagePath(storageKey);
let release: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
try {
if (!journal) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
release = await acquireMediaPathLock(filePath);
} else await journal.lockPath(filePath);
await saveManagedImageFileUnlocked(
storageKey,
filePath,
processed.output,
journal,
);
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
id,
name,
category[type],
tags,
0,
0,
storageKey,
mutation.file!.name,
processed.output.length,
processed.width,
processed.height,
sourceType,
entityId,
actorId,
now,
now,
existing.id,
);
return (
const result =
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(existing.id)) ?? null
);
).get(id)) ?? null;
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return result;
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
release?.();
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
id,
name,
category[type],
tags,
0,
0,
storageKey,
mutation.file!.name,
processed.output.length,
processed.width,
processed.height,
sourceType,
entityId,
actorId,
now,
now,
);
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id)) ?? null
);
}
export async function deleteManagedImage(database: Database, id: string) {
export async function deleteManagedImage(
database: Database,
id: string,
): Promise<{
image: Record<string, unknown>;
sourceIcon: Record<string, unknown> | null;
} | null> {
const image = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id);
@@ -310,64 +504,98 @@ export async function deleteManagedImage(database: Database, id: string) {
'Managed image is missing its storage key.',
);
const sourceType =
typeof image.source_entity_type === 'string'
? image.source_entity_type
: null;
const sourceId =
typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError(
'This image has an incomplete source assignment and cannot be deleted safely.',
);
}
if (
sourceType &&
!GAME_ICON_TYPES.includes(sourceType as GameDataIconType) &&
!GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)
) {
throw new ManagedImageDeletionError(
'This image has an unsupported source assignment and cannot be deleted safely.',
);
}
const sourceIcon =
sourceType &&
sourceId &&
GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
? ((await (
await database.prepare(
'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(sourceType, sourceId)) ?? null)
: null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
throw new ManagedImageDeletionError(
'The library image and its source assignment reference different files. Resolve the assignment before deleting.',
);
}
await database.exec('BEGIN');
const filePath = storagePath(image.storage_key);
let release: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
let retry = false;
let deleted: {
image: typeof image;
sourceIcon: typeof image | null;
} | null = null;
try {
if (sourceIcon && sourceType && sourceId) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
release = await acquireMediaPathLock(filePath);
const current = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id);
if (!current) {
await database.exec('COMMIT');
committed = true;
return null;
}
if (
current.storage_key !== image.storage_key ||
current.source_entity_type !== image.source_entity_type ||
current.source_entity_id !== image.source_entity_id
) {
retry = true;
} else {
const sourceType =
typeof current.source_entity_type === 'string'
? current.source_entity_type
: null;
const sourceId =
typeof current.source_entity_id === 'string'
? current.source_entity_id
: null;
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError(
'This image has an incomplete source assignment and cannot be deleted safely.',
);
}
if (
sourceType &&
!GAME_ICON_TYPES.includes(sourceType as GameDataIconType) &&
!GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)
) {
throw new ManagedImageDeletionError(
'This image has an unsupported source assignment and cannot be deleted safely.',
);
}
const sourceIcon =
sourceType &&
sourceId &&
GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
? ((await (
await database.prepare(
'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(sourceType, sourceId)) ?? null)
: null;
if (sourceIcon && sourceIcon.storage_key !== current.storage_key) {
throw new ManagedImageDeletionError(
'The library image and its source assignment reference different files. Resolve the assignment before deleting.',
);
}
if (sourceIcon && sourceType && sourceId) {
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?',
)
).run(sourceType, sourceId, current.storage_key);
}
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?',
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
)
).run(sourceType, sourceId, image.storage_key);
).run(id, current.storage_key);
await rm(filePath, { force: true });
deleted = { image: current, sourceIcon };
}
await (
await database.prepare(
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
)
).run(id, image.storage_key);
await database.exec('COMMIT');
committed = true;
} catch (error) {
await database.exec('ROLLBACK').catch(() => undefined);
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
release?.();
}
await rm(storagePath(image.storage_key), { force: true });
return { image, sourceIcon };
return retry ? deleteManagedImage(database, id) : deleted;
}
export async function readManagedImage(database: Database, id: string) {
+311
View File
@@ -0,0 +1,311 @@
import {
parseMechanicAuthoredSource,
type MechanicAuthoredOwner,
type MechanicAuthoredSource,
} from '$lib/calculations/mechanic-authored-description';
import { hashMechanicAuthoredSource } from '$lib/calculations/mechanic-lifecycle';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
export type MechanicAuthoredSourceRecord = {
id: string;
source: MechanicAuthoredSource;
fingerprint: string;
createdAt: string;
updatedAt: string;
};
export type SaveMechanicAuthoredSourceOptions = {
/** Undefined is reserved for migration and trusted repair code. */
expectedSourceFingerprint?: string | null;
};
export class MechanicSourceFingerprintConflictError extends Error {
readonly expectedFingerprint: string | null;
readonly currentFingerprint: string | null;
constructor(
expectedFingerprint: string | null,
currentFingerprint: string | null,
) {
super('The authored mechanic changed after this editor was loaded.');
this.name = 'MechanicSourceFingerprintConflictError';
this.expectedFingerprint = expectedFingerprint;
this.currentFingerprint = currentFingerprint;
}
}
type SourceRow = Record<string, unknown>;
type VariantRow = Record<string, unknown>;
type SlotRow = Record<string, unknown>;
function ownerFromRow(row: SourceRow): MechanicAuthoredOwner {
const type = String(row.owner_type);
const id = String(row.owner_id);
if (type === 'talent') {
return {
type,
id,
scope: row.talent_scope === 'exotic' ? 'exotic' : 'standard',
...(Number(row.talent_has_tier_ii ?? 0) === 1
? { hasTierII: true }
: {}),
};
}
if (
type === 'os_protocol_talent' ||
type === 'skill_chip_talent' ||
type === 'specialization_tree_rank'
) {
return { type, id };
}
throw new Error(`Unsupported authored mechanic owner type "${type}".`);
}
function sourceFromRows(
row: SourceRow,
variants: VariantRow[],
slots: SlotRow[],
): MechanicAuthoredSource {
const owner = ownerFromRow(row);
const source: MechanicAuthoredSource = {
version: Number(
row.authored_contract_version,
) as MechanicAuthoredSource['version'],
owner,
template: String(row.template),
variants: variants.map((variant) => ({
key: String(variant.variant_key),
materialized: String(variant.materialized),
slots: slots
.filter(
(slot) => String(slot.variant_key) === String(variant.variant_key),
)
.map((slot) => ({
key: String(slot.slot_key),
value: String(slot.display_value),
order: Number(slot.sort_order),
...(slot.unit === null || slot.unit === undefined
? {}
: { unit: String(slot.unit) }),
...(slot.stat_attribute_id === null ||
slot.stat_attribute_id === undefined
? {}
: { attributeId: String(slot.stat_attribute_id) }),
})),
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: String(row.specialization_rank_description),
effectId: String(row.specialization_effect_id),
effectTemplate: String(row.specialization_effect_template),
},
}
: {}),
};
const parsed = parseMechanicAuthoredSource(source);
if (!parsed.ok) {
throw new Error(
`Stored authored mechanic source is invalid: ${parsed.errors
.map((error) => error.message)
.join(' ')}`,
);
}
return parsed.value;
}
async function loadSourceRows(database: Database, row: SourceRow) {
const [variants, slots] = await Promise.all([
(
await database.prepare(
`SELECT * FROM mechanic_authored_variants
WHERE source_id=? ORDER BY sort_order`,
)
).all(row.id),
(
await database.prepare(
`SELECT * FROM mechanic_authored_slots
WHERE source_id=? ORDER BY variant_key,sort_order`,
)
).all(row.id),
]);
return { variants, slots };
}
export async function loadMechanicAuthoredSource(
database: Database,
ownerType: MechanicAuthoredOwner['type'],
ownerId: string,
): Promise<MechanicAuthoredSourceRecord | null> {
const row = await (
await database.prepare(
`SELECT * FROM mechanic_authored_sources
WHERE owner_type=? AND owner_id=?`,
)
).get(ownerType, ownerId);
if (!row) return null;
const { variants, slots } = await loadSourceRows(database, row);
return {
id: String(row.id),
source: sourceFromRows(row, variants, slots),
fingerprint: String(row.source_fingerprint),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
};
}
export async function loadMechanicAuthoredSourceById(
database: Database,
sourceId: string,
): Promise<MechanicAuthoredSourceRecord | null> {
const row = await (
await database.prepare(
'SELECT * FROM mechanic_authored_sources WHERE id=?',
)
).get(sourceId);
if (!row) return null;
const { variants, slots } = await loadSourceRows(database, row);
return {
id: String(row.id),
source: sourceFromRows(row, variants, slots),
fingerprint: String(row.source_fingerprint),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
};
}
/**
* Replaces one mutable authored source inside the caller's active owner
* transaction. Model revisions and selections are intentionally untouched.
*/
export async function saveMechanicAuthoredSourceInTransaction(
database: Database,
source: MechanicAuthoredSource,
options: SaveMechanicAuthoredSourceOptions = {},
): Promise<MechanicAuthoredSourceRecord> {
const parsed = parseMechanicAuthoredSource(source);
if (!parsed.ok) {
throw new Error(parsed.errors.map((error) => error.message).join(' '));
}
const fingerprintResult = await hashMechanicAuthoredSource(parsed.value);
if (!fingerprintResult.ok) {
throw new Error(
fingerprintResult.errors.map((error) => error.message).join(' '),
);
}
const existing = await (
await database.prepare(
`SELECT id,source_fingerprint,created_at
FROM mechanic_authored_sources
WHERE owner_type=? AND owner_id=?`,
)
).get(parsed.value.owner.type, parsed.value.owner.id);
const currentFingerprint = existing
? String(existing.source_fingerprint)
: null;
if (
options.expectedSourceFingerprint !== undefined &&
options.expectedSourceFingerprint !== currentFingerprint
) {
throw new MechanicSourceFingerprintConflictError(
options.expectedSourceFingerprint,
currentFingerprint,
);
}
const now = new Date().toISOString();
const sourceId = String(existing?.id ?? randomUUID());
const owner = parsed.value.owner;
await (
await database.prepare(`INSERT INTO mechanic_authored_sources (
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
talent_scope,talent_has_tier_ii,specialization_rank_description,
specialization_effect_id,specialization_effect_template,created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(owner_type,owner_id) DO UPDATE SET
template=excluded.template,
authored_contract_version=excluded.authored_contract_version,
source_fingerprint=excluded.source_fingerprint,
talent_scope=excluded.talent_scope,
talent_has_tier_ii=excluded.talent_has_tier_ii,
specialization_rank_description=excluded.specialization_rank_description,
specialization_effect_id=excluded.specialization_effect_id,
specialization_effect_template=excluded.specialization_effect_template,
updated_at=excluded.updated_at`)
).run(
sourceId,
owner.type,
owner.id,
parsed.value.template,
parsed.value.version,
fingerprintResult.fingerprint,
owner.type === 'talent' ? owner.scope : null,
owner.type === 'talent' ? (owner.hasTierII ? 1 : 0) : null,
parsed.value.specialization?.rankDescription ?? null,
parsed.value.specialization?.effectId ?? null,
parsed.value.specialization?.effectTemplate ?? null,
String(existing?.created_at ?? now),
now,
);
await (
await database.prepare(
'DELETE FROM mechanic_authored_slots WHERE source_id=?',
)
).run(sourceId);
await (
await database.prepare(
'DELETE FROM mechanic_authored_variants WHERE source_id=?',
)
).run(sourceId);
for (const [variantIndex, variant] of parsed.value.variants.entries()) {
await (
await database.prepare(`INSERT INTO mechanic_authored_variants
(source_id,variant_key,materialized,sort_order) VALUES (?,?,?,?)`)
).run(sourceId, variant.key, variant.materialized, variantIndex);
for (const slot of variant.slots) {
await (
await database.prepare(`INSERT INTO mechanic_authored_slots
(source_id,variant_key,slot_key,display_value,sort_order,unit,stat_attribute_id)
VALUES (?,?,?,?,?,?,?)`)
).run(
sourceId,
variant.key,
slot.key,
slot.value,
slot.order,
slot.unit ?? null,
slot.attributeId ?? null,
);
}
}
return {
id: sourceId,
source: parsed.value,
fingerprint: fingerprintResult.fingerprint,
createdAt: String(existing?.created_at ?? now),
updatedAt: now,
};
}
export function mechanicAuthoredSourceSnapshot(
record: MechanicAuthoredSourceRecord | null,
) {
if (!record) return null;
return {
id: record.id,
owner: record.source.owner,
template: record.source.template,
authoredContractVersion: record.source.version,
sourceFingerprint: record.fingerprint,
variants: record.source.variants,
...(record.source.specialization
? { specialization: record.source.specialization }
: {}),
};
}
+634
View File
@@ -0,0 +1,634 @@
import {
parseConditionalMechanicDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft,
} from '$lib/calculations/mechanic-editor';
import {
MECHANIC_PROGRAM_HASH_VERSION,
hashMechanicProgramVariants,
type MechanicSimulationReadiness,
} from '$lib/calculations/mechanic-lifecycle';
import type { MechanicPublicProgramVariant } from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import type { MechanicAuthoredSourceRecord } from './mechanic-authored-sources';
export const MECHANIC_SELECTION_INTENTS = ['preserve', 'save', 'clear'] as const;
export type MechanicSelectionIntent =
(typeof MECHANIC_SELECTION_INTENTS)[number];
export type MechanicModelRevisionRecord = {
id: string;
sourceId: string;
revisionNumber: number;
sourceFingerprint: string;
mechanicContractVersion: number;
programHash: string;
simulationReadiness: MechanicSimulationReadiness;
programName: string;
draft: ConditionalMechanicDraftV1;
variants: MechanicPublicProgramVariant[];
origin: string;
actorId: string | null;
createdAt: string;
};
export type SaveMechanicModelRevisionOptions = {
programName: string;
origin: string;
actorId: string | null;
};
type Row = Record<string, unknown>;
type ClauseDraft = ConditionalMechanicDraftV1['clauses'][number];
type StoredVariant = { key: string; label: string };
const parseJson = <T>(value: unknown, label: string): T => {
try {
return JSON.parse(String(value)) as T;
} catch {
throw new Error(`Stored mechanic ${label} JSON is invalid.`);
}
};
function variantLabel(key: string) {
if (key === 'I') return 'Tier I';
if (key === 'II') return 'Tier II';
if (key === 'minimum') return 'Minimum';
if (key === 'maximum') return 'Maximum';
return 'Base';
}
function sourceVariants(source: MechanicAuthoredSourceRecord): StoredVariant[] {
return source.source.variants.map((variant) => ({
key: variant.key,
label: variantLabel(variant.key),
}));
}
function parseDraft(
draft: ConditionalMechanicDraftV1,
variants: StoredVariant[],
programName: string,
) {
const parsed = parseConditionalMechanicDraft(JSON.stringify(draft), {
variantKeys: variants.map((variant) => variant.key),
programName,
});
if (!parsed.ok) throw new Error(parsed.errors.join(' '));
return {
draft: parsed.value.draft,
variants: variants.map((variant) => ({
...variant,
program: parsed.value.programs[variant.key],
})),
};
}
function assertVariantMetadataConsistent(
effectKey: string,
rows: Row[],
columns: readonly string[],
) {
const first = rows[0];
for (const row of rows.slice(1)) {
const mismatch = columns.find(
(column) => (row[column] ?? null) !== (first[column] ?? null),
);
if (mismatch) {
throw new Error(
`Stored mechanic outcome "${effectKey}" has inconsistent ${mismatch} metadata.`,
);
}
}
}
const outcomeMetadataColumns = [
'label',
'outcome_kind',
'effect_scope',
'value_basis',
'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'application_phase',
'combat_timing',
'stat_attribute_id',
'state_pool_id',
'per_stack',
'min_state_value',
'max_state_value',
'targeting_json',
'probability_percent',
'sort_order',
] as const;
function draftFromRows(
revision: Row,
poolRows: Row[],
clauseRows: Row[],
outcomeRows: Row[],
variants: StoredVariant[],
): ConditionalMechanicDraftV1 {
const poolKeyById = new Map(
poolRows.map((row) => [String(row.id), String(row.pool_key)]),
);
const pools = poolRows.map(
(row): ConditionalMechanicPoolDraft => ({
key: String(row.pool_key),
label: String(row.label),
stateKind: String(
row.state_kind,
) as ConditionalMechanicPoolDraft['stateKind'],
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
initial: Number(row.initial_value),
...(row.minimum_value === null ? {} : { min: Number(row.minimum_value) }),
...(row.maximum_value === null ? {} : { max: Number(row.maximum_value) }),
...(row.best_case_value === null
? {}
: { bestCaseValue: Number(row.best_case_value) }),
...(row.duration_seconds === null
? {}
: { expiresAfterSeconds: Number(row.duration_seconds) }),
...(row.duration_variants_json === null
? {}
: {
expiresAfterSecondsVariants: parseJson<Record<string, number>>(
row.duration_variants_json,
'pool duration variants',
),
}),
}),
);
const outcomesByClause = new Map<string, Row[]>();
for (const row of outcomeRows) {
const clauseId = String(row.clause_id);
outcomesByClause.set(clauseId, [
...(outcomesByClause.get(clauseId) ?? []),
row,
]);
}
const clauses = clauseRows.map((clause) => {
const grouped = new Map<string, Row[]>();
for (const outcome of outcomesByClause.get(String(clause.id)) ?? []) {
const effectKey = String(outcome.effect_key);
grouped.set(effectKey, [...(grouped.get(effectKey) ?? []), outcome]);
}
const outcomes = [...grouped.entries()]
.sort(
([, left], [, right]) =>
Number(left[0].sort_order) - Number(right[0].sort_order),
)
.map(([effectKey, rows]): ConditionalMechanicOutcomeDraft => {
assertVariantMetadataConsistent(
effectKey,
rows,
outcomeMetadataColumns,
);
const first = rows[0];
const statePool =
first.state_pool_id === null
? undefined
: poolKeyById.get(String(first.state_pool_id));
return {
key: effectKey,
label: String(first.label),
phase: String(
first.application_phase,
) as ConditionalMechanicOutcomeDraft['phase'],
kind: String(
first.outcome_kind,
) as ConditionalMechanicOutcomeDraft['kind'],
scope: String(
first.effect_scope,
) as ConditionalMechanicOutcomeDraft['scope'],
basis: String(
first.value_basis,
) as ConditionalMechanicOutcomeDraft['basis'],
...(first.resource_kind === null
? {}
: {
resource: String(
first.resource_kind,
) as ConditionalMechanicOutcomeDraft['resource'],
}),
...(first.delivery_mode === 'continuous'
? {
delivery: 'continuous' as const,
durationSeconds: Number(first.delivery_duration_seconds),
}
: {}),
...(first.combat_timing === 'pre_event'
? { combatTiming: 'pre_event' as const }
: {}),
...(first.stat_attribute_id === null
? {}
: { attributeId: String(first.stat_attribute_id) }),
...(statePool ? { statePool } : {}),
perStack: Number(first.per_stack) === 1,
...(first.min_state_value === null
? {}
: { minStacks: Number(first.min_state_value) }),
...(first.max_state_value === null
? {}
: { maxStacks: Number(first.max_state_value) }),
...(first.targeting_json === null
? {}
: {
targeting: parseJson(
first.targeting_json,
'outcome targeting',
),
}),
...(first.probability_percent === null
? {}
: { probabilityPercent: Number(first.probability_percent) }),
variants: Object.fromEntries(
variants.map((variant) => {
const row = rows.find(
(candidate) => candidate.variant_key === variant.key,
);
if (!row) {
throw new Error(
`Stored mechanic outcome "${effectKey}" is missing variant ${variant.key}.`,
);
}
return [
variant.key,
{
value: Number(row.value),
...(row.value_expression_json === null
? {}
: {
expression: parseJson(
row.value_expression_json,
'outcome expression',
),
}),
},
];
}),
),
};
});
const result: ClauseDraft = {
key: String(clause.clause_key),
...(clause.label === null ? {} : { label: String(clause.label) }),
event: String(clause.event_kind) as ConditionalMechanicDraftV1['clauses'][number]['event'],
...(clause.recipe_key === null
? {}
: { recipeKey: String(clause.recipe_key) }),
...(clause.conditions_json === null
? {}
: {
if: parseJson<NonNullable<ClauseDraft['if']>>(
clause.conditions_json,
'clause conditions',
),
}),
reducer: parseJson<ClauseDraft['reducer']>(
clause.reducer_json,
'clause reducer',
),
...(clause.weight_expression_json === null
? {}
: {
weight: parseJson<NonNullable<ClauseDraft['weight']>>(
clause.weight_expression_json,
'clause weight',
),
}),
...(clause.cooldown_seconds === null
? {}
: { cooldownSeconds: Number(clause.cooldown_seconds) }),
...(clause.proc_chance_percent === null
? {}
: { procChancePercent: Number(clause.proc_chance_percent) }),
...(clause.requires_assigned_ability_active === null
? {}
: {
requiresAssignedAbilityActive:
Number(clause.requires_assigned_ability_active) === 1,
}),
mutations: parseJson<ClauseDraft['mutations']>(
clause.mutations_json,
'clause mutations',
),
outcomes,
};
return result;
});
return {
version: 1,
simulationReadiness: String(
revision.simulation_readiness,
) as MechanicSimulationReadiness,
scenarioInputs: parseJson(
revision.scenario_inputs_json,
'Scenario Inputs',
),
pools,
clauses,
};
}
async function revisionFromRow(
database: Database,
revision: Row,
): Promise<MechanicModelRevisionRecord> {
const [pools, clauses, outcomes] = await Promise.all([
(
await database.prepare(
`SELECT * FROM mechanic_model_revision_pools
WHERE revision_id=? ORDER BY sort_order`,
)
).all(revision.id),
(
await database.prepare(
`SELECT * FROM mechanic_model_revision_clauses
WHERE revision_id=? ORDER BY sort_order`,
)
).all(revision.id),
(
await database.prepare(
`SELECT * FROM mechanic_model_revision_outcomes
WHERE revision_id=? ORDER BY sort_order,variant_key`,
)
).all(revision.id),
]);
const storedVariants = parseJson<StoredVariant[]>(
revision.source_variants_json,
'source variants',
);
const draft = draftFromRows(revision, pools, clauses, outcomes, storedVariants);
const parsed = parseDraft(draft, storedVariants, String(revision.program_name));
return {
id: String(revision.id),
sourceId: String(revision.source_id),
revisionNumber: Number(revision.revision_number),
sourceFingerprint: String(revision.source_fingerprint),
mechanicContractVersion: Number(revision.mechanic_contract_version),
programHash: String(revision.program_hash),
simulationReadiness: String(
revision.simulation_readiness,
) as MechanicSimulationReadiness,
programName: String(revision.program_name),
draft: parsed.draft,
variants: parsed.variants,
origin: String(revision.origin),
actorId:
revision.actor_id === null || revision.actor_id === undefined
? null
: String(revision.actor_id),
createdAt: String(revision.created_at),
};
}
export async function loadMechanicModelRevision(
database: Database,
revisionId: string,
): Promise<MechanicModelRevisionRecord | null> {
const revision = await (
await database.prepare(
'SELECT * FROM mechanic_model_revisions WHERE id=?',
)
).get(revisionId);
return revision ? revisionFromRow(database, revision) : null;
}
export async function loadSelectedMechanicModelRevision(
database: Database,
sourceId: string,
): Promise<MechanicModelRevisionRecord | null> {
const revision = await (
await database.prepare(`SELECT revision.*
FROM mechanic_model_selections selection
JOIN mechanic_model_revisions revision
ON revision.id=selection.revision_id
AND revision.source_id=selection.source_id
WHERE selection.source_id=?`)
).get(sourceId);
return revision ? revisionFromRow(database, revision) : null;
}
export async function selectMechanicModelRevisionInTransaction(
database: Database,
sourceId: string,
revisionId: string,
) {
await (
await database.prepare(`INSERT INTO mechanic_model_selections
(source_id,revision_id,updated_at) VALUES (?,?,?)
ON CONFLICT(source_id) DO UPDATE SET
revision_id=excluded.revision_id,
updated_at=excluded.updated_at`)
).run(sourceId, revisionId, new Date().toISOString());
}
export async function clearMechanicModelSelectionInTransaction(
database: Database,
sourceId: string,
) {
await (
await database.prepare(
'DELETE FROM mechanic_model_selections WHERE source_id=?',
)
).run(sourceId);
}
/** Appends and selects a normalized model revision in the caller transaction. */
export async function saveMechanicModelRevisionInTransaction(
database: Database,
source: MechanicAuthoredSourceRecord,
draft: ConditionalMechanicDraftV1,
options: SaveMechanicModelRevisionOptions,
): Promise<{ created: boolean; revision: MechanicModelRevisionRecord }> {
const storedVariants = sourceVariants(source);
const parsed = parseDraft(draft, storedVariants, options.programName);
const programHash = await hashMechanicProgramVariants({
simulationReadiness: parsed.draft.simulationReadiness,
variants: parsed.variants,
});
const selected = await loadSelectedMechanicModelRevision(database, source.id);
if (
selected?.sourceFingerprint === source.fingerprint &&
selected.mechanicContractVersion === MECHANIC_PROGRAM_HASH_VERSION &&
selected.programHash === programHash
) {
return { created: false, revision: selected };
}
const latest = await (
await database.prepare(
`SELECT COALESCE(MAX(revision_number),0) AS revision_number
FROM mechanic_model_revisions WHERE source_id=?`,
)
).get(source.id);
const revisionNumber = Number(latest?.revision_number ?? 0) + 1;
const revisionId = randomUUID();
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO mechanic_model_revisions (
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,actor_id,created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
revisionId,
source.id,
revisionNumber,
source.fingerprint,
MECHANIC_PROGRAM_HASH_VERSION,
programHash,
parsed.draft.simulationReadiness,
JSON.stringify(parsed.draft.scenarioInputs),
JSON.stringify(storedVariants),
options.programName,
options.origin,
options.actorId,
now,
);
const poolIds = new Map<string, string>();
for (const [index, pool] of parsed.draft.pools.entries()) {
const poolId = randomUUID();
poolIds.set(pool.key, poolId);
await (
await database.prepare(`INSERT INTO mechanic_model_revision_pools (
id,revision_id,pool_key,label,state_kind,state_scope,initial_value,
minimum_value,maximum_value,best_case_value,duration_seconds,
duration_variants_json,sort_order
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
poolId,
revisionId,
pool.key,
pool.label,
pool.stateKind,
pool.scope,
pool.initial,
pool.min ?? null,
pool.max ?? null,
pool.bestCaseValue ?? null,
pool.expiresAfterSeconds ?? null,
pool.expiresAfterSecondsVariants === undefined
? null
: JSON.stringify(pool.expiresAfterSecondsVariants),
index,
);
}
for (const [clauseIndex, clause] of parsed.draft.clauses.entries()) {
const clauseId = randomUUID();
await (
await database.prepare(`INSERT INTO mechanic_model_revision_clauses (
id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json,
reducer_json,weight_expression_json,mutations_json,cooldown_seconds,
proc_chance_percent,requires_assigned_ability_active,sort_order
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
clauseId,
revisionId,
clause.key,
clause.label ?? null,
clause.event,
clause.recipeKey ?? null,
clause.if === undefined ? null : JSON.stringify(clause.if),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
JSON.stringify(clause.mutations),
clause.cooldownSeconds ?? null,
clause.procChancePercent ?? null,
clause.requiresAssignedAbilityActive === undefined
? null
: clause.requiresAssignedAbilityActive
? 1
: 0,
clauseIndex,
);
for (const [outcomeIndex, outcome] of clause.outcomes.entries()) {
for (const storedVariant of storedVariants) {
const value = outcome.variants[storedVariant.key];
if (!value) {
throw new Error(
`Mechanic outcome ${outcome.key} is missing variant ${storedVariant.key}.`,
);
}
await (
await database.prepare(`INSERT INTO mechanic_model_revision_outcomes (
id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label,
outcome_kind,effect_scope,value,value_expression_json,value_basis,
resource_kind,delivery_mode,delivery_duration_seconds,application_phase,
combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value,
targeting_json,probability_percent,sort_order
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
revisionId,
clauseId,
outcome.statePool ? (poolIds.get(outcome.statePool) ?? null) : null,
outcome.key,
storedVariant.key,
outcome.label,
outcome.kind,
outcome.scope,
value.value,
value.expression === undefined
? null
: JSON.stringify(value.expression),
outcome.basis,
outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.phase,
outcome.combatTiming ?? 'current',
outcome.attributeId ?? null,
outcome.perStack ? 1 : 0,
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.targeting === undefined
? null
: JSON.stringify(outcome.targeting),
outcome.probabilityPercent ?? null,
outcomeIndex,
);
}
}
}
await selectMechanicModelRevisionInTransaction(
database,
source.id,
revisionId,
);
const revision = await loadMechanicModelRevision(database, revisionId);
if (!revision) throw new Error('The appended mechanic revision was not found.');
return { created: true, revision };
}
export function mechanicModelRevisionSnapshot(
revision: MechanicModelRevisionRecord | null,
) {
if (!revision) return null;
return {
id: revision.id,
revisionNumber: revision.revisionNumber,
sourceFingerprint: revision.sourceFingerprint,
mechanicContractVersion: revision.mechanicContractVersion,
programHash: revision.programHash,
simulationReadiness: revision.simulationReadiness,
scenarioInputs: revision.draft.scenarioInputs,
pools: revision.draft.pools,
clauses: revision.draft.clauses,
origin: revision.origin,
actorId: revision.actorId,
createdAt: revision.createdAt,
};
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import {
parseExpectedOwnerUpdatedAt,
validateMechanicAuthoredTemplate,
validateMechanicOwnerCapabilities,
} from './mechanic-owner-mutation';
const draft = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'primary',
label: 'Primary',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'effect',
label: 'Effect',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
variants: { base: { value: 1 } },
},
],
},
],
});
const withAssignedAbilityBehavior = () => {
const value = draft();
value.clauses[0].requiresAssignedAbilityActive = true;
value.clauses[0].mutations = [
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 1 },
];
value.clauses[0].outcomes[0].scope = 'assigned_ability';
value.clauses[0].if = {
kind: 'exists',
value: { source: 'assigned_ability', path: 'active' },
};
return value;
};
const withProtocolUpgradeReference = () => {
const value = draft();
value.clauses[0].outcomes[0].variants.base.expression = {
op: 'add',
left: { ref: { source: 'variant_value' } },
right: {
ref: { source: 'equipment', path: 'protocolTalentUpgrades' },
},
};
return value;
};
describe('owner optimistic-concurrency token parsing', () => {
it('requires exact bounded tokens for existing owners', () => {
expect(parseExpectedOwnerUpdatedAt('2026-08-02T12:00:00.000Z', true)).toBe(
'2026-08-02T12:00:00.000Z',
);
expect(parseExpectedOwnerUpdatedAt('', false)).toBeNull();
expect(() => parseExpectedOwnerUpdatedAt('', true)).toThrow(/required/);
expect(() => parseExpectedOwnerUpdatedAt(' padded ', true)).toThrow(/invalid/);
expect(() => parseExpectedOwnerUpdatedAt('bad\u0000token', true)).toThrow(
/invalid/,
);
expect(() => parseExpectedOwnerUpdatedAt('x'.repeat(101), true)).toThrow(
/invalid/,
);
});
});
describe('mechanic owner capability validation', () => {
it('rejects assigned-ability behavior for Talent, Protocol, and Specialization owners', () => {
for (const ownerType of [
'talent',
'os_protocol_talent',
'specialization_tree_rank',
] as const) {
expect(
validateMechanicOwnerCapabilities(
ownerType,
withAssignedAbilityBehavior(),
),
).toEqual(
expect.arrayContaining([
expect.stringContaining('assigned ability'),
expect.stringContaining('assigned-ability'),
]),
);
}
});
it('allows the supported owner-specific capability and rejects protocol scaling elsewhere', () => {
expect(
validateMechanicOwnerCapabilities(
'skill_chip_talent',
withAssignedAbilityBehavior(),
),
).toEqual([]);
expect(
validateMechanicOwnerCapabilities(
'os_protocol_talent',
withProtocolUpgradeReference(),
),
).toEqual([]);
for (const ownerType of [
'talent',
'skill_chip_talent',
'specialization_tree_rank',
] as const) {
expect(
validateMechanicOwnerCapabilities(
ownerType,
withProtocolUpgradeReference(),
),
).toEqual(
expect.arrayContaining([
expect.stringContaining('protocolTalentUpgrades'),
]),
);
}
});
it('uses the authored template grammar without requiring an owner source', () => {
expect(
validateMechanicAuthoredTemplate('Gain {1} for {duration}.'),
).toEqual([]);
expect(validateMechanicAuthoredTemplate('Gain {unsupported}.')).toEqual(
expect.arrayContaining([expect.stringContaining('unsupported token')]),
);
expect(validateMechanicAuthoredTemplate('Gain {1.')).toEqual(
expect.arrayContaining([expect.stringContaining('malformed')]),
);
expect(validateMechanicAuthoredTemplate('')).toEqual(
expect.arrayContaining([expect.stringContaining('required')]),
);
});
});
+340
View File
@@ -0,0 +1,340 @@
import {
parseMechanicAuthoredSource,
type MechanicAuthoredOwner,
type MechanicAuthoredSource,
type SpecializationAuthoredInputs,
} from '$lib/calculations/mechanic-authored-description';
import {
parseConditionalMechanicDraft,
type ConditionalMechanicDraftV1,
} from '$lib/calculations/mechanic-editor';
import type { MechanicAttributeLabelResolver } from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import {
mechanicAuthoredSourceSnapshot,
saveMechanicAuthoredSourceInTransaction,
} from './mechanic-authored-sources';
import {
clearMechanicModelSelectionInTransaction,
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
saveMechanicModelRevisionInTransaction,
type MechanicModelRevisionRecord,
type MechanicSelectionIntent,
} from './mechanic-model-revisions';
import {
mechanicPublicProjectionSnapshot,
refreshMechanicPublicProjectionInTransaction,
} from './mechanic-public-projection';
type AuthoredPayload = Pick<
MechanicAuthoredSource,
'version' | 'template' | 'variants'
>;
type MechanicOwnerType = MechanicAuthoredOwner['type'];
const authoredTokenPattern = /\{([^{}]+)\}/g;
const authoredNamedTokens = new Set(['duration', 'max_stacks']);
/**
* Validates the token grammar shared by authored descriptions and reusable
* Specialization combat-effect templates. This intentionally does not require
* an owner or variant payload, so templates can be checked before any source
* exists (including a reusable effect with no rank attachments).
*/
export function validateMechanicAuthoredTemplate(template: unknown): string[] {
const errors: string[] = [];
if (typeof template !== 'string' || !template.trim()) {
errors.push('The authored template is required.');
return errors;
}
if (/[{}]/.test(template.replace(authoredTokenPattern, ''))) {
errors.push('The authored template contains malformed placeholder braces.');
}
for (const match of template.matchAll(authoredTokenPattern)) {
const token = match[1];
if (!authoredNamedTokens.has(token) && !/^[1-9]\d*$/.test(token)) {
errors.push(
`The authored template contains unsupported token {${token}}.`,
);
}
}
return errors;
}
/**
* Returns owner-specific restrictions for a typed conditional mechanic draft.
* The draft has already passed its structural parser, but this second walk is
* required because callers outside a form action can still supply forged JSON.
*/
export function validateMechanicOwnerCapabilities(
ownerType: MechanicOwnerType,
draft: ConditionalMechanicDraftV1,
): string[] {
const errors: string[] = [];
const allowsAssignedAbility = ownerType === 'skill_chip_talent';
const allowsProtocolUpgradeScaling = ownerType === 'os_protocol_talent';
const add = (message: string) => {
if (!errors.includes(message)) errors.push(message);
};
if (!allowsAssignedAbility) {
draft.clauses.forEach((clause, clauseIndex) => {
const path = `Clause ${clauseIndex + 1}`;
if (clause.requiresAssignedAbilityActive === true) {
add(
`${path} cannot require an active assigned ability for this owner.`,
);
}
clause.mutations.forEach((mutation, mutationIndex) => {
if (
mutation.kind === 'extend_duration' &&
mutation.target === 'assigned_ability'
) {
add(
`${path} mutation ${mutationIndex + 1} cannot extend assigned-ability duration for this owner.`,
);
}
});
clause.outcomes.forEach((outcome, outcomeIndex) => {
if (outcome.scope === 'assigned_ability') {
add(
`${path} outcome ${outcomeIndex + 1} cannot use the assigned-ability scope for this owner.`,
);
}
});
});
}
// Walk every typed expression, predicate, reducer, and nested draft value so
// references cannot be hidden in a branch that the editor does not expose.
const visit = (value: unknown, path: string) => {
if (Array.isArray(value)) {
value.forEach((item, index) => visit(item, `${path}[${index}]`));
return;
}
if (typeof value !== 'object' || value === null) return;
const record = value as Record<string, unknown>;
if (!allowsAssignedAbility && record.source === 'assigned_ability') {
add(`${path} cannot reference assigned-ability state for this owner.`);
}
if (
!allowsProtocolUpgradeScaling &&
record.source === 'equipment' &&
record.path === 'protocolTalentUpgrades'
) {
add(
`${path} cannot reference equipment.protocolTalentUpgrades for this owner.`,
);
}
Object.entries(record).forEach(([key, nested]) =>
visit(nested, `${path}.${key}`),
);
};
visit(draft, 'Conditional mechanic');
return errors;
}
export type MechanicOwnerSelection =
| { intent: 'preserve' }
| { intent: 'clear' }
| { intent: 'save'; draft: ConditionalMechanicDraftV1 };
export type PersistMechanicOwnerStateInput = {
source: MechanicAuthoredSource;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
programName: string;
origin: string;
actorId: string | null;
resolveAttributeLabel?: MechanicAttributeLabelResolver;
};
export type PersistedMechanicOwnerState = {
source: Awaited<ReturnType<typeof saveMechanicAuthoredSourceInTransaction>>;
selectedRevision: MechanicModelRevisionRecord | null;
revisionCreated: boolean;
projection: Awaited<
ReturnType<typeof refreshMechanicPublicProjectionInTransaction>
>;
};
function parseJsonObject(
raw: FormDataEntryValue | string | null,
label: string,
) {
if (typeof raw !== 'string' || !raw) throw new Error(`${label} is required.`);
try {
const value: unknown = JSON.parse(raw);
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error();
}
return value as Record<string, unknown>;
} catch {
throw new Error(`${label} must be valid JSON.`);
}
}
export function parseMechanicAuthoredSourcePayload(
raw: FormDataEntryValue | string | null,
owner: MechanicAuthoredOwner,
specialization?: SpecializationAuthoredInputs,
): MechanicAuthoredSource {
const payload = parseJsonObject(raw, 'The authored description');
const allowed = new Set(['version', 'template', 'variants']);
if (Object.keys(payload).some((key) => !allowed.has(key))) {
throw new Error('The authored description contains an unsupported field.');
}
const parsed = parseMechanicAuthoredSource({
...payload,
owner,
...(specialization ? { specialization } : {}),
});
if (!parsed.ok) {
throw new Error(parsed.errors.map((error) => error.message).join(' '));
}
return parsed.value;
}
export function parseExpectedSourceFingerprint(
raw: FormDataEntryValue | string | null,
): string | null {
if (raw === null || raw === '') return null;
const value = String(raw);
if (!/^[0-9a-f]{64}$/.test(value)) {
throw new Error('The expected authored-source fingerprint is invalid.');
}
return value;
}
export function parseExpectedOwnerUpdatedAt(
raw: FormDataEntryValue | string | null,
required: boolean,
): string | null {
if (raw === null || raw === '') {
if (required)
throw new Error('The expected owner version is required. Reload the editor and try again.');
return null;
}
const value = String(raw);
if (
value !== value.trim() ||
value.length > 100 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
) {
throw new Error('The expected owner version is invalid.');
}
return value;
}
export function parseMechanicSelectionIntent(
rawIntent: FormDataEntryValue | string | null,
rawDraft: FormDataEntryValue | string | null,
options: {
variantKeys: readonly string[];
allowedAttributeIds?: ReadonlySet<string>;
allowedRecipeKeys?: ReadonlySet<string>;
programName: string;
ownerType?: MechanicOwnerType;
},
): MechanicOwnerSelection {
const intent = String(rawIntent ?? '') as MechanicSelectionIntent;
if (intent === 'preserve' || intent === 'clear') return { intent };
if (intent !== 'save') {
throw new Error('Choose whether to preserve, save, or clear the model.');
}
const parsed = parseConditionalMechanicDraft(rawDraft, {
variantKeys: options.variantKeys,
allowedAttributeIds: options.allowedAttributeIds,
allowedRecipeKeys: options.allowedRecipeKeys,
programName: options.programName,
});
if (!parsed.ok) throw new Error(parsed.errors.join(' '));
const capabilityErrors = validateMechanicOwnerCapabilities(
options.ownerType ?? 'talent',
parsed.value.draft,
);
if (capabilityErrors.length) throw new Error(capabilityErrors.join(' '));
return { intent, draft: parsed.value.draft };
}
/**
* Persists authored source, selection intent, immutable revision, and public
* projection inside the caller's active owner transaction.
*/
export async function persistMechanicOwnerStateInTransaction(
database: Database,
input: PersistMechanicOwnerStateInput,
): Promise<PersistedMechanicOwnerState> {
if (input.selection.intent === 'save') {
const capabilityErrors = validateMechanicOwnerCapabilities(
input.source.owner.type,
input.selection.draft,
);
if (capabilityErrors.length) throw new Error(capabilityErrors.join(' '));
}
const source = await saveMechanicAuthoredSourceInTransaction(
database,
input.source,
{ expectedSourceFingerprint: input.expectedSourceFingerprint },
);
let revisionCreated = false;
let selectedRevision: MechanicModelRevisionRecord | null;
let forceAuthoredFallback = false;
if (input.selection.intent === 'save') {
const saved = await saveMechanicModelRevisionInTransaction(
database,
source,
input.selection.draft,
{
programName: input.programName,
origin: input.origin,
actorId: input.actorId,
},
);
revisionCreated = saved.created;
selectedRevision = saved.revision;
} else if (input.selection.intent === 'clear') {
await clearMechanicModelSelectionInTransaction(database, source.id);
selectedRevision = null;
} else {
selectedRevision = await loadSelectedMechanicModelRevision(
database,
source.id,
);
if (selectedRevision) {
forceAuthoredFallback =
validateMechanicOwnerCapabilities(
source.source.owner.type,
selectedRevision.draft,
).length > 0;
}
}
const projection = await refreshMechanicPublicProjectionInTransaction(
database,
source,
{
resolveAttributeLabel: input.resolveAttributeLabel,
forceAuthoredFallback,
},
);
return { source, selectedRevision, revisionCreated, projection };
}
export function mechanicOwnerStateSnapshot(
state: PersistedMechanicOwnerState | null,
) {
if (!state) return null;
return {
authoredSource: mechanicAuthoredSourceSnapshot(state.source),
selectedRevision: mechanicModelRevisionSnapshot(state.selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
state.selectedRevision,
state.projection.variants,
),
};
}
+598
View File
@@ -0,0 +1,598 @@
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
MechanicSourceFingerprintConflictError,
loadMechanicAuthoredSource,
saveMechanicAuthoredSourceInTransaction,
} from './mechanic-authored-sources';
import {
clearMechanicModelSelectionInTransaction,
loadSelectedMechanicModelRevision,
saveMechanicModelRevisionInTransaction,
} from './mechanic-model-revisions';
import {
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
} from './mechanic-owner-mutation';
import {
loadMechanicPublicProjection,
refreshMechanicPublicProjectionInTransaction,
} from './mechanic-public-projection';
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(
new URL('../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
const talentSource = (secondTierDamage = '20'): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
template: 'After {1} hits, gain {2}% weapon damage for {duration} seconds.',
variants: [
{
key: 'I',
materialized: 'After 10 hits, gain 15% weapon damage for 5 seconds.',
slots: [
{ key: '1', value: '10', order: 0 },
{
key: '2',
value: '15',
order: 1,
unit: '%',
attributeId: 'weapon-damage',
},
{ key: 'duration', value: '5', order: 2, unit: 'seconds' },
],
},
{
key: 'II',
materialized: `After 8 hits, gain ${secondTierDamage}% weapon damage for 7 seconds.`,
slots: [
{ key: '1', value: '8', order: 0 },
{
key: '2',
value: secondTierDamage,
order: 1,
unit: '%',
attributeId: 'weapon-damage',
},
{ key: 'duration', value: '7', order: 2, unit: 'seconds' },
],
},
],
});
const mechanicDraft = (tierTwoValue = 20): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'weapon-hit',
label: 'Weapon hit',
event: 'weapon_hit',
reducer: { kind: 'count', threshold: 10 },
mutations: [],
outcomes: [
{
key: 'weapon-damage',
label: 'Weapon damage',
phase: 'after_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
basis: 'flat',
attributeId: 'weapon-damage',
perStack: false,
variants: {
I: { value: 15 },
II: { value: tierTwoValue },
},
},
],
},
],
});
const protocolSource = (
description = 'Gain weapon damage when the protocol activates.',
): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'os_protocol_talent', id: 'protocol-1' },
template: description,
variants: [{ key: 'base', materialized: description, slots: [] }],
});
const protocolDraftWithForbiddenScaling = (): ConditionalMechanicDraftV1 => {
const value = mechanicDraft();
value.clauses[0].outcomes[0].variants = { base: { value: 15 } };
value.clauses[0].outcomes[0].variants.base.expression = {
op: 'add',
left: { ref: { source: 'variant_value' } },
right: {
ref: { source: 'equipment', path: 'protocolTalentUpgrades' },
},
};
return value;
};
const count = async (table: string) =>
Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get()
)?.count ?? 0,
);
const execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
describe('mechanic authored source and revision persistence', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await database.exec(`INSERT INTO users(id,username,role,created_at)
VALUES('actor-1','Agent','admin','now')`);
await database.exec(`INSERT INTO stat_attributes(
id,name,type,category,calculation_type,unit,created_at,updated_at
) VALUES('weapon-damage','Weapon Damage','Offensive','Weapon','Additive','%','now','now')`);
});
afterEach(async () => {
await database.close();
});
it('constructs trusted owner sources and explicit selection intent from form payloads', () => {
const source = talentSource();
const payload = JSON.stringify({
version: source.version,
template: source.template,
variants: source.variants,
});
expect(parseMechanicAuthoredSourcePayload(payload, source.owner)).toEqual(
source,
);
expect(() =>
parseMechanicAuthoredSourcePayload(
JSON.stringify({
version: source.version,
owner: { type: 'talent', id: 'attacker' },
template: source.template,
variants: source.variants,
}),
source.owner,
),
).toThrow(/unsupported field/);
expect(parseExpectedSourceFingerprint('a'.repeat(64))).toBe('a'.repeat(64));
expect(parseExpectedSourceFingerprint('')).toBeNull();
expect(() => parseExpectedSourceFingerprint('not-a-hash')).toThrow(
/fingerprint is invalid/,
);
expect(
parseMechanicSelectionIntent('preserve', null, {
variantKeys: ['I', 'II'],
programName: 'Measured fire',
}),
).toEqual({ intent: 'preserve' });
expect(
parseMechanicSelectionIntent('save', JSON.stringify(mechanicDraft()), {
variantKeys: ['I', 'II'],
programName: 'Measured fire',
allowedAttributeIds: new Set(['weapon-damage']),
}),
).toMatchObject({ intent: 'save', draft: mechanicDraft() });
});
it('rejects a forged owner-restricted draft before writing its authored source', async () => {
const forged = mechanicDraft();
forged.clauses[0].requiresAssignedAbilityActive = true;
forged.clauses[0].outcomes[0].scope = 'assigned_ability';
forged.clauses[0].if = {
kind: 'exists',
value: { source: 'assigned_ability', path: 'active' },
};
await expect(
persistMechanicOwnerStateInTransaction(database, {
source: talentSource(),
expectedSourceFingerprint: null,
selection: { intent: 'save', draft: forged },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
}),
).rejects.toThrow(/assigned/);
expect(await count('mechanic_authored_sources')).toBe(0);
expect(await count('mechanic_model_revisions')).toBe(0);
});
it('round-trips exact authored variants and rejects stale fingerprints without writes', async () => {
const saved = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource(),
{ expectedSourceFingerprint: null },
);
expect(
await loadMechanicAuthoredSource(database, 'talent', 'talent-1'),
).toEqual(saved);
await expect(
saveMechanicAuthoredSourceInTransaction(database, talentSource('25'), {
expectedSourceFingerprint: 'f'.repeat(64),
}),
).rejects.toBeInstanceOf(MechanicSourceFingerprintConflictError);
expect(await count('mechanic_authored_sources')).toBe(1);
expect(await count('mechanic_authored_variants')).toBe(2);
expect(await count('mechanic_authored_slots')).toBe(6);
expect(
(await loadMechanicAuthoredSource(database, 'talent', 'talent-1'))
?.fingerprint,
).toBe(saved.fingerprint);
});
it('appends revisions, skips the identical selected retry, and preserves history when cleared', async () => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource(),
{ expectedSourceFingerprint: null },
);
const first = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
expect(first.created).toBe(true);
expect(first.revision.revisionNumber).toBe(1);
const retry = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
expect(retry).toMatchObject({
created: false,
revision: { id: first.revision.id, revisionNumber: 1 },
});
expect(await count('mechanic_model_revisions')).toBe(1);
const second = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(25),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
expect(second).toMatchObject({
created: true,
revision: { revisionNumber: 2 },
});
expect(await count('mechanic_model_revisions')).toBe(2);
expect(await count('mechanic_model_revision_outcomes')).toBe(4);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toMatchObject({
id: second.revision.id,
revisionNumber: 2,
draft: mechanicDraft(25),
});
await clearMechanicModelSelectionInTransaction(database, source.id);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toBeNull();
expect(await count('mechanic_model_revisions')).toBe(2);
expect(await count('mechanic_model_revision_outcomes')).toBe(4);
});
it('applies save, preserve, and clear intent through one owner persistence seam', async () => {
const source = talentSource();
const saved = await persistMechanicOwnerStateInTransaction(database, {
source,
expectedSourceFingerprint: null,
selection: { intent: 'save', draft: mechanicDraft() },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
expect(saved).toMatchObject({
revisionCreated: true,
selectedRevision: { revisionNumber: 1 },
projection: { lifecycle: { status: 'current' } },
});
const proseChanged = talentSource('25');
const preserved = await persistMechanicOwnerStateInTransaction(database, {
source: proseChanged,
expectedSourceFingerprint: saved.source.fingerprint,
selection: { intent: 'preserve' },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
expect(preserved).toMatchObject({
revisionCreated: false,
selectedRevision: { id: saved.selectedRevision?.id },
projection: {
lifecycle: { status: 'stale', reason: 'fingerprint_mismatch' },
},
});
const cleared = await persistMechanicOwnerStateInTransaction(database, {
source: proseChanged,
expectedSourceFingerprint: preserved.source.fingerprint,
selection: { intent: 'clear' },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
expect(cleared).toMatchObject({
revisionCreated: false,
selectedRevision: null,
projection: { lifecycle: { status: 'absent' } },
});
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('preserves an invalid selected capability while falling back to authored prose', async () => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
protocolSource(),
{ expectedSourceFingerprint: null },
);
const revision = await saveMechanicModelRevisionInTransaction(
database,
source,
protocolDraftWithForbiddenScaling(),
{ programName: 'Protocol scaling', origin: 'isac', actorId: 'actor-1' },
);
const changedSource = protocolSource(
'Gain weapon damage after the protocol activates.',
);
const preserved = await persistMechanicOwnerStateInTransaction(database, {
source: changedSource,
expectedSourceFingerprint: source.fingerprint,
selection: { intent: 'preserve' },
programName: 'Protocol scaling',
origin: 'isac',
actorId: 'actor-1',
});
expect(preserved.selectedRevision?.id).toBe(revision.revision.id);
expect(preserved.projection.variants).toMatchObject([
{
variantKey: 'base',
status: 'not_modeled',
displaySource: 'authored',
behaviorText: 'Gain weapon damage after the protocol activates.',
},
]);
expect(preserved.source.source.template).toBe(changedSource.template);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toMatchObject({
id: revision.revision.id,
});
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('fails closed for corrupted public projection rows', async () => {
const persisted = await persistMechanicOwnerStateInTransaction(database, {
source: talentSource(),
expectedSourceFingerprint: null,
selection: { intent: 'save', draft: mechanicDraft() },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
const second = await saveMechanicModelRevisionInTransaction(
database,
persisted.source,
mechanicDraft(25),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
await refreshMechanicPublicProjectionInTransaction(
database,
persisted.source,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
const selectedRevisionId = second.revision.id;
const alternateRevisionId = persisted.selectedRevision?.id;
expect(alternateRevisionId).toBeTruthy();
expect(
await loadMechanicPublicProjection(database, persisted.source.id),
).toHaveLength(2);
const expectClosed = async () =>
expect(
await loadMechanicPublicProjection(database, persisted.source.id),
).toEqual([]);
const refresh = async () => {
await refreshMechanicPublicProjectionInTransaction(
database,
persisted.source,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
};
await execute(
"UPDATE mechanic_public_variants SET behavior_text='tampered model text' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
'b'.repeat(64),
selectedRevisionId,
);
await expectClosed();
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
second.revision.programHash,
selectedRevisionId,
);
await refresh();
await execute(
'UPDATE mechanic_public_variants SET current_source_fingerprint=? WHERE source_id=?',
'b'.repeat(64),
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
"DELETE FROM mechanic_public_variants WHERE source_id=? AND variant_key='II'",
persisted.source.id,
);
await expectClosed();
await refresh();
await database.exec('PRAGMA foreign_keys=OFF');
await execute(
"UPDATE mechanic_public_variants SET variant_key='extra' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await database.exec('PRAGMA foreign_keys=ON');
await refresh();
await execute(
"UPDATE mechanic_public_variants SET selected_revision_id=? WHERE source_id=? AND variant_key='I'",
alternateRevisionId,
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
'UPDATE mechanic_public_variants SET authored_contract_version=2 WHERE source_id=?',
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
'UPDATE mechanic_public_variants SET mechanic_contract_version=2 WHERE source_id=?',
persisted.source.id,
);
await expectClosed();
await refresh();
await database.exec('PRAGMA ignore_check_constraints=ON');
await execute(
"UPDATE mechanic_public_variants SET status='not_modeled' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await database.exec('PRAGMA ignore_check_constraints=OFF');
await refresh();
await database.exec('PRAGMA ignore_check_constraints=ON');
await execute(
"UPDATE mechanic_public_variants SET status='not_modeled',display_source='authored',behavior_text='forged fallback' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await database.exec('PRAGMA ignore_check_constraints=OFF');
await refresh();
await execute(
'UPDATE mechanic_public_variants SET updated_at=? WHERE source_id=?',
'not-a-timestamp',
persisted.source.id,
);
await expectClosed();
await refresh();
expect(
await loadMechanicPublicProjection(database, persisted.source.id),
).toHaveLength(2);
expect(selectedRevisionId).toBe(
(await loadSelectedMechanicModelRevision(database, persisted.source.id))
?.id,
);
});
it('keeps a selected revision stale after prose changes and projects authored fallback', async () => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource(),
{ expectedSourceFingerprint: null },
);
const revision = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
const modeled = await refreshMechanicPublicProjectionInTransaction(
database,
source,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
expect(modeled.lifecycle.status).toBe('current');
expect(
modeled.variants.every((variant) => variant.status === 'modeled'),
).toBe(true);
const changedSource = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource('25'),
{ expectedSourceFingerprint: source.fingerprint },
);
const fallback = await refreshMechanicPublicProjectionInTransaction(
database,
changedSource,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
expect(fallback.lifecycle).toEqual({
status: 'stale',
reason: 'fingerprint_mismatch',
});
expect(fallback.variants).toMatchObject([
{ variantKey: 'I', status: 'not_modeled', displaySource: 'authored' },
{
variantKey: 'II',
status: 'not_modeled',
displaySource: 'authored',
behaviorText: 'After 8 hits, gain 25% weapon damage for 7 seconds.',
},
]);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toMatchObject({
id: revision.revision.id,
});
expect(await loadMechanicPublicProjection(database, source.id)).toEqual(
fallback.variants,
);
});
});
@@ -0,0 +1,302 @@
import type {
MechanicAuthoredOwner,
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 { saveMechanicAuthoredSourceInTransaction } from './mechanic-authored-sources';
import { saveMechanicModelRevisionInTransaction } from './mechanic-model-revisions';
import {
loadMechanicPublicProjectionForOwner,
loadMechanicPublicProjectionsForOwners,
mechanicPublicProjectionOwnerKey,
refreshMechanicPublicProjectionInTransaction,
type MechanicPublicProjectionOwnerRef,
} 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 requiredVariantKeys = (
owner: MechanicPublicProjectionOwnerRef,
): readonly string[] => {
switch (owner.type) {
case 'talent':
return ['I', 'II'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
};
const authoredOwner = (
owner: MechanicPublicProjectionOwnerRef,
): MechanicAuthoredOwner =>
owner.type === 'talent' ? { ...owner, scope: 'standard' } : owner;
const sourceFor = (
owner: MechanicPublicProjectionOwnerRef,
): MechanicAuthoredSource => ({
version: 1,
owner: authoredOwner(owner),
template: 'Gain {1} output.',
variants: requiredVariantKeys(owner).map((key, index) => ({
key,
materialized: `Gain ${String(index + 1)} output.`,
slots: [{ key: '1', value: String(index + 1), order: 0 }],
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: 'Rank effect description.',
effectId: 'effect-1',
effectTemplate: 'Gain {1} output.',
},
}
: {}),
});
const modeledDraftFor = (
owner: MechanicPublicProjectionOwnerRef,
): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'mark-target',
label: 'Mark target',
event: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'mark',
label: 'Mark',
phase: 'on_event',
kind: 'marker',
scope: 'target',
basis: 'flat',
variants: Object.fromEntries(
requiredVariantKeys(owner).map((key) => [key, { value: 1 }]),
),
},
],
},
],
});
const createProjection = async (
owner: MechanicPublicProjectionOwnerRef,
options: { modeled?: boolean } = {},
) => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(owner),
{ expectedSourceFingerprint: null },
);
if (options.modeled) {
await saveMechanicModelRevisionInTransaction(
database,
source,
modeledDraftFor(owner),
{ programName: 'Target mark', origin: 'test', actorId: null },
);
}
await refreshMechanicPublicProjectionInTransaction(database, source);
return source;
};
const execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
const databaseWithPreparedQueryCount = () => {
let queryCount = 0;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (sql: string) => {
queryCount += 1;
return target.prepare(sql);
};
},
}) as unknown as Database;
return { reader, queryCount: () => queryCount };
};
describe('mechanic public projection owner readers', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
});
afterEach(async () => {
await database.close();
});
it('creates distinct typed keys for every owner identity', () => {
const owners: MechanicPublicProjectionOwnerRef[] = [
{ type: 'talent', id: 'shared' },
{ type: 'os_protocol_talent', id: 'shared' },
{ type: 'skill_chip_talent', id: 'shared' },
{ type: 'specialization_tree_rank', id: 'shared' },
];
const keys = owners.map(mechanicPublicProjectionOwnerKey);
expect(new Set(keys)).toHaveLength(owners.length);
expect(mechanicPublicProjectionOwnerKey(owners[0])).not.toBe(
mechanicPublicProjectionOwnerKey({
type: 'talent',
id: 'os_protocol_talent:shared',
}),
);
});
it('loads one owner in authored variant order', async () => {
const owner = { type: 'skill_chip_talent', id: 'chip-1' } as const;
await createProjection(owner);
const projection = await loadMechanicPublicProjectionForOwner(
database,
owner,
);
expect(projection.map((variant) => variant.variantKey)).toEqual([
'minimum',
'maximum',
]);
expect(projection.map((variant) => variant.behaviorText)).toEqual([
'Gain 1 output.',
'Gain 2 output.',
]);
});
it('keeps missing owners empty without suppressing other projections', async () => {
const present = { type: 'talent', id: 'talent-1' } as const;
const missing = { type: 'os_protocol_talent', id: 'missing' } as const;
await createProjection(present);
const projections = await loadMechanicPublicProjectionsForOwners(database, [
present,
missing,
]);
expect(
projections[mechanicPublicProjectionOwnerKey(present)].map(
(variant) => variant.variantKey,
),
).toEqual(['I', 'II']);
expect(projections[mechanicPublicProjectionOwnerKey(missing)]).toEqual([]);
});
it('excludes one corrupt projection without suppressing a valid owner', async () => {
const valid = { type: 'os_protocol_talent', id: 'protocol-1' } as const;
const corrupt = { type: 'specialization_tree_rank', id: 'rank-1' } as const;
const corruptSource = await createProjection(corrupt);
await createProjection(valid);
await execute(
"UPDATE mechanic_public_variants SET behavior_text='forged text' WHERE source_id=?",
corruptSource.id,
);
const projections = await loadMechanicPublicProjectionsForOwners(database, [
corrupt,
valid,
]);
expect(projections[mechanicPublicProjectionOwnerKey(corrupt)]).toEqual([]);
expect(
projections[mechanicPublicProjectionOwnerKey(valid)].map(
(variant) => variant.behaviorText,
),
).toEqual(['Gain 1 output.']);
});
it('uses a bounded query snapshot for many modeled owners', async () => {
const owners = Array.from({ length: 8 }, (_, index) => ({
type: 'talent' as const,
id: `talent-${String(index + 1)}`,
}));
for (const owner of owners) {
await createProjection(owner, { modeled: true });
}
const { reader, queryCount } = databaseWithPreparedQueryCount();
const projections = await loadMechanicPublicProjectionsForOwners(
reader,
owners,
);
expect(queryCount()).toBeLessThanOrEqual(10);
for (const owner of owners) {
expect(
projections[mechanicPublicProjectionOwnerKey(owner)],
).toMatchObject([
{ variantKey: 'I', displaySource: 'model' },
{ variantKey: 'II', displaySource: 'model' },
]);
}
});
it('isolates a corrupt selected revision while preserving another modeled owner', async () => {
const valid = { type: 'talent', id: 'valid' } as const;
const corrupt = { type: 'talent', id: 'corrupt' } as const;
const validSource = await createProjection(valid, { modeled: true });
const corruptSource = await createProjection(corrupt, { modeled: true });
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE source_id=?',
'b'.repeat(64),
corruptSource.id,
);
const owners = [corrupt, valid];
const projections = await loadMechanicPublicProjectionsForOwners(
database,
owners,
);
const oneOwnerProjections = Object.fromEntries(
await Promise.all(
owners.map(async (owner) => [
mechanicPublicProjectionOwnerKey(owner),
await loadMechanicPublicProjectionForOwner(database, owner),
]),
),
);
expect(projections).toEqual(oneOwnerProjections);
expect(projections[mechanicPublicProjectionOwnerKey(corrupt)]).toEqual([]);
expect(projections[mechanicPublicProjectionOwnerKey(valid)]).toMatchObject([
{
sourceId: validSource.id,
variantKey: 'I',
displaySource: 'model',
},
{
sourceId: validSource.id,
variantKey: 'II',
displaySource: 'model',
},
]);
});
});
@@ -0,0 +1,849 @@
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 type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description';
import {
loadMechanicAuthoredSource,
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[];
};
/** Stable public identity for a mechanic owner, without authoring-only fields. */
export type MechanicPublicProjectionOwnerRef = {
[Type in MechanicAuthoredOwner['type']]: Pick<
Extract<MechanicAuthoredOwner, { type: Type }>,
'type' | 'id'
>;
}[MechanicAuthoredOwner['type']];
declare const mechanicPublicProjectionOwnerKeyBrand: unique symbol;
/** A serializable, collision-safe map key derived from an owner type and ID. */
export type MechanicPublicProjectionOwnerKey = string & {
readonly [mechanicPublicProjectionOwnerKeyBrand]: true;
};
export type MechanicPublicProjectionsByOwner = Record<
MechanicPublicProjectionOwnerKey,
MechanicPublicVariantRecord[]
>;
/**
* Length-prefixes each part so owner IDs cannot collide with another owner
* type or ID when projections are stored in a route-loader-safe object.
*/
export function mechanicPublicProjectionOwnerKey(
owner: MechanicPublicProjectionOwnerRef,
): MechanicPublicProjectionOwnerKey {
return `${owner.type.length}:${owner.type}${owner.id.length}:${owner.id}` as MechanicPublicProjectionOwnerKey;
}
function isMechanicPublicProjectionOwnerRef(
value: unknown,
): value is MechanicPublicProjectionOwnerRef {
if (typeof value !== 'object' || value === null) return false;
const owner = value as Record<string, unknown>;
return (
isNonEmptyString(owner.id) &&
(owner.type === 'talent' ||
owner.type === 'os_protocol_talent' ||
owner.type === 'skill_chip_talent' ||
owner.type === 'specialization_tree_rank')
);
}
/** 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));
}
type MechanicPublicProjectionSnapshotRow = Record<string, unknown>;
type MechanicPublicProjectionSnapshot = {
sourceByOwner: Map<string, MechanicPublicProjectionSnapshotRow>;
sourceById: Map<string, MechanicPublicProjectionSnapshotRow>;
variantsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
slotsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
publicVariantsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
selectionBySourceId: Map<string, MechanicPublicProjectionSnapshotRow>;
selectedRevisionBySourceId: Map<string, MechanicPublicProjectionSnapshotRow>;
poolsByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
clausesByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
outcomesByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
activeAttributes: MechanicPublicProjectionSnapshotRow[];
};
function ownerSnapshotKey(type: string, id: string) {
return `${type.length}:${type}${id.length}:${id}`;
}
function mapRowsByStringColumn(
rows: readonly MechanicPublicProjectionSnapshotRow[],
column: string,
) {
const result = new Map<string, MechanicPublicProjectionSnapshotRow[]>();
for (const row of rows) {
const value = row[column];
if (typeof value !== 'string') continue;
result.set(value, [...(result.get(value) ?? []), row]);
}
return result;
}
function mapFirstRowByStringColumn(
rows: readonly MechanicPublicProjectionSnapshotRow[],
column: string,
) {
const result = new Map<string, MechanicPublicProjectionSnapshotRow>();
for (const row of rows) {
const value = row[column];
if (typeof value === 'string' && !result.has(value)) {
result.set(value, row);
}
}
return result;
}
function copiedRow(row: MechanicPublicProjectionSnapshotRow | undefined) {
return row === undefined ? undefined : { ...row };
}
function copiedRows(rows: MechanicPublicProjectionSnapshotRow[] | undefined) {
return (rows ?? []).map((row) => ({ ...row }));
}
async function loadMechanicPublicProjectionSnapshotRows(
database: Database,
sql: string,
ids: readonly string[],
): Promise<MechanicPublicProjectionSnapshotRow[]> {
return (await (
await database.prepare(sql)
).all(JSON.stringify(ids))) as MechanicPublicProjectionSnapshotRow[];
}
/**
* Reads every table the single-owner validator needs in a bounded number of
* queries. The returned rows are private input to that validator, never a
* public projection by themselves.
*/
async function loadMechanicPublicProjectionSnapshot(
database: Database,
owners: readonly MechanicPublicProjectionOwnerRef[],
): Promise<MechanicPublicProjectionSnapshot> {
const requestedOwners = [
...new Map(
owners
.filter(isMechanicPublicProjectionOwnerRef)
.map((owner) => [ownerSnapshotKey(owner.type, owner.id), owner]),
).values(),
];
const sourceRows = (await (
await database.prepare(`SELECT source.*
FROM mechanic_authored_sources source
JOIN json_each(?) requested
ON source.owner_type=json_extract(requested.value,'$.type')
AND source.owner_id=json_extract(requested.value,'$.id')`)
).all(
JSON.stringify(requestedOwners),
)) as MechanicPublicProjectionSnapshotRow[];
const sourceByOwner = new Map<string, MechanicPublicProjectionSnapshotRow>();
for (const source of sourceRows) {
if (
typeof source.owner_type === 'string' &&
typeof source.owner_id === 'string'
) {
sourceByOwner.set(
ownerSnapshotKey(source.owner_type, source.owner_id),
source,
);
}
}
const sourceById = mapFirstRowByStringColumn(sourceRows, 'id');
const sourceIds = [...sourceById.keys()];
if (!sourceIds.length) {
return {
sourceByOwner,
sourceById,
variantsBySourceId: new Map(),
slotsBySourceId: new Map(),
publicVariantsBySourceId: new Map(),
selectionBySourceId: new Map(),
selectedRevisionBySourceId: new Map(),
poolsByRevisionId: new Map(),
clausesByRevisionId: new Map(),
outcomesByRevisionId: new Map(),
activeAttributes: [],
};
}
const [
variantRows,
slotRows,
publicVariantRows,
selectionRows,
revisionRows,
activeAttributes,
] = await Promise.all([
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_authored_variants
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,sort_order`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_authored_slots
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,variant_key,sort_order`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_public_variants
WHERE source_id IN (SELECT value FROM json_each(?))`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT source_id,revision_id FROM mechanic_model_selections
WHERE source_id IN (SELECT value FROM json_each(?))`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`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 IN (SELECT value FROM json_each(?))`,
sourceIds,
),
(async () =>
(await (
await database.prepare(
'SELECT id,name FROM stat_attributes WHERE active=1',
)
).all()) as MechanicPublicProjectionSnapshotRow[])(),
]);
const revisionBySourceId = mapFirstRowByStringColumn(
revisionRows,
'source_id',
);
const revisionIds = [
...new Set(
revisionRows.flatMap((revision) =>
typeof revision.id === 'string' ? [revision.id] : [],
),
),
];
const [poolRows, clauseRows, outcomeRows] = revisionIds.length
? await Promise.all([
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_pools
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_clauses
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_outcomes
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order,variant_key`,
revisionIds,
),
])
: [[], [], []];
return {
sourceByOwner,
sourceById,
variantsBySourceId: mapRowsByStringColumn(variantRows, 'source_id'),
slotsBySourceId: mapRowsByStringColumn(slotRows, 'source_id'),
publicVariantsBySourceId: mapRowsByStringColumn(
publicVariantRows,
'source_id',
),
selectionBySourceId: mapFirstRowByStringColumn(selectionRows, 'source_id'),
selectedRevisionBySourceId: revisionBySourceId,
poolsByRevisionId: mapRowsByStringColumn(poolRows, 'revision_id'),
clausesByRevisionId: mapRowsByStringColumn(clauseRows, 'revision_id'),
outcomesByRevisionId: mapRowsByStringColumn(outcomeRows, 'revision_id'),
activeAttributes,
};
}
/**
* Replays the exact read shapes used by the single-owner boundary against a
* private, immutable-in-practice bulk snapshot. Reusing those readers keeps
* source parsing, revision parsing, hashes, lifecycle checks, rendering, and
* cache validation identical for one and many owners.
*/
function mechanicPublicProjectionSnapshotDatabase(
snapshot: MechanicPublicProjectionSnapshot,
): Database {
return {
prepare: async (sql: string) => {
const normalizedSql = sql.replace(/\s+/g, ' ').trim();
const sourceId = (parameters: unknown[]) =>
typeof parameters[0] === 'string' ? parameters[0] : '';
const rowForOwner = (parameters: unknown[]) => {
const [ownerType, ownerId] = parameters;
return typeof ownerType === 'string' && typeof ownerId === 'string'
? snapshot.sourceByOwner.get(ownerSnapshotKey(ownerType, ownerId))
: undefined;
};
const selectedRevision = (parameters: unknown[]) =>
snapshot.selectedRevisionBySourceId.get(sourceId(parameters));
return {
get: async (...parameters: unknown[]) => {
if (
normalizedSql ===
'SELECT * FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?'
) {
return copiedRow(rowForOwner(parameters));
}
if (
normalizedSql ===
'SELECT * FROM mechanic_authored_sources WHERE id=?'
) {
return copiedRow(snapshot.sourceById.get(sourceId(parameters)));
}
if (
normalizedSql ===
'SELECT revision_id FROM mechanic_model_selections WHERE source_id=?'
) {
return copiedRow(
snapshot.selectionBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT revision.* FROM mechanic_model_selections selection JOIN mechanic_model_revisions revision ',
)
) {
return copiedRow(selectedRevision(parameters));
}
throw new Error(
`Unsupported mechanic public projection snapshot query: ${normalizedSql}`,
);
},
all: async (...parameters: unknown[]) => {
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_authored_variants ',
)
) {
return copiedRows(
snapshot.variantsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith('SELECT * FROM mechanic_authored_slots ')
) {
return copiedRows(
snapshot.slotsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql ===
'SELECT * FROM mechanic_public_variants WHERE source_id=?'
) {
return copiedRows(
snapshot.publicVariantsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql ===
'SELECT id,name FROM stat_attributes WHERE active=1'
) {
return copiedRows(snapshot.activeAttributes);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_pools ',
)
) {
return copiedRows(
snapshot.poolsByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_clauses ',
)
) {
return copiedRows(
snapshot.clausesByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_outcomes ',
)
) {
return copiedRows(
snapshot.outcomesByRevisionId.get(sourceId(parameters)),
);
}
throw new Error(
`Unsupported mechanic public projection snapshot query: ${normalizedSql}`,
);
},
};
},
} as unknown as Database;
}
/**
* 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 [];
}
}
/**
* Loads one owner's projection only after resolving its source identity and
* passing it through the complete fail-closed projection validator.
*/
export async function loadMechanicPublicProjectionForOwner(
database: Database,
owner: MechanicPublicProjectionOwnerRef,
): Promise<MechanicPublicVariantRecord[]> {
try {
if (!isMechanicPublicProjectionOwnerRef(owner)) return [];
const source = await loadMechanicAuthoredSource(
database,
owner.type,
owner.id,
);
if (
!source ||
source.source.owner.type !== owner.type ||
source.source.owner.id !== owner.id
) {
return [];
}
return await loadMechanicPublicProjection(database, source.id);
} catch {
return [];
}
}
/**
* Loads independent, validated public projections for route loaders. Missing
* or corrupt owners retain an empty entry without suppressing valid owners.
*/
export async function loadMechanicPublicProjectionsForOwners(
database: Database,
owners: readonly MechanicPublicProjectionOwnerRef[],
): Promise<MechanicPublicProjectionsByOwner> {
const ownerKeys = owners.map(mechanicPublicProjectionOwnerKey);
const emptyProjections = Object.fromEntries(
ownerKeys.map((key) => [key, []]),
) as MechanicPublicProjectionsByOwner;
if (!owners.length) return emptyProjections;
try {
const snapshot = await loadMechanicPublicProjectionSnapshot(
database,
owners,
);
const snapshotDatabase = mechanicPublicProjectionSnapshotDatabase(snapshot);
const entries = await Promise.all(
owners.map(async (owner, index) => {
const projection = await loadMechanicPublicProjectionForOwner(
snapshotDatabase,
owner,
);
return [ownerKeys[index], projection] as const;
}),
);
return Object.fromEntries(entries) as MechanicPublicProjectionsByOwner;
} catch {
return emptyProjections;
}
}
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,623 @@
import type {
MechanicAuthoredOwner,
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 { saveMechanicAuthoredSourceInTransaction } from './mechanic-authored-sources';
import { saveMechanicModelRevisionInTransaction } from './mechanic-model-revisions';
import {
loadSelectedMechanicRuntime,
loadSelectedMechanicRuntimes,
mechanicRuntimeOwnerKey,
mechanicRuntimeProgramId,
} from './mechanic-runtime-selection';
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);
}
};
function variantKeys(owner: MechanicAuthoredOwner) {
switch (owner.type) {
case 'talent':
return owner.scope === 'standard' || owner.hasTierII
? ['I', 'II']
: ['I'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
}
}
function sourceFor(
owner: MechanicAuthoredOwner,
text = 'Mark the target.',
): MechanicAuthoredSource {
return {
version: 1,
owner,
template: text,
variants: variantKeys(owner).map((key) => ({
key,
materialized: text,
slots: [],
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: 'Applies a mark.',
effectId: 'specialization-mark',
effectTemplate: 'Mark the target.',
},
}
: {}),
};
}
function draftFor(
owner: MechanicAuthoredOwner,
simulationReadiness: ConditionalMechanicDraftV1['simulationReadiness'] = 'ready',
): ConditionalMechanicDraftV1 {
return {
version: 1,
simulationReadiness,
scenarioInputs:
simulationReadiness === 'requires_assumptions'
? [
{
key: 'nearby-enemies',
label: 'Nearby enemies',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
]
: [],
pools: [],
clauses: [
{
key: 'mark-target',
label: 'Mark target',
event: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'mark',
label: 'Mark',
phase: 'on_event',
kind: 'marker',
scope: 'target',
basis: 'flat',
variants: Object.fromEntries(
variantKeys(owner).map((key) => [key, { value: 1 }]),
),
},
],
},
],
};
}
async function saveSelected(
owner: MechanicAuthoredOwner,
options: {
text?: string;
simulationReadiness?: ConditionalMechanicDraftV1['simulationReadiness'];
} = {},
) {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(owner, options.text),
{ expectedSourceFingerprint: null },
);
const saved = await saveMechanicModelRevisionInTransaction(
database,
source,
draftFor(owner, options.simulationReadiness),
{ programName: 'Target mark', origin: 'test', actorId: null },
);
return { source, revision: saved.revision };
}
const execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
function databaseThatMutatesAfterFirstSelectedRevisionRead(
mutate: () => Promise<void>,
) {
let hasMutated = false;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (sql: string) => {
const statement = await target.prepare(sql);
if (!sql.includes('FROM mechanic_model_selections selection')) {
return statement;
}
return new Proxy(statement, {
get(statementTarget, statementProperty, statementReceiver) {
if (statementProperty !== 'get' && statementProperty !== 'all') {
return Reflect.get(
statementTarget,
statementProperty,
statementReceiver,
);
}
const read = Reflect.get(statementTarget, statementProperty);
return async (...parameters: unknown[]) => {
const result = await Reflect.apply(
read,
statementTarget,
parameters,
);
if (!hasMutated) {
hasMutated = true;
await mutate();
}
return result;
};
},
});
};
},
}) as unknown as Database;
return { reader, hasMutated: () => hasMutated };
}
function databaseThatMutatesBeforeSecondSnapshotSourceRead(
mutate: () => Promise<void>,
) {
let sourceReadCount = 0;
let hasMutated = false;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (sql: string) => {
const statement = await target.prepare(sql);
if (!sql.includes('FROM mechanic_authored_sources source')) {
return statement;
}
return new Proxy(statement, {
get(statementTarget, statementProperty, statementReceiver) {
if (statementProperty !== 'all') {
return Reflect.get(
statementTarget,
statementProperty,
statementReceiver,
);
}
const all = Reflect.get(statementTarget, statementProperty);
return async (...parameters: unknown[]) => {
sourceReadCount += 1;
if (sourceReadCount === 2 && !hasMutated) {
hasMutated = true;
await mutate();
}
return Reflect.apply(all, statementTarget, parameters);
};
},
});
};
},
}) as unknown as Database;
return { reader, hasMutated: () => hasMutated };
}
function databaseThatCountsQueries() {
let queryCount = 0;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (...parameters: unknown[]) => {
queryCount += 1;
return Reflect.apply(target.prepare, target, parameters);
};
},
}) as unknown as Database;
return { reader, queryCount: () => queryCount };
}
describe('selected mechanic runtime reader', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
});
afterEach(async () => {
await database.close();
});
it('reads validated selected revisions for every typed authored owner shape', async () => {
const owners: MechanicAuthoredOwner[] = [
{ type: 'talent', id: 'talent-1', scope: 'standard' },
{ type: 'os_protocol_talent', id: 'protocol-1' },
{ type: 'skill_chip_talent', id: 'chip-1' },
{ type: 'specialization_tree_rank', id: 'rank-1' },
];
for (const owner of owners) await saveSelected(owner);
const runtimeIds: string[] = [];
for (const owner of owners) {
const selection = await loadSelectedMechanicRuntime(database, owner);
expect(selection).toMatchObject({
owner,
simulationReadiness: 'ready',
scenarioInputs: [],
variants: variantKeys(owner).map((key) => ({ key })),
});
const selectionRuntimeIds = selection?.variants.map(
(variant) => variant.program.id,
);
expect(selectionRuntimeIds).toEqual(
selection?.variants.map((variant) =>
mechanicRuntimeProgramId(selection.sourceId, variant.key),
),
);
runtimeIds.push(...(selectionRuntimeIds ?? []));
}
expect(new Set(runtimeIds).size).toBe(runtimeIds.length);
});
it('returns null when no revision has been explicitly selected', async () => {
const owner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-no-selection',
};
await saveMechanicAuthoredSourceInTransaction(database, sourceFor(owner), {
expectedSourceFingerprint: null,
});
await expect(
loadSelectedMechanicRuntime(database, owner),
).resolves.toBeNull();
});
it('fails closed when the selected revision is stale or the stored source fingerprint is tampered', async () => {
const staleOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-stale',
};
const stale = await saveSelected(staleOwner);
await saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(staleOwner, 'Mark a different target.'),
{ expectedSourceFingerprint: stale.source.fingerprint },
);
await expect(
loadSelectedMechanicRuntime(database, staleOwner),
).resolves.toBeNull();
const tamperedOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-tampered-source',
};
const tampered = await saveSelected(tamperedOwner);
await execute(
'UPDATE mechanic_authored_sources SET source_fingerprint=? WHERE id=?',
'b'.repeat(64),
tampered.source.id,
);
await expect(
loadSelectedMechanicRuntime(database, tamperedOwner),
).resolves.toBeNull();
});
it('rechecks the current source after its selected revision is read', async () => {
const owner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-changes-during-read',
};
const saved = await saveSelected(owner);
const intercepted = databaseThatMutatesAfterFirstSelectedRevisionRead(() =>
saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(owner, 'Mark a newly authored target.'),
{ expectedSourceFingerprint: saved.source.fingerprint },
).then(() => undefined),
);
await expect(
loadSelectedMechanicRuntime(intercepted.reader, owner),
).resolves.toBeNull();
expect(intercepted.hasMutated()).toBe(true);
await expect(
loadSelectedMechanicRuntime(database, owner),
).resolves.toBeNull();
});
it('rejects an in-place revision graph mutation after lifecycle validation', async () => {
const owner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-revision-changes-after-validation',
};
const saved = await saveSelected(owner);
const intercepted = databaseThatMutatesBeforeSecondSnapshotSourceRead(() =>
execute(
'UPDATE mechanic_model_revision_outcomes SET value=? WHERE revision_id=?',
2,
saved.revision.id,
).then(() => undefined),
);
await expect(
loadSelectedMechanicRuntime(intercepted.reader, owner),
).resolves.toBeNull();
expect(intercepted.hasMutated()).toBe(true);
});
it('fails closed for malformed graphs, unsupported contracts, and invalid hashes', async () => {
const malformedOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-malformed',
};
const malformed = await saveSelected(malformedOwner);
await execute(
'UPDATE mechanic_model_revision_clauses SET reducer_json=? WHERE revision_id=?',
'{"kind":"unsupported"}',
malformed.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, malformedOwner),
).resolves.toBeNull();
const unsupportedOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-unsupported-contract',
};
const unsupported = await saveSelected(unsupportedOwner);
await execute(
'UPDATE mechanic_model_revisions SET mechanic_contract_version=? WHERE id=?',
2,
unsupported.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, unsupportedOwner),
).resolves.toBeNull();
const invalidHashOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-invalid-hash',
};
const invalidHash = await saveSelected(invalidHashOwner);
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
'b'.repeat(64),
invalidHash.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, invalidHashOwner),
).resolves.toBeNull();
});
it('rejects empty persisted IDs and non-integer revision metadata', async () => {
const emptyIdOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-empty-runtime-ids',
};
const emptyIds = await saveSelected(emptyIdOwner);
await database.exec('PRAGMA foreign_keys=OFF');
try {
await execute(
'UPDATE mechanic_authored_sources SET id=? WHERE id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_authored_variants SET source_id=? WHERE source_id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_authored_slots SET source_id=? WHERE source_id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_model_revisions SET source_id=? WHERE source_id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_model_revisions SET id=? WHERE id=?',
'',
emptyIds.revision.id,
);
await execute(
'UPDATE mechanic_model_selections SET source_id=?,revision_id=? WHERE source_id=?',
'',
'',
emptyIds.source.id,
);
for (const table of [
'mechanic_model_revision_pools',
'mechanic_model_revision_clauses',
'mechanic_model_revision_outcomes',
]) {
await execute(
`UPDATE ${table} SET revision_id=? WHERE revision_id=?`,
'',
emptyIds.revision.id,
);
}
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
await expect(
loadSelectedMechanicRuntime(database, emptyIdOwner),
).resolves.toBeNull();
const fractionalOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-fractional-revision',
};
const fractional = await saveSelected(fractionalOwner);
await execute(
'UPDATE mechanic_model_revisions SET revision_number=? WHERE id=?',
1.5,
fractional.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, fractionalOwner),
).resolves.toBeNull();
const nonNumericOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-nonnumeric-revision',
};
const nonNumeric = await saveSelected(nonNumericOwner);
await execute(
'UPDATE mechanic_model_revisions SET revision_number=? WHERE id=?',
'not-a-number',
nonNumeric.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, nonNumericOwner),
).resolves.toBeNull();
});
it('excludes captured-only models and preserves requires-assumptions inputs', async () => {
const capturedOwner: MechanicAuthoredOwner = {
type: 'skill_chip_talent',
id: 'chip-captured',
};
await saveSelected(capturedOwner, { simulationReadiness: 'captured_only' });
await expect(
loadSelectedMechanicRuntime(database, capturedOwner),
).resolves.toBeNull();
const assumedOwner: MechanicAuthoredOwner = {
type: 'skill_chip_talent',
id: 'chip-assumed',
};
await saveSelected(assumedOwner, {
simulationReadiness: 'requires_assumptions',
});
await expect(
loadSelectedMechanicRuntime(database, assumedOwner),
).resolves.toMatchObject({
simulationReadiness: 'requires_assumptions',
scenarioInputs: [
{
key: 'nearby-enemies',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
],
});
});
it('keeps bulk runtime reads bounded at realistic owner counts', async () => {
const owners: MechanicAuthoredOwner[] = Array.from(
{ length: 24 },
(_, index) => {
switch (index % 4) {
case 0:
return {
type: 'talent' as const,
id: `talent-bulk-${index}`,
scope: 'standard' as const,
};
case 1:
return {
type: 'os_protocol_talent' as const,
id: `protocol-bulk-${index}`,
};
case 2:
return {
type: 'skill_chip_talent' as const,
id: `chip-bulk-${index}`,
};
default:
return {
type: 'specialization_tree_rank' as const,
id: `rank-bulk-${index}`,
};
}
},
);
for (const owner of owners) await saveSelected(owner);
const counted = databaseThatCountsQueries();
const selections = await loadSelectedMechanicRuntimes(
counted.reader,
owners,
);
expect(
[...selections.values()].filter((selection) => selection !== null),
).toHaveLength(owners.length);
expect(counted.queryCount()).toBeLessThanOrEqual(16);
});
it('isolates corrupt owners in multi-owner reads and produces unique runtime IDs', async () => {
const healthyOwner: MechanicAuthoredOwner = {
type: 'talent',
id: 'talent-healthy',
scope: 'standard',
};
const corruptOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-corrupt',
};
const healthy = await saveSelected(healthyOwner);
const corrupt = await saveSelected(corruptOwner);
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
'b'.repeat(64),
corrupt.revision.id,
);
const selections = await loadSelectedMechanicRuntimes(database, [
healthyOwner,
corruptOwner,
]);
const healthySelection = selections.get(
mechanicRuntimeOwnerKey(healthyOwner),
);
expect(healthySelection).toMatchObject({ sourceId: healthy.source.id });
expect(selections.get(mechanicRuntimeOwnerKey(corruptOwner))).toBeNull();
const runtimeIds = healthySelection?.variants.map(
(variant) => variant.program.id,
);
expect(runtimeIds).toHaveLength(2);
expect(new Set(runtimeIds).size).toBe(2);
expect(runtimeIds).toEqual([
mechanicRuntimeProgramId(healthy.source.id, 'I'),
mechanicRuntimeProgramId(healthy.source.id, 'II'),
]);
});
});
@@ -0,0 +1,735 @@
import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description';
import {
classifyMechanicVariantLifecycle,
hashMechanicAuthoredSource,
type MechanicSimulationReadiness,
} from '$lib/calculations/mechanic-lifecycle';
import type {
MechanicProgram,
ScenarioInputDefinition,
} from '$lib/calculations/mechanic';
import type { MechanicAttributeLabelResolver } from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import {
loadMechanicAuthoredSource,
type MechanicAuthoredSourceRecord,
} from './mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
type MechanicModelRevisionRecord,
} from './mechanic-model-revisions';
export type MechanicRuntimeOwnerKey = string;
export type MechanicRuntimeOwnerReference = Pick<
MechanicAuthoredOwner,
'type' | 'id'
>;
export type MechanicRuntimeOwnerReferenceKey = string;
export type MechanicRuntimeSimulationReadiness = Exclude<
MechanicSimulationReadiness,
'captured_only'
>;
/** A validated selected program, ordered by its current authored variants. */
export type MechanicRuntimeVariant = {
key: string;
label: string;
program: MechanicProgram;
};
/**
* The executable portion of one explicitly selected, current mechanic model.
* Captured-only models intentionally never reach this boundary.
*/
export type MechanicRuntimeSelection = {
owner: MechanicAuthoredOwner;
sourceId: string;
sourceFingerprint: string;
revisionId: string;
revisionNumber: number;
simulationReadiness: MechanicRuntimeSimulationReadiness;
scenarioInputs: ScenarioInputDefinition[];
variants: MechanicRuntimeVariant[];
};
/**
* Produces an unambiguous stable key for a typed owner reference. Talent
* metadata is included because it is part of that owner's authored contract.
*/
export function mechanicRuntimeOwnerKey(
owner: MechanicAuthoredOwner,
): MechanicRuntimeOwnerKey {
return JSON.stringify(
owner.type === 'talent'
? [owner.type, owner.id, owner.scope, Boolean(owner.hasTierII)]
: [owner.type, owner.id],
);
}
/** Produces a stable key before the source row supplies talent metadata. */
export function mechanicRuntimeOwnerReferenceKey(
owner: MechanicRuntimeOwnerReference,
): MechanicRuntimeOwnerReferenceKey {
return JSON.stringify([owner.type, owner.id]);
}
/** The generated ID is deliberately outside the stored canonical hash. */
export function mechanicRuntimeProgramId(sourceId: string, variantKey: string) {
return `mechanic-runtime:${JSON.stringify([sourceId, variantKey])}`;
}
function ownersMatch(
expected: MechanicAuthoredOwner,
actual: MechanicAuthoredOwner,
) {
if (expected.type !== actual.type || expected.id !== actual.id) return false;
return (
expected.type !== 'talent' ||
(actual.type === 'talent' &&
expected.scope === actual.scope &&
Boolean(expected.hasTierII) === Boolean(actual.hasTierII))
);
}
function hasCurrentVariantOrder(
ownerVariants: readonly { key: string }[],
selectedVariants: readonly { key: string }[],
) {
return (
ownerVariants.length === selectedVariants.length &&
ownerVariants.every(
(variant, index) => variant.key === selectedVariants[index]?.key,
)
);
}
async function loadAttributeLabelResolver(
database: Database,
): Promise<MechanicAttributeLabelResolver> {
const rows = await (
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
).all();
const labels = new Map<string, string>();
for (const row of rows) {
if (
typeof row.id === 'string' &&
row.id.trim() &&
typeof row.name === 'string' &&
row.name.trim()
) {
labels.set(row.id, row.name);
}
}
return (attributeId) => labels.get(attributeId) ?? null;
}
type RuntimeSnapshotRow = Record<string, unknown>;
type RuntimeSnapshot = {
sourceByOwner: Map<string, RuntimeSnapshotRow>;
variantsBySourceId: Map<string, RuntimeSnapshotRow[]>;
slotsBySourceId: Map<string, RuntimeSnapshotRow[]>;
selectionBySourceId: Map<string, RuntimeSnapshotRow>;
selectedRevisionBySourceId: Map<string, RuntimeSnapshotRow>;
poolsByRevisionId: Map<string, RuntimeSnapshotRow[]>;
clausesByRevisionId: Map<string, RuntimeSnapshotRow[]>;
outcomesByRevisionId: Map<string, RuntimeSnapshotRow[]>;
activeAttributes: RuntimeSnapshotRow[];
};
type RuntimeSnapshotCandidate = {
selection: MechanicRuntimeSelection;
token: string;
};
const MAX_STABLE_READ_ATTEMPTS = 2;
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function hasValidRuntimeIdentity(
source: MechanicAuthoredSourceRecord,
selected: MechanicModelRevisionRecord,
) {
return (
isNonEmptyString(source.id) &&
isNonEmptyString(source.fingerprint) &&
isNonEmptyString(selected.id) &&
isNonEmptyString(selected.sourceId) &&
Number.isFinite(selected.revisionNumber) &&
Number.isInteger(selected.revisionNumber) &&
selected.revisionNumber > 0
);
}
function runtimeSnapshotOwnerKey(type: unknown, id: unknown) {
return `${String(type)}:${String(id)}`;
}
function runtimeOwnerFromSnapshotRow(
row: RuntimeSnapshotRow,
): MechanicAuthoredOwner | null {
if (!isNonEmptyString(row.owner_id)) return null;
switch (row.owner_type) {
case 'talent':
if (row.talent_scope !== 'standard' && row.talent_scope !== 'exotic') {
return null;
}
return {
type: 'talent',
id: row.owner_id,
scope: row.talent_scope,
...(Number(row.talent_has_tier_ii ?? 0) === 1
? { hasTierII: true }
: {}),
};
case 'os_protocol_talent':
return { type: 'os_protocol_talent', id: row.owner_id };
case 'skill_chip_talent':
return { type: 'skill_chip_talent', id: row.owner_id };
case 'specialization_tree_rank':
return { type: 'specialization_tree_rank', id: row.owner_id };
default:
return null;
}
}
function mapFirstRowByStringColumn(
rows: readonly RuntimeSnapshotRow[],
column: string,
) {
const result = new Map<string, RuntimeSnapshotRow>();
for (const row of rows) {
const value = row[column];
if (typeof value === 'string' && !result.has(value)) {
result.set(value, row);
}
}
return result;
}
function mapRowsByStringColumn(
rows: readonly RuntimeSnapshotRow[],
column: string,
) {
const result = new Map<string, RuntimeSnapshotRow[]>();
for (const row of rows) {
const value = row[column];
if (typeof value !== 'string') continue;
result.set(value, [...(result.get(value) ?? []), row]);
}
return result;
}
function copiedRow(row: RuntimeSnapshotRow | undefined) {
return row === undefined ? undefined : { ...row };
}
function copiedRows(rows: RuntimeSnapshotRow[] | undefined) {
return (rows ?? []).map((row) => ({ ...row }));
}
async function loadRuntimeSnapshotRows(
database: Database,
sql: string,
ids: readonly string[],
): Promise<RuntimeSnapshotRow[]> {
return (await (
await database.prepare(sql)
).all(JSON.stringify(ids))) as RuntimeSnapshotRow[];
}
/**
* Reads every row the runtime validator needs in a bounded number of queries.
* These rows remain private input to the existing source and revision readers.
*/
async function loadRuntimeSnapshot(
database: Database,
owners: readonly MechanicRuntimeOwnerReference[],
): Promise<RuntimeSnapshot> {
const requestedOwners = [
...new Map(
owners
.filter((owner) => isNonEmptyString(owner.id))
.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), owner]),
).values(),
];
const sourceRows = (await (
await database.prepare(`SELECT source.*
FROM mechanic_authored_sources source
JOIN json_each(?) requested
ON source.owner_type=json_extract(requested.value,'$.type')
AND source.owner_id=json_extract(requested.value,'$.id')`)
).all(JSON.stringify(requestedOwners))) as RuntimeSnapshotRow[];
const sourceByOwner = new Map<string, RuntimeSnapshotRow>();
for (const source of sourceRows) {
if (
typeof source.owner_type === 'string' &&
typeof source.owner_id === 'string'
) {
sourceByOwner.set(
runtimeSnapshotOwnerKey(source.owner_type, source.owner_id),
source,
);
}
}
const sourceById = mapFirstRowByStringColumn(sourceRows, 'id');
const sourceIds = [...sourceById.keys()];
if (!sourceIds.length) {
return {
sourceByOwner,
variantsBySourceId: new Map(),
slotsBySourceId: new Map(),
selectionBySourceId: new Map(),
selectedRevisionBySourceId: new Map(),
poolsByRevisionId: new Map(),
clausesByRevisionId: new Map(),
outcomesByRevisionId: new Map(),
activeAttributes: [],
};
}
const [variantRows, slotRows, revisionRows, activeAttributes] = await Promise.all([
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_authored_variants
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,sort_order`,
sourceIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_authored_slots
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,variant_key,sort_order`,
sourceIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT
selection.source_id AS selection_source_id,
selection.revision_id AS selection_revision_id,
selection.updated_at AS selection_updated_at,
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 IN (SELECT value FROM json_each(?))`,
sourceIds,
),
(async () =>
(await (
await database.prepare(
'SELECT id,name FROM stat_attributes WHERE active=1',
)
).all()) as RuntimeSnapshotRow[])(),
]);
const revisionIds = [
...new Set(
revisionRows.flatMap((revision) =>
typeof revision.id === 'string' ? [revision.id] : [],
),
),
];
const [poolRows, clauseRows, outcomeRows] = revisionIds.length
? await Promise.all([
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_pools
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_clauses
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_outcomes
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order,variant_key`,
revisionIds,
),
])
: [[], [], []];
return {
sourceByOwner,
variantsBySourceId: mapRowsByStringColumn(variantRows, 'source_id'),
slotsBySourceId: mapRowsByStringColumn(slotRows, 'source_id'),
selectionBySourceId: mapFirstRowByStringColumn(
revisionRows,
'selection_source_id',
),
selectedRevisionBySourceId: mapFirstRowByStringColumn(
revisionRows,
'source_id',
),
poolsByRevisionId: mapRowsByStringColumn(poolRows, 'revision_id'),
clausesByRevisionId: mapRowsByStringColumn(clauseRows, 'revision_id'),
outcomesByRevisionId: mapRowsByStringColumn(outcomeRows, 'revision_id'),
activeAttributes,
};
}
/**
* Replays the exact source, selected-revision, and active-attribute queries
* against a private bulk snapshot. This retains the established single-owner
* validation and parser behavior without per-owner database fan-out.
*/
function runtimeSnapshotDatabase(snapshot: RuntimeSnapshot): Database {
return {
prepare: async (sql: string) => {
const normalizedSql = sql.replace(/\s+/g, ' ').trim();
const sourceId = (parameters: unknown[]) =>
typeof parameters[0] === 'string' ? parameters[0] : '';
const sourceForOwner = (parameters: unknown[]) => {
const [ownerType, ownerId] = parameters;
return typeof ownerType === 'string' && typeof ownerId === 'string'
? snapshot.sourceByOwner.get(runtimeSnapshotOwnerKey(ownerType, ownerId))
: undefined;
};
const selectedRevision = (parameters: unknown[]) =>
snapshot.selectedRevisionBySourceId.get(sourceId(parameters));
return {
get: async (...parameters: unknown[]) => {
if (
normalizedSql ===
'SELECT * FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?'
) {
return copiedRow(sourceForOwner(parameters));
}
if (
normalizedSql.startsWith(
'SELECT revision.* FROM mechanic_model_selections selection JOIN mechanic_model_revisions revision ',
)
) {
return copiedRow(selectedRevision(parameters));
}
throw new Error(
`Unsupported mechanic runtime snapshot query: ${normalizedSql}`,
);
},
all: async (...parameters: unknown[]) => {
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_authored_variants ',
)
) {
return copiedRows(
snapshot.variantsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith('SELECT * FROM mechanic_authored_slots ')
) {
return copiedRows(snapshot.slotsBySourceId.get(sourceId(parameters)));
}
if (
normalizedSql ===
'SELECT id,name FROM stat_attributes WHERE active=1'
) {
return copiedRows(snapshot.activeAttributes);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_pools ',
)
) {
return copiedRows(
snapshot.poolsByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_clauses ',
)
) {
return copiedRows(
snapshot.clausesByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_outcomes ',
)
) {
return copiedRows(
snapshot.outcomesByRevisionId.get(sourceId(parameters)),
);
}
throw new Error(
`Unsupported mechanic runtime snapshot query: ${normalizedSql}`,
);
},
};
},
} as unknown as Database;
}
async function loadVerifiedAuthoredSource(
database: Database,
owner: MechanicAuthoredOwner,
): Promise<{
source: MechanicAuthoredSourceRecord;
fingerprint: string;
} | null> {
const source = await loadMechanicAuthoredSource(
database,
owner.type,
owner.id,
);
if (!source || !ownersMatch(owner, source.source.owner)) return null;
const fingerprint = await hashMechanicAuthoredSource(source.source);
if (!fingerprint.ok || fingerprint.fingerprint !== source.fingerprint) {
return null;
}
return { source, fingerprint: fingerprint.fingerprint };
}
async function loadRuntimeSelection(
database: Database,
owner: MechanicAuthoredOwner,
): Promise<MechanicRuntimeSelection | null> {
const current = await loadVerifiedAuthoredSource(database, owner);
if (!current) return null;
// This reader intentionally never falls back to a latest or historical row.
const selected = await loadSelectedMechanicModelRevision(
database,
current.source.id,
);
if (
!selected ||
!hasValidRuntimeIdentity(current.source, selected) ||
selected.sourceId !== current.source.id ||
!hasCurrentVariantOrder(current.source.source.variants, selected.variants)
) {
return null;
}
const lifecycle = await classifyMechanicVariantLifecycle({
currentSourceFingerprint: current.fingerprint,
resolveAttributeLabel: await loadAttributeLabelResolver(database),
selectedRevision: {
sourceFingerprint: selected.sourceFingerprint,
mechanicContractVersion: selected.mechanicContractVersion,
programHash: selected.programHash,
simulationReadiness: selected.simulationReadiness,
variants: selected.variants,
},
});
if (lifecycle.status !== 'current') return null;
if (lifecycle.simulationReadiness === 'captured_only') return null;
return {
owner: current.source.source.owner,
sourceId: current.source.id,
sourceFingerprint: lifecycle.fingerprint,
revisionId: selected.id,
revisionNumber: selected.revisionNumber,
simulationReadiness: lifecycle.simulationReadiness,
scenarioInputs: selected.draft.scenarioInputs,
variants: selected.variants.map((variant) => ({
key: variant.key,
label: variant.label ?? variant.key,
// Stored programs are validated and hash-checked before this runtime-only
// generated identity is assigned.
program: {
...variant.program,
id: mechanicRuntimeProgramId(current.source.id, variant.key),
},
})),
};
}
/**
* Includes every executable input for one owner. A selection ID check alone is
* insufficient because revision rows are conventionally append-only rather
* than database-enforced immutable.
*/
function runtimeSnapshotToken(
snapshot: RuntimeSnapshot,
reference: MechanicRuntimeOwnerReference,
selection: MechanicRuntimeSelection,
): string | null {
const source = snapshot.sourceByOwner.get(
runtimeSnapshotOwnerKey(reference.type, reference.id),
);
if (!source || source.id !== selection.sourceId) return null;
const selected = snapshot.selectionBySourceId.get(selection.sourceId);
const revision = snapshot.selectedRevisionBySourceId.get(selection.sourceId);
if (
!selected ||
!revision ||
selected.selection_revision_id !== selection.revisionId ||
revision.id !== selection.revisionId ||
revision.source_id !== selection.sourceId
) {
return null;
}
return JSON.stringify({
source,
variants: snapshot.variantsBySourceId.get(selection.sourceId) ?? [],
slots: snapshot.slotsBySourceId.get(selection.sourceId) ?? [],
selection: selected,
revision,
pools: snapshot.poolsByRevisionId.get(selection.revisionId) ?? [],
clauses: snapshot.clausesByRevisionId.get(selection.revisionId) ?? [],
outcomes: snapshot.outcomesByRevisionId.get(selection.revisionId) ?? [],
attributes: snapshot.activeAttributes,
});
}
async function runtimeSnapshotCandidate(
snapshot: RuntimeSnapshot,
reference: MechanicRuntimeOwnerReference,
): Promise<RuntimeSnapshotCandidate | null> {
const sourceRow = snapshot.sourceByOwner.get(
runtimeSnapshotOwnerKey(reference.type, reference.id),
);
if (!sourceRow) return null;
const owner = runtimeOwnerFromSnapshotRow(sourceRow);
if (!owner) return null;
try {
const selection = await loadRuntimeSelection(
runtimeSnapshotDatabase(snapshot),
owner,
);
if (!selection) return null;
const token = runtimeSnapshotToken(snapshot, reference, selection);
return token ? { selection, token } : null;
} catch {
return null;
}
}
function uniqueRuntimeOwnerReferences(
owners: readonly MechanicRuntimeOwnerReference[],
) {
return [
...new Map(
owners
.filter((owner) => isNonEmptyString(owner.id))
.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), owner]),
).values(),
];
}
async function loadStableRuntimeSelections(
database: Database,
owners: readonly MechanicRuntimeOwnerReference[],
) {
const result = new Map<
MechanicRuntimeOwnerReferenceKey,
MechanicRuntimeSelection | null
>(owners.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), null]));
let pendingOwners = [...owners];
for (let attempt = 0; attempt < MAX_STABLE_READ_ATTEMPTS; attempt += 1) {
const initial = await loadRuntimeSnapshot(database, pendingOwners);
const candidates = await Promise.all(
pendingOwners.map((owner) => runtimeSnapshotCandidate(initial, owner)),
);
if (!candidates.some((candidate) => candidate !== null)) break;
const reloaded = await loadRuntimeSnapshot(database, pendingOwners);
const unstableOwners: MechanicRuntimeOwnerReference[] = [];
for (const [index, owner] of pendingOwners.entries()) {
const candidate = candidates[index];
if (!candidate) continue;
const reloadedToken = runtimeSnapshotToken(
reloaded,
owner,
candidate.selection,
);
if (candidate.token === reloadedToken) {
result.set(mechanicRuntimeOwnerReferenceKey(owner), candidate.selection);
} else {
unstableOwners.push(owner);
}
}
pendingOwners = unstableOwners;
if (!pendingOwners.length) break;
}
return result;
}
/**
* Bulk-hydrates selected runtimes when caller metadata has only an owner type
* and ID. The current source supplies talent scope and tier metadata, which is
* validated again while assembling every executable selection.
*/
export async function loadSelectedMechanicRuntimesForOwnerReferences(
database: Database,
owners: readonly MechanicRuntimeOwnerReference[],
): Promise<
Map<MechanicRuntimeOwnerReferenceKey, MechanicRuntimeSelection | null>
> {
const references = uniqueRuntimeOwnerReferences(owners);
const selections = new Map<
MechanicRuntimeOwnerReferenceKey,
MechanicRuntimeSelection | null
>(owners.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), null]));
if (!references.length) return selections;
try {
const stable = await loadStableRuntimeSelections(database, references);
for (const reference of references) {
selections.set(
mechanicRuntimeOwnerReferenceKey(reference),
stable.get(mechanicRuntimeOwnerReferenceKey(reference)) ?? null,
);
}
} catch {
// A failed bulk read has no executable fallback at this boundary.
}
return selections;
}
/**
* Loads only the explicitly selected revision for one typed owner. Any stale,
* unsupported, malformed, or captured-only record is unavailable to runtime.
*/
export async function loadSelectedMechanicRuntime(
database: Database,
owner: MechanicAuthoredOwner,
): Promise<MechanicRuntimeSelection | null> {
const selections = await loadSelectedMechanicRuntimes(database, [owner]);
return selections.get(mechanicRuntimeOwnerKey(owner)) ?? null;
}
/**
* Resolves each owner independently. A corrupt source remains null without
* suppressing healthy owners in the shared bounded runtime snapshot.
*/
export async function loadSelectedMechanicRuntimes(
database: Database,
owners: readonly MechanicAuthoredOwner[],
): Promise<Map<MechanicRuntimeOwnerKey, MechanicRuntimeSelection | null>> {
const byReference = await loadSelectedMechanicRuntimesForOwnerReferences(
database,
owners,
);
return new Map(
owners.map((owner) => {
const selection =
byReference.get(mechanicRuntimeOwnerReferenceKey(owner)) ?? null;
return [
mechanicRuntimeOwnerKey(owner),
selection && ownersMatch(owner, selection.owner) ? selection : null,
];
}),
);
}
+709
View File
@@ -0,0 +1,709 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
applyIconMutation,
deleteGameDataIcon,
gameDataIconStorageDirectory,
saveGameDataIcon,
} from './game-data-icons';
import {
applyGameDataRender,
deleteManagedImage,
managedImageStorageDirectory as renderStorageDirectory,
saveManagedImageFile,
} from './managed-images';
import {
MediaMutationJournal,
captureMediaFile,
restoreMediaFile,
} from './media-journal';
const iconId = '11111111-1111-4111-8111-111111111111';
const imageId = '33333333-3333-4333-8333-333333333333';
const alternateImageId = '44444444-4444-4444-8444-444444444444';
let database: Database;
let storageDirectory: string;
const previousStorageDirectory = process.env.TDR_GAME_ICON_STORAGE_PATH;
beforeEach(async () => {
storageDirectory = await mkdtemp(
path.join(os.tmpdir(), 'shd-media-journal-'),
);
process.env.TDR_GAME_ICON_STORAGE_PATH = storageDirectory;
database = await connect(':memory:');
await database.exec(`
CREATE TABLE talents (id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL);
CREATE TABLE talent_weapon_scopes (talent_id TEXT NOT NULL, scope TEXT NOT NULL);
CREATE TABLE game_data_icons (
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, storage_key TEXT,
byte_size INTEGER, updated_at TEXT, PRIMARY KEY(entity_type, entity_id)
);
CREATE TABLE managed_images (
id TEXT PRIMARY KEY, name TEXT, category TEXT, tags_json TEXT, is_icon INTEGER,
force_white INTEGER, storage_key TEXT, original_filename TEXT, byte_size INTEGER,
width INTEGER, height INTEGER, source_entity_type TEXT, source_entity_id TEXT,
created_by TEXT, created_at TEXT, updated_at TEXT
);
`);
await database.exec(
`INSERT INTO talents(id,name,category) VALUES('${iconId}','Test Talent','Weapon')`,
);
});
afterEach(async () => {
await database.close();
await rm(storageDirectory, { recursive: true, force: true });
if (previousStorageDirectory === undefined)
delete process.env.TDR_GAME_ICON_STORAGE_PATH;
else process.env.TDR_GAME_ICON_STORAGE_PATH = previousStorageDirectory;
});
const iconPath = () =>
path.join(gameDataIconStorageDirectory(), `talent-${iconId}.webp`);
const renderPath = (key: string) => path.join(renderStorageDirectory(), key);
const renderMutation = (output: string, fileName = 'render.png') => ({
file: new File(['source'], fileName, { type: 'image/png' }),
remove: false,
processed: { output: Buffer.from(output), width: 1000, height: 1000 },
});
const flushTasks = () => new Promise<void>((resolve) => setImmediate(resolve));
const seedRender = async (sourceType = 'talent_render') => {
const key = `library-${imageId}.webp`;
await mkdir(renderStorageDirectory(), { recursive: true });
await writeFile(renderPath(key), Buffer.from('old-render'), { flag: 'w' });
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
imageId,
'Old render',
'Talents',
'[]',
0,
0,
key,
'old.png',
10,
1000,
1000,
sourceType,
iconId,
'operator',
'old',
'old',
);
};
describe('MediaMutationJournal', () => {
it('runs compensations in LIFO order once', async () => {
const journal = new MediaMutationJournal();
const events: string[] = [];
journal.register(async () => {
events.push('first');
});
journal.register(async () => {
events.push('second');
});
await journal.rollback(new Error('operation failed'));
await journal.rollback(new Error('operation failed again'));
expect(events).toEqual(['second', 'first']);
});
it('aggregates the operation and compensation failures', async () => {
const journal = new MediaMutationJournal();
const original = new Error('audit failed');
const compensation = new Error('restore failed');
journal.register(async () => {
throw compensation;
});
let failure: unknown;
try {
await journal.rollback(original);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).errors).toEqual([
original,
compensation,
]);
});
it('restores exact bytes and absence', async () => {
const filePath = path.join(storageDirectory, 'snapshot.webp');
await writeFile(filePath, Buffer.from('before'));
const snapshot = await captureMediaFile(filePath);
await writeFile(filePath, Buffer.from('after'));
await restoreMediaFile(filePath, snapshot);
expect(await readFile(filePath, 'utf8')).toBe('before');
const absentPath = path.join(storageDirectory, 'absent.webp');
const absent = await captureMediaFile(absentPath);
await writeFile(absentPath, Buffer.from('new'));
await restoreMediaFile(absentPath, absent);
await expect(readFile(absentPath)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('does not deadlock when a journal locks a path repeatedly', async () => {
const journal = new MediaMutationJournal();
const filePath = path.join(storageDirectory, 'repeat.webp');
await journal.lockPath(filePath);
await journal.lockPath(filePath);
journal.clear();
});
it('releases a path when rollback compensation throws', async () => {
const first = new MediaMutationJournal();
const second = new MediaMutationJournal();
const filePath = path.join(storageDirectory, 'throwing.webp');
await first.lockPath(filePath);
first.register(() => {
throw new Error('restore failed');
});
const waiting = second.lockPath(filePath);
await expect(first.rollback(new Error('operation failed'))).rejects.toThrow(
'Media rollback did not complete.',
);
await waiting;
second.clear();
});
it('starts legacy DB work before waiting on media across connections', async () => {
const databasePath = path.join(storageDirectory, 'deadlock.db');
const firstDatabase = await connect(databasePath);
const secondDatabase = await connect(databasePath);
const storageKey = `library-${imageId}.webp`;
const filePath = renderPath(storageKey);
let beginResolved!: () => void;
const beginStarted = new Promise<void>((resolve) => {
beginResolved = resolve;
});
try {
await firstDatabase.exec(`
CREATE TABLE managed_images (
id TEXT PRIMARY KEY, name TEXT, category TEXT, tags_json TEXT,
is_icon INTEGER, force_white INTEGER, storage_key TEXT,
original_filename TEXT, byte_size INTEGER, width INTEGER, height INTEGER,
source_entity_type TEXT, source_entity_id TEXT, created_by TEXT,
created_at TEXT, updated_at TEXT
);
`);
await (
await firstDatabase.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
imageId,
'Render',
'Talents',
'[]',
0,
0,
storageKey,
'old.png',
10,
1000,
1000,
'talent_render',
iconId,
'operator',
'old',
'old',
);
await mkdir(renderStorageDirectory(), { recursive: true });
await writeFile(filePath, Buffer.from('old-render'));
const legacyDatabase = {
exec: async (sql: string) => {
const result = await secondDatabase.exec(sql);
if (sql === 'BEGIN IMMEDIATE') beginResolved();
return result;
},
prepare: (sql: string) => secondDatabase.prepare(sql),
} as unknown as Database;
const holder = new MediaMutationJournal();
await holder.lockPath(filePath);
const deletion = deleteManagedImage(legacyDatabase, imageId);
await Promise.race([
beginStarted,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('legacy DB transaction did not start')),
1000,
),
),
]);
await expect(readFile(filePath, 'utf8')).resolves.toBe('old-render');
holder.clear();
await deletion;
await expect(readFile(filePath)).rejects.toMatchObject({
code: 'ENOENT',
});
} finally {
await firstDatabase.close();
await secondDatabase.close();
}
});
});
describe('game-data icon media rollback', () => {
it.each([
{
label: 'create',
before: null,
mutation: { output: Buffer.from('new-icon'), remove: false },
},
{
label: 'replace',
before: Buffer.from('old-icon'),
mutation: { output: Buffer.from('new-icon'), remove: false },
},
{
label: 'remove',
before: Buffer.from('old-icon'),
mutation: { output: null, remove: true },
},
])(
'$label restores after a later operation failure',
async ({ before, mutation }) => {
if (before) {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath(), before);
await (
await database.prepare(
'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)',
)
).run('talent', iconId, `talent-${iconId}.webp`, before.length, 'old');
}
const journal = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
mutation,
'operator',
journal,
);
if (before) {
if (mutation.remove)
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
else expect(await readFile(iconPath())).toEqual(mutation.output);
} else expect(await readFile(iconPath())).toEqual(mutation.output);
await journal.rollback(new Error('audit failed'));
if (before) expect(await readFile(iconPath())).toEqual(before);
else
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
},
);
it('retains a new icon after commit', async () => {
const journal = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('committed-icon'), remove: false },
'operator',
journal,
);
journal.clear();
expect(await readFile(iconPath(), 'utf8')).toBe('committed-icon');
});
it('waits for an earlier journal to clear before mutating the icon', async () => {
const first = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('first-icon'), remove: false },
'operator',
first,
);
const second = new MediaMutationJournal();
const mutation = applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('second-icon'), remove: false },
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(iconPath(), 'utf8')).toBe('first-icon');
first.clear();
await mutation;
expect(await readFile(iconPath(), 'utf8')).toBe('second-icon');
second.clear();
});
it('does not let rollback overwrite a later serialized icon mutation', async () => {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath(), Buffer.from('old-icon'));
await (
await database.prepare(
'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)',
)
).run('talent', iconId, `talent-${iconId}.webp`, 8, 'old');
const first = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('first-icon'), remove: false },
'operator',
first,
);
const second = new MediaMutationJournal();
const mutation = applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('second-icon'), remove: false },
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(iconPath(), 'utf8')).toBe('first-icon');
await first.rollback(new Error('transaction failed'));
expect(await readFile(iconPath(), 'utf8')).toBe('old-icon');
await mutation;
expect(await readFile(iconPath(), 'utf8')).toBe('second-icon');
second.clear();
});
it('also serializes a legacy icon writer without a journal', async () => {
const first = new MediaMutationJournal();
await first.lockPath(iconPath());
const mutation = saveGameDataIcon(
database,
'talent',
iconId,
Buffer.from('legacy-icon'),
'operator',
);
await flushTasks();
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
first.clear();
await mutation;
expect(await readFile(iconPath(), 'utf8')).toBe('legacy-icon');
});
it('also serializes a legacy icon deleter without a journal', async () => {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath(), Buffer.from('old-icon'));
await (
await database.prepare(
'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)',
)
).run('talent', iconId, `talent-${iconId}.webp`, 8, 'old');
const first = new MediaMutationJournal();
await first.lockPath(iconPath());
const deletion = deleteGameDataIcon(database, 'talent', iconId);
await flushTasks();
expect(await readFile(iconPath(), 'utf8')).toBe('old-icon');
first.clear();
await deletion;
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
});
});
describe('managed render media rollback', () => {
it('removes a newly created render after a later operation failure', async () => {
const journal = new MediaMutationJournal();
const result = await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('new-render'),
'operator',
journal,
);
const key = String(result?.storage_key);
expect(await readFile(renderPath(key), 'utf8')).toBe('new-render');
await journal.rollback(new Error('audit failed'));
await expect(readFile(renderPath(key))).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('restores replacement and removal renders after a later operation failure', async () => {
await seedRender();
const replacement = new MediaMutationJournal();
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('replacement'),
'operator',
replacement,
);
expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe(
'replacement',
);
await replacement.rollback(new Error('audit failed'));
expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe(
'old-render',
);
const removal = new MediaMutationJournal();
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
{ file: null, remove: true },
'operator',
removal,
);
await removal.rollback(new Error('audit failed'));
expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe(
'old-render',
);
});
it('cleans up a new file if compensation registration is unavailable', async () => {
const journal = new MediaMutationJournal();
journal.clear();
await expect(
saveManagedImageFile(imageId, Buffer.from('orphan'), journal),
).rejects.toThrow('Cannot register media compensation after commit.');
await expect(
readFile(renderPath(`library-${imageId}.webp`)),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('does not register or delete a pre-existing file after an exclusive-create collision', async () => {
await mkdir(renderStorageDirectory(), { recursive: true });
const filePath = renderPath(`library-${imageId}.webp`);
await writeFile(filePath, Buffer.from('pre-existing'));
const journal = new MediaMutationJournal();
await expect(
saveManagedImageFile(imageId, Buffer.from('replacement'), journal),
).rejects.toMatchObject({ code: 'EEXIST' });
await journal.rollback(new Error('collision'));
expect(await readFile(filePath, 'utf8')).toBe('pre-existing');
});
it('retains a new render after commit', async () => {
const journal = new MediaMutationJournal();
const result = await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('committed-render'),
'operator',
journal,
);
const key = String(result?.storage_key);
journal.clear();
expect(await readFile(renderPath(key), 'utf8')).toBe('committed-render');
});
it('waits for an earlier journal to clear before mutating a render', async () => {
await seedRender();
const first = new MediaMutationJournal();
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('first-render'),
'operator',
first,
);
const second = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
const mutation = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('second-render'),
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(filePath, 'utf8')).toBe('first-render');
first.clear();
await mutation;
expect(await readFile(filePath, 'utf8')).toBe('second-render');
second.clear();
});
it('does not let rollback overwrite a later serialized render mutation', async () => {
await seedRender();
const first = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('first-render'),
'operator',
first,
);
const second = new MediaMutationJournal();
const mutation = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('second-render'),
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(filePath, 'utf8')).toBe('first-render');
await first.rollback(new Error('transaction failed'));
expect(await readFile(filePath, 'utf8')).toBe('old-render');
await mutation;
expect(await readFile(filePath, 'utf8')).toBe('second-render');
second.clear();
});
it('also serializes a legacy managed-image writer without a journal', async () => {
const first = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
await first.lockPath(filePath);
const mutation = saveManagedImageFile(
imageId,
Buffer.from('legacy-render'),
);
await flushTasks();
await expect(readFile(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
first.clear();
await mutation;
expect(await readFile(filePath, 'utf8')).toBe('legacy-render');
});
it('also serializes a legacy managed-image deleter without a journal', async () => {
await seedRender();
const first = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
await first.lockPath(filePath);
const deletion = deleteManagedImage(database, imageId);
await flushTasks();
expect(await readFile(filePath, 'utf8')).toBe('old-render');
first.clear();
await deletion;
await expect(readFile(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('revalidates the render path before a waiting removal', async () => {
await seedRender();
const oldPath = renderPath(`library-${imageId}.webp`);
const alternateKey = `library-${alternateImageId}.webp`;
const alternatePath = renderPath(alternateKey);
const first = new MediaMutationJournal();
await first.lockPath(oldPath);
const second = new MediaMutationJournal();
const waiting = second.lockPath(oldPath);
const removal = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
{ file: null, remove: true },
'operator',
second,
);
await flushTasks();
await flushTasks();
await writeFile(alternatePath, Buffer.from('current-render'));
await (
await database.prepare(
'UPDATE managed_images SET storage_key=? WHERE id=?',
)
).run(alternateKey, imageId);
first.clear();
await waiting;
await removal;
await expect(readFile(alternatePath)).rejects.toMatchObject({
code: 'ENOENT',
});
const removed = await (
await database.prepare('SELECT id FROM managed_images WHERE id=?')
).get(imageId);
expect(removed).toBeUndefined();
await expect(readFile(oldPath, 'utf8')).resolves.toBe('old-render');
second.clear();
});
it('revalidates the render path before a waiting replacement', async () => {
await seedRender();
const oldPath = renderPath(`library-${imageId}.webp`);
const alternateKey = `library-${alternateImageId}.webp`;
const alternatePath = renderPath(alternateKey);
const first = new MediaMutationJournal();
await first.lockPath(oldPath);
const second = new MediaMutationJournal();
const waiting = second.lockPath(oldPath);
const replacement = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('second-render'),
'operator',
second,
);
await flushTasks();
await flushTasks();
await writeFile(alternatePath, Buffer.from('current-render'));
await (
await database.prepare(
'UPDATE managed_images SET storage_key=? WHERE id=?',
)
).run(alternateKey, imageId);
first.clear();
await waiting;
const result = await replacement;
expect(result).toMatchObject({ id: imageId, storage_key: alternateKey });
expect(await readFile(alternatePath, 'utf8')).toBe('second-render');
expect(await readFile(oldPath, 'utf8')).toBe('old-render');
second.clear();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
export type MediaFileSnapshot = {
exists: boolean;
bytes: Buffer | null;
};
/** Captures a media file exactly so a later transaction failure can restore it. */
export async function captureMediaFile(
filePath: string,
): Promise<MediaFileSnapshot> {
try {
return { exists: true, bytes: await readFile(filePath) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
return { exists: false, bytes: null };
throw error;
}
}
/** Restores a snapshot captured by captureMediaFile. */
export async function restoreMediaFile(
filePath: string,
snapshot: MediaFileSnapshot,
): Promise<void> {
if (snapshot.exists) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, snapshot.bytes ?? Buffer.alloc(0), {
flag: 'w',
mode: 0o600,
});
return;
}
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
type Compensation = () => void | Promise<void>;
export type ReleaseMediaPathLock = () => void;
/**
* A process-local queue for each storage path. The queue promise represents
* the next holder, so releasing an earlier holder wakes only the next waiter.
*/
const mediaPathQueues = new Map<string, Promise<void>>();
export async function acquireMediaPathLock(
filePath: string,
): Promise<ReleaseMediaPathLock> {
const key = path.resolve(filePath);
const previous = mediaPathQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const next = new Promise<void>((resolve) => {
releaseNext = resolve;
});
mediaPathQueues.set(key, next);
await previous;
let released = false;
return () => {
if (released) return;
released = true;
if (mediaPathQueues.get(key) === next) mediaPathQueues.delete(key);
releaseNext();
};
}
/**
* Per-operation LIFO filesystem rollback state. A journal is deliberately
* passed through the save call graph rather than stored in ambient state.
*/
export class MediaMutationJournal {
private compensations: Compensation[] = [];
private committed = false;
private rolledBack = false;
private rollbackPromise: Promise<void> | null = null;
private pathLocks = new Map<string, Promise<ReleaseMediaPathLock>>();
private locksReleased = false;
/**
* Acquires a path for this journal. Repeated requests for the same path are
* idempotent, including while the first request is still waiting in queue.
*/
async lockPath(filePath: string): Promise<void> {
if (this.committed)
throw new Error('Cannot register media compensation after commit.');
if (this.rolledBack)
throw new Error('Cannot register media compensation after rollback.');
const key = path.resolve(filePath);
let lock = this.pathLocks.get(key);
if (!lock) {
lock = acquireMediaPathLock(key);
this.pathLocks.set(key, lock);
}
const release = await lock;
if (this.committed || this.rolledBack) {
release();
throw new Error(
this.committed
? 'Cannot register media compensation after commit.'
: 'Cannot register media compensation after rollback.',
);
}
}
register(compensation: Compensation): void {
if (this.committed)
throw new Error('Cannot register media compensation after commit.');
if (this.rolledBack)
throw new Error('Cannot register media compensation after rollback.');
this.compensations.push(compensation);
}
/** Releases all path locks after a successful operation. */
clear(): void {
if (this.rolledBack || this.committed) return;
this.compensations = [];
this.committed = true;
this.releasePathLocks();
}
commit(): void {
this.clear();
}
/**
* Runs each compensation once in reverse order. The original failure is
* included first when any compensation also fails, preserving both causes.
*/
async rollback(originalError?: unknown): Promise<void> {
if (this.committed) return;
if (this.rollbackPromise) return this.rollbackPromise;
this.rolledBack = true;
const compensations = this.compensations.splice(0).reverse();
this.rollbackPromise = this.runRollback(compensations, originalError);
return this.rollbackPromise;
}
private releasePathLocks(): void {
if (this.locksReleased) return;
this.locksReleased = true;
const locks = [...this.pathLocks.values()];
this.pathLocks.clear();
for (const lock of locks) void lock.then((release) => release());
}
private async runRollback(
compensations: Compensation[],
originalError: unknown,
): Promise<void> {
const failures: unknown[] = [];
try {
for (const compensation of compensations) {
try {
await compensation();
} catch (error) {
failures.push(error);
}
}
if (failures.length) {
const causes =
originalError === undefined ? failures : [originalError, ...failures];
throw new AggregateError(causes, 'Media rollback did not complete.');
}
} finally {
this.releasePathLocks();
}
}
}
+306
View File
@@ -0,0 +1,306 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BuildMechanicRuntimes } from './build-mechanic-runtime';
import type { MechanicRuntimeSelection } from './mechanic-runtime-selection';
type HydratedCombatEffect = {
id: string;
definition: { id: string };
runtime: MechanicRuntimeSelection | null;
};
const mocks = vi.hoisted(() => ({
loadRuntimes: vi.fn(),
}));
vi.mock('./build-mechanic-runtime', () => ({
loadBuildMechanicRuntimes: mocks.loadRuntimes,
}));
import { loadSharedBuildSummaryCatalog } from './shared-build-summary';
const selected: MechanicRuntimeSelection = {
owner: { type: 'talent', id: 'source', scope: 'standard', hasTierII: true },
sourceId: 'source',
sourceFingerprint: 'a'.repeat(64),
revisionId: 'revision',
revisionNumber: 1,
simulationReadiness: 'requires_assumptions',
scenarioInputs: [
{
key: 'nearby-enemies',
label: 'Nearby enemies',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
],
variants: [
{
key: 'base',
label: 'BASE',
program: {
id: 'mechanic-runtime:["source","base"]',
name: 'Selected',
scenarioInputs: [
{
key: 'nearby-enemies',
label: 'Nearby enemies',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
],
clauses: [],
},
},
],
};
const runtimes: BuildMechanicRuntimes = {
talents: { 'talent-1': selected },
protocolTalents: { 'protocol-talent-1': selected },
skillChipTalents: { 'chip-talent-1': selected },
specializationAttachments: {
'attachment-1': selected,
'attachment-2': selected,
},
};
const applySchema = async (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 seedSpecializationAttachments = async (database: Database) => {
await database.exec(`
INSERT INTO specializations(id,name,created_at,updated_at) VALUES
('specialization-parent','Parent specialization','now','now'),
('specialization-cross','Cross specialization','now','now');
INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES('focus-parent','specialization-parent','Parent focus','Firepower',0,'now','now');
INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES('node-parent','focus-parent','Parent node','',1,1,1,'now','now');
INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-parent',1,'');
INSERT INTO specialization_combat_effects(
id,specialization_id,name,description_template,created_at,updated_at
) VALUES
('effect-valid-first','specialization-parent','Valid first','', 'now','now'),
('effect-valid-second','specialization-parent','Valid second','', 'now','now'),
('effect-cross','specialization-cross','Cross specialization effect','', 'now','now');
INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES
('attachment-valid-first','node-parent',1,'effect-valid-first',1,
'enemy_hits_within_window',1,1,1,1),
('attachment-valid-second','node-parent',1,'effect-valid-second',2,
'enemy_hits_within_window',1,1,1,1),
('attachment-cross','node-parent',1,'effect-cross',3,
'enemy_hits_within_window',1,1,1,1);
`);
};
function catalogDatabase() {
const prepare = vi.fn(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM talents '))
return [{ id: 'talent-1', name: 'Talent' }];
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set',
name: 'Chip',
specialization_ability_id: 'ability',
},
];
if (sql.includes('FROM skill_chip_talents'))
return [
{
id: 'chip-talent-1',
skill_chip_set_id: 'chip-set',
name: 'Chip Talent',
},
];
if (sql.includes('FROM os_protocols'))
return [
{
id: 'protocol-1',
name: 'Protocol',
quality: 'High-end',
primary_attribute_type: 'Health',
},
];
if (sql.includes('FROM os_protocol_talents'))
return [
{
id: 'protocol-talent-1',
protocol_id: 'protocol-1',
name: 'Protocol Talent',
},
];
if (sql.includes('FROM specializations '))
return [{ id: 'specialization-1', name: 'Spec' }];
if (sql.includes('FROM specialization_focuses'))
return [
{
id: 'focus-1',
specialization_id: 'specialization-1',
display_order: 1,
name: 'Focus',
},
];
if (sql.includes('FROM specialization_tree_nodes'))
return [
{
id: 'node-1',
focus_id: 'focus-1',
row_number: 1,
column_number: 1,
},
];
if (sql.includes('FROM specialization_tree_node_ranks'))
return [{ node_id: 'node-1', rank: 1 }];
if (sql.includes('FROM specialization_tree_rank_combat_effects'))
return [
{
id: 'attachment-1',
node_id: 'node-1',
rank: 1,
combat_effect_id: 'reusable-effect',
effect_order: 1,
definition_name: 'First effect',
},
{
id: 'attachment-2',
node_id: 'node-1',
rank: 1,
combat_effect_id: 'reusable-effect',
effect_order: 2,
definition_name: 'Second effect',
},
];
return [];
}),
}));
return { prepare } as unknown as Database;
}
describe('shared Build summary mechanic hydration', () => {
beforeEach(() => {
mocks.loadRuntimes.mockReset().mockResolvedValue(runtimes);
});
it('uses the shared selected-runtime capability for all owners and active attachment order', async () => {
const database = catalogDatabase();
const catalog = await loadSharedBuildSummaryCatalog(database);
expect(catalog.mechanics).toBe(runtimes);
expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, {
talentIds: ['talent-1'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1', 'attachment-2'],
});
expect(catalog.talents[0]?.runtime).toBe(selected);
expect(catalog.chipSets[0]?.talent?.runtime).toBe(selected);
expect(catalog.protocols[0]?.talent?.runtime).toBe(selected);
const effects = catalog.specializations[0]?.focuses[0]?.nodes[0]?.ranks[0]
?.combatEffects as
| Array<{
id: string;
runtime: MechanicRuntimeSelection | null;
definition: { id: string };
}>
| undefined;
expect(effects?.map((effect) => effect.id)).toEqual([
'attachment-1',
'attachment-2',
]);
expect(effects?.map((effect) => effect.runtime)).toEqual([
selected,
selected,
]);
expect(
effects?.every((effect) => effect.definition.id === 'reusable-effect'),
).toBe(true);
expect(
(database.prepare as ReturnType<typeof vi.fn>).mock.calls.some(([sql]) =>
String(sql).includes('ORDER BY specialization.id,focus.display_order'),
),
).toBe(true);
});
});
describe('shared Build specialization combat-effect ownership', () => {
let database: Database;
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await seedSpecializationAttachments(database);
});
afterEach(async () => {
await database.close();
});
it('hydrates only same-specialization effects in attachment order and does not select corrupt attachment runtimes', async () => {
const firstRuntime = { ...selected, sourceId: 'first-runtime' };
const secondRuntime = { ...selected, sourceId: 'second-runtime' };
mocks.loadRuntimes.mockReset().mockResolvedValue({
talents: {},
protocolTalents: {},
skillChipTalents: {},
specializationAttachments: {
'attachment-valid-first': firstRuntime,
'attachment-valid-second': secondRuntime,
},
} satisfies BuildMechanicRuntimes);
const catalog = await loadSharedBuildSummaryCatalog(database);
expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, {
talentIds: [],
protocolTalentIds: [],
skillChipTalentIds: [],
specializationAttachmentIds: [
'attachment-valid-first',
'attachment-valid-second',
],
});
const parentSpecialization = catalog.specializations.find(
(specialization: { id: string }) =>
specialization.id === 'specialization-parent',
);
const combatEffects = (parentSpecialization?.focuses[0]?.nodes[0]?.ranks[0]
?.combatEffects ?? []) as HydratedCombatEffect[];
expect(combatEffects.map((effect) => effect.id)).toEqual([
'attachment-valid-first',
'attachment-valid-second',
]);
expect(combatEffects.map((effect) => effect.definition.id)).toEqual([
'effect-valid-first',
'effect-valid-second',
]);
expect(combatEffects.map((effect) => effect.runtime)).toEqual([
firstRuntime,
secondRuntime,
]);
});
});
+48
View File
@@ -1,6 +1,7 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
import { loadBuildMechanicRuntimes } from './build-mechanic-runtime';
export async function loadSharedBuildSummaryCatalog(database: Database) {
const [
@@ -37,6 +38,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationRankCombatEffects,
] = await Promise.all([
(
await database.prepare(
@@ -172,9 +174,35 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(`SELECT attachment.*,effect.name AS definition_name
FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_node_ranks rank
ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specializations specialization
ON specialization.id=focus.specialization_id AND specialization.active=1
JOIN specialization_combat_effects effect
ON effect.id=attachment.combat_effect_id
AND effect.specialization_id=focus.specialization_id
AND effect.active=1
ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE,
node.row_number,node.column_number,attachment.rank,
attachment.effect_order,attachment.id`)
).all(),
]);
const mechanics = await loadBuildMechanicRuntimes(database, {
talentIds: talents.map((talent) => String(talent.id)),
protocolTalentIds: protocolTalents.map((talent) => String(talent.id)),
skillChipTalentIds: chipTalents.map((talent) => String(talent.id)),
specializationAttachmentIds: specializationRankCombatEffects.map((effect) =>
String(effect.id),
),
});
return {
mechanics,
attributes,
gear,
gearAugments,
@@ -187,6 +215,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
talents: talents.map((talent) => ({
...talent,
runtime: mechanics.talents[String(talent.id)] ?? null,
template:
talentTemplates.find((item) => item.talent_id === talent.id)
?.template ?? '',
@@ -231,6 +260,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talent: talent
? {
...talent,
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
@@ -260,6 +290,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talent: talent
? {
...talent,
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
@@ -298,6 +329,23 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
),
combatEffects: specializationRankCombatEffects
.filter(
(effect) =>
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
)
.map((effect) => ({
...effect,
definition: {
id: String(effect.combat_effect_id),
name: String(effect.definition_name),
},
runtime:
mechanics.specializationAttachments[
String(effect.id)
] ?? null,
})),
})),
})),
})),
+253 -5
View File
@@ -4,6 +4,7 @@ import { createDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
loadCatalog: vi.fn(),
summarizeLoadout: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
@@ -14,12 +15,101 @@ vi.mock('$lib/server/database', () => ({
})),
})),
}));
vi.mock('$lib/builder/loadout-summary', () => ({
summarizeLoadout: mocks.summarizeLoadout,
}));
vi.mock('$lib/server/shared-build-summary', () => ({
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
}));
import { loadSharedBuildProjection } from './shared-build';
const selectedRuntime = {
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
sourceId: 'talent-source',
sourceFingerprint: 'f'.repeat(64),
revisionId: 'talent-revision',
revisionNumber: 4,
simulationReadiness: 'ready',
scenarioInputs: [
{ key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' },
],
variants: [
{
key: 'I',
label: 'Tier I',
program: {
id: 'mechanic-runtime:["talent-source","I"]',
name: 'Selected mechanic',
scenarioInputs: [
{ key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' },
],
pools: [{ key: 'stacks', label: 'Stacks', min: 0, max: 10 }],
clauses: [
{
key: 'on-kill',
outcomes: [
{
key: 'damage',
variants: { I: { value: 15 } },
},
],
},
],
},
},
],
};
const mechanics = {
talents: { 'talent-1': selectedRuntime },
protocolTalents: { 'protocol-talent-1': selectedRuntime },
skillChipTalents: { 'chip-talent-1': selectedRuntime },
specializationAttachments: { 'attachment-1': selectedRuntime },
};
const mechanicInternalKeys = new Set([
'mechanics',
'runtime',
'sourceId',
'sourceFingerprint',
'revisionId',
'revisionNumber',
'simulationReadiness',
'variants',
'key',
'program',
'scenarioInputs',
'pools',
'clauses',
'outcomes',
'programHash',
'mechanicContractVersion',
'selectedRevisionId',
'source_id',
'source_fingerprint',
'revision_id',
'revision_number',
'simulation_readiness',
'scenario_inputs',
'program_hash',
'mechanic_contract_version',
'selected_revision_id',
]);
function serializedMechanicInternals(value: unknown, path = '$'): string[] {
if (Array.isArray(value))
return value.flatMap((entry, index) =>
serializedMechanicInternals(entry, `${path}[${index}]`),
);
if (!value || typeof value !== 'object') return [];
return Object.entries(value).flatMap(([key, entry]) => [
...(mechanicInternalKeys.has(key) ? [`${path}.${key}`] : []),
...serializedMechanicInternals(entry, `${path}.${key}`),
]);
}
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
return {
id: 'build-id',
@@ -37,16 +127,101 @@ function buildRow(visibility: 'private' | 'unlisted' | 'public') {
describe('shared Build access policy', () => {
beforeEach(() => {
mocks.row = null;
mocks.summarizeLoadout.mockReset().mockReturnValue({
stats: [{ id: 'firepower', value: 42, sourceCount: 3 }],
abilityStats: [
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-power',
value: 18,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set-1',
value: 18,
},
],
},
],
},
],
activeBonuses: [
{
name: 'Medic',
pieces: 2,
count: 3,
statId: 'skill-power',
value: 10,
runtime: selectedRuntime,
},
],
equippedTalents: [
{
key: 'gear:Mask:talent-1',
id: 'talent-1',
runtime: selectedRuntime,
tier: 2,
tierLabel: 'TIER II',
source: 'Mask',
description: 'Restore 15% Armor.',
custom: {
name: 'Medic Protocol',
runtime: selectedRuntime,
},
},
],
runtime: selectedRuntime,
});
mocks.loadCatalog.mockReset().mockResolvedValue({
attributes: [],
attributes: [
{
id: 'firepower',
name: 'Firepower',
unit: '%',
category: 'Core',
runtime: selectedRuntime,
},
],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
talents: [
{
id: 'talent-1',
name: 'Field Medic',
cooldown_seconds: 12,
runtime: selectedRuntime,
tiers: [{ runtime: selectedRuntime }],
},
],
chipSets: [
{ id: 'chip-set-1', talent: { runtime: selectedRuntime } },
],
protocols: [
{ id: 'protocol-1', talent: { runtime: selectedRuntime } },
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
nodes: [
{
ranks: [
{ combatEffects: [{ runtime: selectedRuntime }] },
],
},
],
},
],
},
],
mechanics,
});
});
@@ -59,9 +234,82 @@ describe('shared Build access policy', () => {
});
expect(result?.build.visibility).toBe(visibility);
expect(result?.build.owner).toBe(false);
expect(result).not.toHaveProperty('mechanics');
},
);
it('serializes rendered Build fields without selected Mechanic Programs', async () => {
mocks.row = buildRow('public');
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
});
expect(mocks.summarizeLoadout).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
mechanics,
talents: [expect.objectContaining({ runtime: selectedRuntime })],
}),
);
expect(result?.summary).toEqual({
stats: [{ id: 'firepower', value: 42, sourceCount: 3 }],
abilityStats: [
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-power',
value: 18,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set-1',
value: 18,
},
],
},
],
},
],
activeBonuses: [
{
name: 'Medic',
pieces: 2,
count: 3,
statId: 'skill-power',
value: 10,
},
],
equippedTalents: [
{
id: 'talent-1',
tier: 2,
tierLabel: 'TIER II',
source: 'Mask',
description: 'Restore 15% Armor.',
custom: { name: 'Medic Protocol' },
},
],
});
expect(result?.attributes).toEqual([
{
id: 'firepower',
name: 'Firepower',
unit: '%',
category: 'Core',
},
]);
expect(result?.talents).toEqual([
{ id: 'talent-1', name: 'Field Medic', cooldown_seconds: 12 },
]);
expect(serializedMechanicInternals(JSON.parse(JSON.stringify(result)))).toEqual(
[],
);
});
it('does not expose private Builds to public previews or other viewers', async () => {
mocks.row = buildRow('private');
await expect(
+86 -9
View File
@@ -1,6 +1,9 @@
import { avatarUrl } from '$lib/avatar';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import {
summarizeLoadout,
type LoadoutSummary,
} from '$lib/builder/loadout-summary';
import { getDatabase } from '$lib/server/database';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
@@ -8,6 +11,63 @@ export type SharedBuildAccessPolicy =
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
type BuildVisibility = 'public' | 'unlisted' | 'private';
function publicFields<
Value extends Record<string, unknown>,
Field extends keyof Value,
>(value: Value, fields: readonly Field[]): Value {
return Object.fromEntries(
fields.map((field) => [field, value[field]]),
) as Value;
}
/**
* Builds are calculated with mechanic runtimes, but shared pages render only
* known values and materialized descriptions. Keep this as a whitelist so a
* future catalog or summary field cannot accidentally serialize a program.
*/
function publicLoadoutSummary(summary: LoadoutSummary): LoadoutSummary {
return {
stats: summary.stats.map((stat) =>
publicFields(stat, ['id', 'value', 'sourceCount']),
),
abilityStats: summary.abilityStats.map((ability) => ({
abilityId: ability.abilityId,
stats: ability.stats.map((stat) => ({
id: stat.id,
value: stat.value,
sourceCount: stat.sourceCount,
sources: stat.sources.map((source) => ({
source: source.source,
chipIndex: source.chipIndex,
chipSetId: source.chipSetId,
value: source.value,
})),
})),
})),
activeBonuses: summary.activeBonuses.map((bonus) =>
publicFields(bonus, ['name', 'pieces', 'count', 'statId', 'value']),
),
equippedTalents: summary.equippedTalents.map((talent) => ({
id: talent.id,
tier: talent.tier,
...(talent.tierLabel === undefined
? {}
: { tierLabel: talent.tierLabel }),
...(talent.isExotic === undefined
? {}
: { isExotic: talent.isExotic }),
...(talent.abilityId === undefined
? {}
: { abilityId: talent.abilityId }),
source: talent.source,
description: talent.description,
...(talent.custom?.name === undefined
? {}
: { custom: { name: talent.custom.name } }),
})),
} as LoadoutSummary;
}
export async function loadSharedBuildProjection(
slug: string,
access: SharedBuildAccessPolicy,
@@ -92,13 +152,30 @@ export async function loadSharedBuildProjection(
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
gear: gear.map((item) =>
publicFields(item, ['id', 'name', 'slot', 'tier', 'base_armor_min']),
),
weapons: weapons.map((item) =>
publicFields(item, [
'id',
'name',
'category',
'rpm',
'magazine',
'reload_speed',
'damage_type',
'exotic_talent_id',
]),
),
chips: chips.map((item) => publicFields(item, ['id', 'name'])),
protocols: protocols.map((item) => publicFields(item, ['id', 'name'])),
brands: brands.map((item) => publicFields(item, ['id', 'name'])),
summary: publicLoadoutSummary(summary),
attributes: summaryCatalog.attributes.map((item) =>
publicFields(item, ['id', 'name', 'unit', 'category']),
),
talents: summaryCatalog.talents.map((item) =>
publicFields(item, ['id', 'name', 'cooldown_seconds']),
),
};
}
+48
View File
@@ -8,6 +8,7 @@ import { publicOrigin } from '$lib/server/public-origin';
import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
import { loadBuildMechanicRuntimes } from '$lib/server/build-mechanic-runtime';
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) =>
@@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationRankCombatEffects,
specializationRankCosts,
progression,
rankProfiles,
@@ -454,6 +456,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(`SELECT attachment.*,effect.name AS definition_name
FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_node_ranks rank
ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specializations specialization
ON specialization.id=focus.specialization_id AND specialization.active=1
JOIN specialization_combat_effects effect
ON effect.id=attachment.combat_effect_id
AND effect.specialization_id=focus.specialization_id
AND effect.active=1
ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE,
node.row_number,node.column_number,attachment.rank,
attachment.effect_order,attachment.id`)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
@@ -470,6 +489,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
)
).all(),
]);
const mechanics = await loadBuildMechanicRuntimes(database, {
talentIds: talents.map((talent) => String(talent.id)),
protocolTalentIds: protocolTalents.map((talent) => String(talent.id)),
skillChipTalentIds: chipTalents.map((talent) => String(talent.id)),
specializationAttachmentIds: specializationRankCombatEffects.map((effect) =>
String(effect.id),
),
});
const iconUrl = (
type:
| 'talent'
@@ -488,6 +515,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
const configuredTalents = talents.map((talent) => ({
...talent,
runtime: mechanics.talents[String(talent.id)] ?? null,
iconUrl: iconUrl('talent', talent.id),
template:
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
@@ -505,6 +533,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
publicOrigin: publicOrigin(url.origin),
initialBuild,
mechanics,
attributes,
coreRanges,
gear: gear.map((item) => ({
@@ -584,6 +613,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
@@ -614,6 +644,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
@@ -664,6 +695,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
),
combatEffects: specializationRankCombatEffects
.filter(
(effect) =>
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
)
.map((effect) => ({
...effect,
definition: {
id: String(effect.combat_effect_id),
name: String(effect.definition_name),
},
runtime:
mechanics.specializationAttachments[
String(effect.id)
] ?? null,
})),
costs: specializationRankCosts.filter(
(cost) =>
cost.node_id === node.id &&
@@ -0,0 +1,282 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BuildMechanicRuntimes } from '$lib/server/build-mechanic-runtime';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
type HydratedCombatEffect = {
id: string;
definition: { id: string };
runtime: MechanicRuntimeSelection | null;
};
const mocks = vi.hoisted(() => ({
database: null as Database | null,
loadRuntimes: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => null),
}));
vi.mock('$lib/server/build-mechanic-runtime', () => ({
loadBuildMechanicRuntimes: mocks.loadRuntimes,
}));
import { load } from './+page.server';
const selected: MechanicRuntimeSelection = {
owner: { type: 'talent', id: 'source', scope: 'standard', hasTierII: true },
sourceId: 'source',
sourceFingerprint: 'a'.repeat(64),
revisionId: 'revision',
revisionNumber: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
variants: [
{
key: 'base',
label: 'BASE',
program: {
id: 'mechanic-runtime:["source","base"]',
name: 'Selected',
clauses: [],
},
},
],
};
const runtimes: BuildMechanicRuntimes = {
talents: { 'talent-1': selected },
protocolTalents: { 'protocol-talent-1': selected },
skillChipTalents: { 'chip-talent-1': selected },
specializationAttachments: { 'attachment-1': selected },
};
const applySchema = async (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 seedSpecializationAttachments = async (database: Database) => {
await database.exec(`
INSERT INTO specializations(id,name,created_at,updated_at) VALUES
('specialization-parent','Parent specialization','now','now'),
('specialization-cross','Cross specialization','now','now');
INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES('focus-parent','specialization-parent','Parent focus','Firepower',0,'now','now');
INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES('node-parent','focus-parent','Parent node','',1,1,1,'now','now');
INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-parent',1,'');
INSERT INTO specialization_combat_effects(
id,specialization_id,name,description_template,created_at,updated_at
) VALUES
('effect-valid-first','specialization-parent','Valid first','', 'now','now'),
('effect-valid-second','specialization-parent','Valid second','', 'now','now'),
('effect-cross','specialization-cross','Cross specialization effect','', 'now','now');
INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES
('attachment-valid-first','node-parent',1,'effect-valid-first',1,
'enemy_hits_within_window',1,1,1,1),
('attachment-valid-second','node-parent',1,'effect-valid-second',2,
'enemy_hits_within_window',1,1,1,1),
('attachment-cross','node-parent',1,'effect-cross',3,
'enemy_hits_within_window',1,1,1,1);
`);
};
function builderDatabase() {
return {
prepare: vi.fn(async (sql: string) => ({
get: vi.fn(async () => null),
all: vi.fn(async () => {
if (sql.includes('FROM talents WHERE active=1'))
return [{ id: 'talent-1', name: 'Talent' }];
if (sql.includes('FROM skill_chip_sets WHERE active=1'))
return [
{
id: 'chip-set',
name: 'Chip',
specialization_ability_id: 'ability',
},
];
if (sql.includes('FROM skill_chip_talents'))
return [
{
id: 'chip-talent-1',
skill_chip_set_id: 'chip-set',
name: 'Chip Talent',
},
];
if (sql.includes('FROM os_protocols WHERE active=1'))
return [
{
id: 'protocol-1',
name: 'Protocol',
quality: 'High-end',
primary_attribute_type: 'Health',
},
];
if (sql.includes('FROM os_protocol_talents'))
return [
{
id: 'protocol-talent-1',
protocol_id: 'protocol-1',
name: 'Protocol Talent',
},
];
if (sql.includes('FROM specializations WHERE active=1'))
return [{ id: 'specialization-1', name: 'Spec' }];
if (sql.includes('FROM specialization_focuses WHERE active=1'))
return [
{
id: 'focus-1',
specialization_id: 'specialization-1',
display_order: 1,
name: 'Focus',
},
];
if (sql.includes('FROM specialization_tree_nodes WHERE active=1'))
return [
{
id: 'node-1',
focus_id: 'focus-1',
row_number: 1,
column_number: 1,
},
];
if (sql.includes('FROM specialization_tree_node_ranks'))
return [{ node_id: 'node-1', rank: 1 }];
if (sql.includes('FROM specialization_tree_rank_combat_effects'))
return [
{
id: 'attachment-1',
node_id: 'node-1',
rank: 1,
combat_effect_id: 'reusable-effect',
effect_order: 1,
definition_name: 'Attached effect',
},
];
return [];
}),
})),
} as unknown as Database;
}
describe('Builder mechanic runtime hydration', () => {
beforeEach(() => {
mocks.database = builderDatabase();
mocks.loadRuntimes.mockReset().mockResolvedValue(runtimes);
});
it('hydrates the same selected runtime capability shape as shared Builds', async () => {
const result = await load({
cookies: {},
url: new URL('https://shd.build/builder'),
} as never);
expect(result).toBeDefined();
if (!result) return;
expect(mocks.loadRuntimes).toHaveBeenCalledWith(mocks.database, {
talentIds: ['talent-1'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1'],
});
expect(result.mechanics).toEqual(runtimes);
expect(result.talents[0]?.runtime).toBe(selected);
expect(result.chipSets[0]?.talent?.runtime).toBe(selected);
expect(result.protocols[0]?.talent?.runtime).toBe(selected);
expect(
result.specializations[0]?.focuses[0]?.nodes[0]?.ranks[0]?.combatEffects,
).toMatchObject([
{
id: 'attachment-1',
definition: { id: 'reusable-effect', name: 'Attached effect' },
runtime: selected,
},
]);
});
});
describe('Builder specialization combat-effect ownership', () => {
let database: Database;
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await seedSpecializationAttachments(database);
mocks.database = database;
});
afterEach(async () => {
await database.close();
mocks.database = null;
});
it('hydrates only same-specialization effects in attachment order and does not select corrupt attachment runtimes', async () => {
const firstRuntime = { ...selected, sourceId: 'first-runtime' };
const secondRuntime = { ...selected, sourceId: 'second-runtime' };
mocks.loadRuntimes.mockReset().mockResolvedValue({
talents: {},
protocolTalents: {},
skillChipTalents: {},
specializationAttachments: {
'attachment-valid-first': firstRuntime,
'attachment-valid-second': secondRuntime,
},
} satisfies BuildMechanicRuntimes);
const result = await load({
cookies: {},
url: new URL('https://shd.build/builder'),
} as never);
expect(result).toBeDefined();
if (!result) return;
expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, {
talentIds: [],
protocolTalentIds: [],
skillChipTalentIds: [],
specializationAttachmentIds: [
'attachment-valid-first',
'attachment-valid-second',
],
});
const parentSpecialization = result.specializations.find(
(specialization: { id: string }) =>
specialization.id === 'specialization-parent',
);
const combatEffects = (parentSpecialization?.focuses[0]?.nodes[0]?.ranks[0]
?.combatEffects ?? []) as HydratedCombatEffect[];
expect(combatEffects.map((effect) => effect.id)).toEqual([
'attachment-valid-first',
'attachment-valid-second',
]);
expect(combatEffects.map((effect) => effect.definition.id)).toEqual([
'effect-valid-first',
'effect-valid-second',
]);
expect(combatEffects.map((effect) => effect.runtime)).toEqual([
firstRuntime,
secondRuntime,
]);
});
});
+463 -98
View File
@@ -1,6 +1,16 @@
import { getDatabase } from '$lib/server/database';
import { getRegistrySearch } from '$lib/server/pagination';
import { databaseCategories } from '$lib/database/categories';
import {
loadMechanicPublicProjectionsForOwners,
type MechanicPublicProjectionOwnerRef,
type MechanicPublicProjectionsByOwner,
} from '$lib/server/mechanic-public-projection';
import {
databaseMechanicGroup,
databaseMechanicOwnersMatchSearch,
databaseMechanicSearchSummary,
} from './public-mechanics';
import type { PageServerLoad } from './$types';
const tables = {
@@ -28,110 +38,465 @@ export const load: PageServerLoad = async ({ url }) => {
return { ...category, count: Number(row?.count ?? 0) };
}),
);
const searchResults = search
const rawSearchResults = search
? await (
await database.prepare(`
SELECT * FROM (
SELECT 'gear' AS slug,'Gear' AS category,item.name,
item.slot || ' · T' || item.tier AS detail
FROM gear item
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE)
UNION ALL
SELECT 'weapons','Weapons',item.name,
item.category || ' · ' || item.damage_type || ' damage'
FROM weapons item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'weapon-mods','Weapon Mods',item.name,item.slot_type
FROM weapon_mods item
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE)
UNION ALL
SELECT 'gear-mods','Gear Mods',item.name,'Gear modification'
FROM gear_mods item
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
UNION ALL
SELECT 'os-protocols','OS Protocols',item.name,
item.quality || ' · ' || item.primary_attribute_type
FROM os_protocols item
LEFT JOIN os_protocol_talents talent ON talent.protocol_id=item.id
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'talents','Talents',item.name,item.category
FROM talents item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talent_tiers tier
WHERE tier.talent_id=item.id AND (
tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
)
UNION ALL
SELECT 'brands','Brands',item.name,
COALESCE((
SELECT GROUP_CONCAT(attribute.name, ', ')
FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id
),'Brand Set bonuses')
FROM brand_sets item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'skill-chips','Skill Chips',item.name,COALESCE(talent.name,'Skill Chip set')
FROM skill_chip_sets item
LEFT JOIN skill_chip_talents talent ON talent.skill_chip_set_id=item.id
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'specializations','Specializations',item.name,'Specialization'
FROM specializations item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.description LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM specialization_focuses focus
WHERE focus.specialization_id=item.id AND focus.active=1
AND focus.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
OR EXISTS (
SELECT 1 FROM specialization_abilities ability
WHERE ability.specialization_id=item.id AND ability.active=1
AND ability.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
) matches
ORDER BY name COLLATE NOCASE
LIMIT 40
`)
).all(...Array(27).fill(pattern))
SELECT * FROM (
SELECT 'gear' AS slug,'Gear' AS category,item.id AS parent_id,item.name,
item.slot || ' · T' || item.tier AS detail
FROM gear item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM gear_talents link
JOIN talents linked_talent
ON linked_talent.id=link.talent_id AND linked_talent.active=1
JOIN mechanic_authored_sources source
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE link.gear_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'weapons','Weapons',item.id,item.name,
item.category || ' · ' || item.damage_type || ' damage'
FROM weapons item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talents linked_talent
JOIN mechanic_authored_sources source
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE linked_talent.active=1
AND (
linked_talent.id=item.exotic_talent_id
OR EXISTS (
SELECT 1 FROM weapon_talent_options link
WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id
)
)
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'weapon-mods','Weapon Mods',item.id,item.name,item.slot_type
FROM weapon_mods item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'gear-mods','Gear Mods',item.id,item.name,'Gear modification'
FROM gear_mods item
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
UNION ALL
SELECT 'os-protocols','OS Protocols',item.id,item.name,
item.quality || ' · ' || item.primary_attribute_type
FROM os_protocols item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM os_protocol_talents talent
WHERE talent.protocol_id=item.id AND (
talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM mechanic_authored_sources source
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE source.owner_type='os_protocol_talent' AND source.owner_id=talent.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
)
)
UNION ALL
SELECT 'talents','Talents',item.id,item.name,item.category
FROM talents item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talent_tiers tier
WHERE tier.talent_id=item.id AND (
tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
OR EXISTS (
SELECT 1 FROM mechanic_authored_sources source
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE source.owner_type='talent' AND source.owner_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'brands','Brands',item.id,item.name,
COALESCE((
SELECT GROUP_CONCAT(attribute.name, ', ')
FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id
),'Brand Set bonuses')
FROM brand_sets item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id
AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'skill-chips','Skill Chips',item.id,item.name,
COALESCE((SELECT talent.name FROM skill_chip_talents talent
WHERE talent.skill_chip_set_id=item.id),'Skill Chip set')
FROM skill_chip_sets item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM skill_chip_talents talent
WHERE talent.skill_chip_set_id=item.id AND (
talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM mechanic_authored_sources source
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE source.owner_type='skill_chip_talent' AND source.owner_id=talent.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
)
)
UNION ALL
SELECT 'specializations','Specializations',item.id,item.name,'Specialization'
FROM specializations item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.description LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM specialization_focuses focus
WHERE focus.specialization_id=item.id AND focus.active=1
AND focus.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
OR EXISTS (
SELECT 1 FROM specialization_abilities ability
WHERE ability.specialization_id=item.id AND ability.active=1
AND ability.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
OR EXISTS (
SELECT 1 FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specialization_combat_effects combat_effect
ON combat_effect.id=attachment.combat_effect_id
AND combat_effect.specialization_id=focus.specialization_id
AND combat_effect.active=1
JOIN mechanic_authored_sources source
ON source.owner_type='specialization_tree_rank' AND source.owner_id=attachment.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE focus.specialization_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
) matches
ORDER BY name COLLATE NOCASE
`)
).all(...Array(33).fill(pattern))
: [];
// Raw projection text produces a candidate set only. Canonical projections
// below decide behavior matches; this query preserves independent metadata matches.
const metadataSearchResultKeys = search
? new Set(
(
await (
await database.prepare(`WITH search(pattern) AS (VALUES (?))
SELECT 'gear' AS slug,item.id AS parent_id
FROM gear item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.slot LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'weapons',item.id
FROM weapons item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.category LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.damage_type LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'weapon-mods',item.id
FROM weapon_mods item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.slot_type LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'gear-mods',item.id
FROM gear_mods item,search
WHERE item.active=1
AND item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
UNION ALL
SELECT 'os-protocols',item.id
FROM os_protocols item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.quality LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.primary_attribute_type LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM os_protocol_talents talent
WHERE talent.protocol_id=item.id AND (
talent.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
)
)
UNION ALL
SELECT 'talents',item.id
FROM talents item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.category LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talent_tiers tier
WHERE tier.talent_id=item.id AND (
tier.description LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR tier.trigger_text LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR tier.effect_text LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
)
)
UNION ALL
SELECT 'brands',item.id
FROM brand_sets item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id
AND attribute.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'skill-chips',item.id
FROM skill_chip_sets item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM skill_chip_talents talent
WHERE talent.skill_chip_set_id=item.id AND (
talent.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
)
)
UNION ALL
SELECT 'specializations',item.id
FROM specializations item,search
WHERE item.active=1 AND (
item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR item.description LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM specialization_focuses focus
WHERE focus.specialization_id=item.id AND focus.active=1
AND focus.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
OR EXISTS (
SELECT 1 FROM specialization_abilities ability
WHERE ability.specialization_id=item.id AND ability.active=1
AND ability.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE
)
)`)
).all(pattern)
).map((result) => `${result.slug}\u0000${result.parent_id}`),
)
: new Set<string>();
const resultIds = (slug: string) =>
rawSearchResults
.filter((result) => String(result.slug) === slug)
.map((result) => String(result.parent_id));
const [
gearTalentRows,
weaponTalentRows,
protocolTalentRows,
skillChipTalentRows,
specializationAttachmentRows,
] = await Promise.all([
resultIds('gear').length
? (
await database.prepare(`SELECT link.gear_id,link.talent_id FROM gear_talents link
JOIN talents ON talents.id=link.talent_id AND talents.active=1
WHERE link.gear_id IN (${resultIds('gear')
.map(() => '?')
.join(',')})
ORDER BY link.gear_id,link.talent_id`)
).all(...resultIds('gear'))
: [],
resultIds('weapons').length
? (
await database.prepare(`SELECT relation.weapon_id,relation.talent_id FROM (
SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options
UNION ALL
SELECT id AS weapon_id,NULL AS talent_slot,exotic_talent_id AS talent_id
FROM weapons WHERE exotic_talent_id IS NOT NULL
) relation
JOIN talents ON talents.id=relation.talent_id AND talents.active=1
WHERE relation.weapon_id IN (${resultIds('weapons')
.map(() => '?')
.join(',')})
ORDER BY relation.weapon_id,relation.talent_slot,relation.talent_id`)
).all(...resultIds('weapons'))
: [],
resultIds('os-protocols').length
? (
await database.prepare(`SELECT protocol_id,id FROM os_protocol_talents
WHERE protocol_id IN (${resultIds('os-protocols')
.map(() => '?')
.join(',')})
ORDER BY protocol_id,id`)
).all(...resultIds('os-protocols'))
: [],
resultIds('skill-chips').length
? (
await database.prepare(`SELECT skill_chip_set_id,id FROM skill_chip_talents
WHERE skill_chip_set_id IN (${resultIds('skill-chips')
.map(() => '?')
.join(',')})
ORDER BY skill_chip_set_id,id`)
).all(...resultIds('skill-chips'))
: [],
resultIds('specializations').length
? (
await database.prepare(`SELECT attachment.id,focus.specialization_id
FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specialization_combat_effects combat_effect
ON combat_effect.id=attachment.combat_effect_id
AND combat_effect.specialization_id=focus.specialization_id
AND combat_effect.active=1
WHERE focus.specialization_id IN (${resultIds('specializations')
.map(() => '?')
.join(',')})
ORDER BY focus.specialization_id,focus.display_order,focus.name COLLATE NOCASE,
node.row_number,node.column_number,attachment.rank,attachment.effect_order`)
).all(...resultIds('specializations'))
: [],
]);
const resultKey = (slug: string, id: string) => `${slug}\u0000${id}`;
const ownersByResult = new Map<string, MechanicPublicProjectionOwnerRef[]>();
const addOwner = (
slug: string,
parentId: string,
owner: MechanicPublicProjectionOwnerRef,
) => {
const key = resultKey(slug, parentId);
ownersByResult.set(key, [...(ownersByResult.get(key) ?? []), owner]);
};
for (const result of rawSearchResults) {
if (String(result.slug) === 'talents') {
addOwner('talents', String(result.parent_id), {
type: 'talent',
id: String(result.parent_id),
});
}
}
for (const row of gearTalentRows) {
addOwner('gear', String(row.gear_id), {
type: 'talent',
id: String(row.talent_id),
});
}
for (const row of weaponTalentRows) {
addOwner('weapons', String(row.weapon_id), {
type: 'talent',
id: String(row.talent_id),
});
}
for (const row of protocolTalentRows) {
addOwner('os-protocols', String(row.protocol_id), {
type: 'os_protocol_talent',
id: String(row.id),
});
}
for (const row of skillChipTalentRows) {
addOwner('skill-chips', String(row.skill_chip_set_id), {
type: 'skill_chip_talent',
id: String(row.id),
});
}
for (const row of specializationAttachmentRows) {
addOwner('specializations', String(row.specialization_id), {
type: 'specialization_tree_rank',
id: String(row.id),
});
}
const owners = [
...new Map(
[...ownersByResult.values()]
.flat()
.map((owner) => [`${owner.type}\u0000${owner.id}`, owner]),
).values(),
];
const publicMechanics: MechanicPublicProjectionsByOwner = owners.length
? await loadMechanicPublicProjectionsForOwners(database, owners)
: ({} as MechanicPublicProjectionsByOwner);
const searchResults = rawSearchResults
.filter(
(result) =>
metadataSearchResultKeys.has(
resultKey(String(result.slug), String(result.parent_id)),
) ||
databaseMechanicOwnersMatchSearch(
ownersByResult.get(
resultKey(String(result.slug), String(result.parent_id)),
) ?? [],
publicMechanics,
search,
),
)
.slice(0, 40);
const normalizedSearch = search.toLocaleLowerCase();
return {
registries: counts,
total: counts.reduce((total, registry) => total + registry.count, 0),
search,
searchResults: searchResults.map((result) => ({
slug: String(result.slug),
category: String(result.category),
name: String(result.name),
detail: String(result.detail ?? ''),
})),
searchResults: searchResults.map((result) => {
const groups = (
ownersByResult.get(
resultKey(String(result.slug), String(result.parent_id)),
) ?? []
)
.map((owner) =>
databaseMechanicGroup(owner, publicMechanics, { label: 'BEHAVIOR' }),
)
.filter((group) => group !== null);
const group =
groups.find((candidate) =>
candidate.variants.some((variant) =>
variant.behaviorText.toLocaleLowerCase().includes(normalizedSearch),
),
) ??
groups[0] ??
null;
const behavior = databaseMechanicSearchSummary(group);
return {
slug: String(result.slug),
category: String(result.category),
name: String(result.name),
detail: String(result.detail ?? ''),
...(behavior ?? {}),
};
}),
};
};
+20 -3
View File
@@ -1,6 +1,21 @@
<script lang="ts">
import DatabaseMechanicStatusBadge from '$lib/components/database/DatabaseMechanicStatusBadge.svelte';
import { isDatabaseMechanicStatus, type DatabaseMechanicStatus } from '$lib/database/items';
type DatabaseSearchResult = {
slug: string;
category: string;
name: string;
detail: string;
behaviorText?: string;
status?: unknown;
};
let { data } = $props();
const onlineRegistries = $derived(data.registries.filter((registry) => registry.count > 0).length);
const behaviorText = (result: DatabaseSearchResult) => result.behaviorText ?? result.detail;
const status = (result: DatabaseSearchResult): DatabaseMechanicStatus | null =>
isDatabaseMechanicStatus(result.status) ? result.status : null;
</script>
<svelte:head>
@@ -31,11 +46,13 @@
{#if data.searchResults.length}
<div>
{#each data.searchResults as result}
{@const resultStatus = status(result)}
<a href={`/database/${result.slug}?q=${encodeURIComponent(result.name)}`}>
<small>{result.category}</small>
<strong>{result.name}</strong>
<span>{result.detail}</span>
<b>VIEW →</b>
<strong class="database-global-result-identity">{result.name}</strong>
{#if resultStatus}<DatabaseMechanicStatusBadge status={resultStatus}/>{/if}
<span class="database-global-result-behavior" aria-label={behaviorText(result)} title={behaviorText(result)}>{behaviorText(result)}</span>
<b class="database-global-result-navigation">VIEW →</b>
</a>
{/each}
</div>
+457 -159
View File
@@ -1,5 +1,5 @@
import { databaseCategory } from '$lib/database/categories';
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
import type { DatabaseItem, DatabaseMechanicGroup } from '$lib/database/items';
import type { GameIconName } from '$lib/game-icons';
import { gearBaseStatRange, weaponBaseRange } from '$lib/builder/loadout';
import {
@@ -44,6 +44,15 @@ import {
publicWeaponModSlots,
weaponModSlotDefinitionByName,
} from '$lib/weapons/mod-slots';
import {
loadMechanicPublicProjectionsForOwners,
type MechanicPublicProjectionOwnerRef,
type MechanicPublicProjectionsByOwner,
} from '$lib/server/mechanic-public-projection';
import {
databaseMechanicGroup,
databaseMechanicOwnersMatchSearch,
} from '../public-mechanics';
const number = (value: unknown) => {
const numeric = Number(value);
@@ -65,19 +74,9 @@ const weaponIcon = (category: unknown) =>
String(category).toLowerCase() as GameIconName;
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
const parsedObject = (value: unknown): Record<string, unknown> => {
try {
const parsed = JSON.parse(String(value ?? '{}'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
};
const talentCardItem = (
row: Record<string, any>,
mechanics: Array<Record<string, any>>,
row: Record<string, unknown>,
mechanicGroups: DatabaseMechanicGroup[],
weaponSlotOverride?: number,
): DatabaseItem => {
const talentCategory = String(row.category);
@@ -91,27 +90,6 @@ const talentCardItem = (
isWeaponTalent && weaponSlot
? `WEAPON ${weaponSlot}`
: normalizedCategory.toUpperCase();
const afterLines: DatabaseFact[] = [
...(row.duration_seconds
? [
{
label: 'EFFECT DURATION',
value: `${number(row.duration_seconds)} SECONDS`,
},
]
: []),
...(Number(row.max_stacks) > 1
? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }]
: []),
...(row.cooldown_seconds
? [
{
label: 'TALENT COOLDOWN',
value: `${number(row.cooldown_seconds)} SECONDS`,
},
]
: []),
];
const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
const destination = isWeaponTalent ? 'weapons' : 'gear';
@@ -136,14 +114,7 @@ const talentCardItem = (
: []),
],
facts: [],
description: row.tier_i
? String(row.tier_i)
: 'Description is not available.',
secondaryDescription: row.tier_ii
? String(row.tier_ii)
: 'Description is not available.',
descriptionStyle: 'data',
afterLines,
mechanicGroups,
actions: [
{
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
@@ -179,6 +150,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
}> = [];
let countSql = '';
let rowsSql = '';
let behaviorSearchCandidateSql: string | null = null;
switch (category.slug) {
case 'gear': {
@@ -195,7 +167,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
).all(),
(
await database.prepare(`SELECT talents.id,talents.name,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
icons.updated_at FROM talents
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
@@ -224,8 +195,19 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const conditions = ['item.active=1'];
queryArgs = [];
if (search) {
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
queryArgs.push(pattern);
conditions.push(`(
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM gear_talents linked_talent
JOIN talents ON talents.id=linked_talent.talent_id AND talents.active=1
JOIN mechanic_authored_sources source
ON source.owner_type='talent' AND source.owner_id=talents.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE linked_talent.gear_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)`);
queryArgs.push(pattern, pattern);
}
if (selectedSlots.length) {
conditions.push(
@@ -244,6 +226,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
queryArgs.push(...selectedTalents);
}
const where = conditions.join(' AND ');
behaviorSearchCandidateSql = search
? `SELECT item.id,item.name FROM gear item WHERE ${where}`
: null;
countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`;
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at
FROM gear item
@@ -282,7 +267,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
options: talentRows.map((row) => ({
value: String(row.id),
label: String(row.name),
description: String(row.tier_i_description ?? ''),
iconUrl: iconUrl('talent', row.id, row.updated_at),
})),
},
@@ -291,13 +275,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
}
case 'weapons': {
const talentRows = await (
await database.prepare(`SELECT DISTINCT talents.id,talents.name,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
icons.updated_at
FROM weapon_talent_options options
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id AND source_weapons.active=1
JOIN talents ON talents.id=options.talent_id AND talents.active=1
await database.prepare(`SELECT talents.id,talents.name,icons.updated_at
FROM talents
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
WHERE talents.active=1 AND (
EXISTS (
SELECT 1 FROM weapon_talent_options options
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id
WHERE source_weapons.active=1 AND options.talent_id=talents.id
)
OR EXISTS (
SELECT 1 FROM weapons source_weapons
WHERE source_weapons.active=1 AND source_weapons.exotic_talent_id=talents.id
)
)
ORDER BY talents.name COLLATE NOCASE`)
).all();
const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
@@ -315,8 +306,25 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const conditions = ['item.active=1'];
queryArgs = [];
if (search) {
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
queryArgs.push(pattern);
conditions.push(`(
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talents linked_talent
JOIN mechanic_authored_sources source
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE linked_talent.active=1
AND (
linked_talent.id=item.exotic_talent_id
OR EXISTS (
SELECT 1 FROM weapon_talent_options link
WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id
)
)
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)`);
queryArgs.push(pattern, pattern);
}
if (selectedTypes.length) {
conditions.push(
@@ -331,11 +339,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
queryArgs.push(...selectedDamageTypes);
}
if (selectedTalents.length) {
conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents
WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
queryArgs.push(...selectedTalents);
conditions.push(`(
EXISTS (
SELECT 1 FROM weapon_talent_options filter_talents
WHERE filter_talents.weapon_id=item.id
AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')})
)
OR item.exotic_talent_id IN (${selectedTalents.map(() => '?').join(',')})
)`);
queryArgs.push(...selectedTalents, ...selectedTalents);
}
const where = conditions.join(' AND ');
behaviorSearchCandidateSql = search
? `SELECT item.id,item.name FROM weapons item WHERE ${where}`
: null;
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE ${where}`;
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
native.damage_min,native.damage_max,native.reference_level,
@@ -377,7 +394,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
options: talentRows.map((row) => ({
value: String(row.id),
label: String(row.name),
description: String(row.tier_i_description ?? ''),
iconUrl: iconUrl('talent', row.id, row.updated_at),
})),
},
@@ -409,10 +425,27 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'os-protocols':
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
case 'os-protocols': {
const where = search
? `item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM os_protocol_talents talent
JOIN mechanic_authored_sources source
ON source.owner_type='os_protocol_talent' AND source.owner_id=talent.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE talent.protocol_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)`
: 'item.active=1';
queryArgs = search ? [pattern, pattern] : [];
behaviorSearchCandidateSql = search
? `SELECT item.id,item.name FROM os_protocols item WHERE ${where}`
: null;
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE ${where}`;
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
primary_effect.base_value,talents.id AS talent_id,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
render.id AS render_id,render.updated_at AS render_updated_at
FROM os_protocols item
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
@@ -420,9 +453,10 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id
LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id
LEFT JOIN managed_images render ON render.source_entity_type='os_protocol_render' AND render.source_entity_id=item.id
WHERE item.active=1${searchClause}
WHERE ${where}
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
}
case 'talents': {
const talentSlots = [
{ value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const },
@@ -448,8 +482,16 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const conditions = ['item.active=1'];
queryArgs = [];
if (search) {
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
queryArgs.push(pattern);
conditions.push(`(
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM mechanic_authored_sources source
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE source.owner_type='talent' AND source.owner_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)`);
queryArgs.push(pattern, pattern);
}
if (selectedTalentSlots.length) {
const slotConditions = selectedTalentSlots.map((slot) => {
@@ -467,10 +509,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
);
}
const where = conditions.join(' AND ');
behaviorSearchCandidateSql = search
? `SELECT item.id,item.name FROM talents item WHERE ${where}`
: null;
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`;
rowsSql = `SELECT item.*,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii,
(SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot,
(SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope,
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
@@ -499,10 +542,27 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'skill-chips':
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
case 'skill-chips': {
const where = search
? `item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM skill_chip_talents talent
JOIN mechanic_authored_sources source
ON source.owner_type='skill_chip_talent' AND source.owner_id=talent.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE talent.skill_chip_set_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)`
: 'item.active=1';
queryArgs = search ? [pattern, pattern] : [];
behaviorSearchCandidateSql = search
? `SELECT item.id,item.name FROM skill_chip_sets item WHERE ${where}`
: null;
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE ${where}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,talents.description_template AS talent_description,
talents.id AS talent_id,talents.name AS talent_name,
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
FROM skill_chip_sets item
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
@@ -510,11 +570,35 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
WHERE item.active=1${searchClause}
WHERE ${where}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'specializations':
countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE item.active=1${searchClause}`;
}
case 'specializations': {
const where = search
? `item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specialization_combat_effects combat_effect
ON combat_effect.id=attachment.combat_effect_id
AND combat_effect.specialization_id=focus.specialization_id
AND combat_effect.active=1
JOIN mechanic_authored_sources source
ON source.owner_type='specialization_tree_rank' AND source.owner_id=attachment.id
JOIN mechanic_public_variants variant ON variant.source_id=source.id
WHERE focus.specialization_id=item.id
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)`
: 'item.active=1';
queryArgs = search ? [pattern, pattern] : [];
behaviorSearchCandidateSql = search
? `SELECT item.id,item.name FROM specializations item WHERE ${where}`
: null;
countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE ${where}`;
rowsSql = `SELECT item.*,
(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.active=1) AS focus_count,
(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.builder_ready=1 AND focus.active=1) AS ready_focus_count,
@@ -536,41 +620,180 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
FROM specializations item
LEFT JOIN game_data_icons icons ON icons.entity_type='specialization' AND icons.entity_id=item.id
LEFT JOIN managed_images render ON render.source_entity_type='specialization_render' AND render.source_entity_id=item.id
WHERE item.active=1${searchClause}
WHERE ${where}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
}
}
const total = Number(
(await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0,
);
// Cache text may identify candidates, but only canonical projections decide
// whether behavior matches and therefore contributes to totals or pages.
let validatedBehaviorSearchIds: string[] | null = null;
if (behaviorSearchCandidateSql) {
const candidates = await (
await database.prepare(behaviorSearchCandidateSql)
).all(...queryArgs);
const candidateIds = candidates.map((candidate) => String(candidate.id));
const ownersByCandidate = new Map<
string,
MechanicPublicProjectionOwnerRef[]
>();
const addOwner = (
parentId: unknown,
owner: MechanicPublicProjectionOwnerRef,
) => {
const id = String(parentId);
ownersByCandidate.set(id, [...(ownersByCandidate.get(id) ?? []), owner]);
};
if (candidateIds.length) {
let ownerRows: Array<Record<string, unknown>> = [];
const placeholders = candidateIds.map(() => '?').join(',');
switch (category.slug) {
case 'gear':
ownerRows = await (
await database.prepare(`SELECT links.gear_id AS parent_id,links.talent_id AS owner_id
FROM gear_talents links
JOIN talents ON talents.id=links.talent_id AND talents.active=1
WHERE links.gear_id IN (${placeholders})`)
).all(...candidateIds);
for (const row of ownerRows)
addOwner(row.parent_id, {
type: 'talent',
id: String(row.owner_id),
});
break;
case 'weapons':
ownerRows = await (
await database.prepare(`SELECT relation.weapon_id AS parent_id,relation.talent_id AS owner_id
FROM (
SELECT weapon_id,talent_id FROM weapon_talent_options
UNION ALL
SELECT id AS weapon_id,exotic_talent_id AS talent_id
FROM weapons WHERE exotic_talent_id IS NOT NULL
) relation
JOIN talents ON talents.id=relation.talent_id AND talents.active=1
WHERE relation.weapon_id IN (${placeholders})`)
).all(...candidateIds);
for (const row of ownerRows)
addOwner(row.parent_id, {
type: 'talent',
id: String(row.owner_id),
});
break;
case 'os-protocols':
ownerRows = await (
await database.prepare(`SELECT protocol_id AS parent_id,id AS owner_id
FROM os_protocol_talents WHERE protocol_id IN (${placeholders})`)
).all(...candidateIds);
for (const row of ownerRows)
addOwner(row.parent_id, {
type: 'os_protocol_talent',
id: String(row.owner_id),
});
break;
case 'talents':
for (const id of candidateIds) addOwner(id, { type: 'talent', id });
break;
case 'skill-chips':
ownerRows = await (
await database.prepare(`SELECT skill_chip_set_id AS parent_id,id AS owner_id
FROM skill_chip_talents WHERE skill_chip_set_id IN (${placeholders})`)
).all(...candidateIds);
for (const row of ownerRows)
addOwner(row.parent_id, {
type: 'skill_chip_talent',
id: String(row.owner_id),
});
break;
case 'specializations':
ownerRows = await (
await database.prepare(`SELECT focus.specialization_id AS parent_id,
attachment.id AS owner_id
FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specialization_combat_effects combat_effect
ON combat_effect.id=attachment.combat_effect_id
AND combat_effect.specialization_id=focus.specialization_id
AND combat_effect.active=1
WHERE focus.specialization_id IN (${placeholders})`)
).all(...candidateIds);
for (const row of ownerRows)
addOwner(row.parent_id, {
type: 'specialization_tree_rank',
id: String(row.owner_id),
});
break;
}
}
const owners = [
...new Map(
[...ownersByCandidate.values()]
.flat()
.map((owner) => [`${owner.type}\u0000${owner.id}`, owner]),
).values(),
] as MechanicPublicProjectionOwnerRef[];
const projections: MechanicPublicProjectionsByOwner = owners.length
? await loadMechanicPublicProjectionsForOwners(database, owners)
: ({} as MechanicPublicProjectionsByOwner);
const normalizedSearch = search.toLocaleLowerCase();
validatedBehaviorSearchIds = candidates
.filter(
(candidate) =>
String(candidate.name)
.toLocaleLowerCase()
.includes(normalizedSearch) ||
databaseMechanicOwnersMatchSearch(
ownersByCandidate.get(String(candidate.id)) ?? [],
projections,
search,
),
)
.map((candidate) => String(candidate.id));
}
const total =
validatedBehaviorSearchIds?.length ??
Number(
(await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0,
);
const pagination = getPagination(url, total);
const rows = await (
await database.prepare(rowsSql)
).all(...queryArgs, pagination.perPage, pagination.offset);
const protocolTalentEffects =
category.slug === 'os-protocols' && rows.length
? await (
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
FROM os_protocol_talents talents
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
ORDER BY talents.protocol_id,effects.sort_order`)
).all(...rows.map((row) => row.id))
: [];
const rows =
validatedBehaviorSearchIds !== null && !validatedBehaviorSearchIds.length
? []
: await (
await database.prepare(
validatedBehaviorSearchIds
? `${rowsSql.slice(0, rowsSql.lastIndexOf('ORDER BY'))}AND item.id IN (${validatedBehaviorSearchIds
.map(() => '?')
.join(
',',
)}) ${rowsSql.slice(rowsSql.lastIndexOf('ORDER BY'))}`
: rowsSql,
)
).all(
...queryArgs,
...(validatedBehaviorSearchIds ?? []),
pagination.perPage,
pagination.offset,
);
const weaponRelations =
category.slug === 'weapons' && rows.length
? await (
await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii,
await database.prepare(`SELECT relation.weapon_id,relation.talent_slot,relation.kind AS relation_kind,
talents.*,COALESCE(scopes.scope,CASE relation.kind WHEN 'exotic' THEN 'Exotic' END) AS weapon_scope,
icons.updated_at AS icon_updated_at
FROM weapon_talent_options options
JOIN talents ON talents.id=options.talent_id AND talents.active=1
FROM (
SELECT weapon_id,talent_slot,talent_id,'standard' AS kind FROM weapon_talent_options
UNION ALL
SELECT id AS weapon_id,NULL AS talent_slot,exotic_talent_id AS talent_id,'exotic' AS kind
FROM weapons WHERE exotic_talent_id IS NOT NULL
) relation
JOIN talents ON talents.id=relation.talent_id AND talents.active=1
LEFT JOIN talent_weapon_scopes scopes ON scopes.talent_id=talents.id
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
WHERE options.weapon_id IN (${rows.map(() => '?').join(',')})
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
WHERE relation.weapon_id IN (${rows.map(() => '?').join(',')})
ORDER BY relation.weapon_id,relation.kind,relation.talent_slot,talents.name COLLATE NOCASE`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotRows =
@@ -594,16 +817,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? await (
await database.prepare(`SELECT relation.*,icons.updated_at
FROM (
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
NULL AS tier_i,NULL AS tier_ii
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,NULL AS category
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
WHERE brands.active=1
UNION ALL
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,talents.category
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
WHERE talents.active=1
) relation
@@ -640,40 +858,113 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)
).all(...rows.map((row) => row.id))
: [];
const talentIds = [
...new Set(
category.slug === 'talents'
? rows.map((row) => String(row.id))
: category.slug === 'weapons'
? weaponRelations.map((relation) => String(relation.id))
: category.slug === 'gear'
? gearRelations
.filter((relation) => relation.kind === 'talent')
.map((relation) => String(relation.id))
: [],
),
const specializationMechanicRows =
category.slug === 'specializations' && rows.length
? await (
await database.prepare(`SELECT attachment.id AS attachment_id,
focus.specialization_id,focus.name AS focus_name,focus.display_order AS focus_display_order,
node.name AS node_name,node.row_number,node.column_number,
attachment.rank,attachment.effect_order,combat_effect.name AS combat_effect_name
FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specialization_combat_effects combat_effect
ON combat_effect.id=attachment.combat_effect_id
AND combat_effect.specialization_id=focus.specialization_id
AND combat_effect.active=1
WHERE focus.specialization_id IN (${rows.map(() => '?').join(',')})
ORDER BY focus.display_order,focus.name COLLATE NOCASE,node.row_number,node.column_number,
attachment.rank,attachment.effect_order`)
).all(...rows.map((row) => row.id))
: [];
const mechanicOwners = [
...(category.slug === 'talents'
? rows.map((row) => ({ type: 'talent' as const, id: String(row.id) }))
: []),
...(category.slug === 'weapons'
? weaponRelations.map((row) => ({
type: 'talent' as const,
id: String(row.id),
}))
: []),
...(category.slug === 'gear'
? gearRelations
.filter((row) => row.kind === 'talent')
.map((row) => ({ type: 'talent' as const, id: String(row.id) }))
: []),
...(category.slug === 'os-protocols'
? rows
.filter((row) => row.talent_id)
.map((row) => ({
type: 'os_protocol_talent' as const,
id: String(row.talent_id),
}))
: []),
...(category.slug === 'skill-chips'
? rows
.filter((row) => row.talent_id)
.map((row) => ({
type: 'skill_chip_talent' as const,
id: String(row.talent_id),
}))
: []),
...specializationMechanicRows.map((row) => ({
type: 'specialization_tree_rank' as const,
id: String(row.attachment_id),
})),
];
const talentMechanics = talentIds.length
? await (
await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope,
outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit,
outcomes.sort_order
FROM conditional_effect_rules rules
JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
WHERE rules.source_type='talent' AND rules.configured=1
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)
).all(...talentIds)
: [];
const mechanicsFor = (talentId: unknown) =>
talentMechanics.filter(
(mechanic) => String(mechanic.talent_id) === String(talentId),
const uniqueMechanicOwners = [
...new Map(
mechanicOwners.map((owner) => [`${owner.type}\u0000${owner.id}`, owner]),
).values(),
] as MechanicPublicProjectionOwnerRef[];
const publicMechanics: MechanicPublicProjectionsByOwner =
uniqueMechanicOwners.length
? await loadMechanicPublicProjectionsForOwners(
database,
uniqueMechanicOwners,
)
: ({} as MechanicPublicProjectionsByOwner);
const talentMechanicGroups = (id: unknown) => {
const group = databaseMechanicGroup(
{ type: 'talent', id: String(id) },
publicMechanics,
{ label: 'TALENT' },
);
return group ? [group] : [];
};
const protocolMechanicGroups = (id: unknown) => {
if (!id) return [];
const group = databaseMechanicGroup(
{ type: 'os_protocol_talent', id: String(id) },
publicMechanics,
{ label: 'PROTOCOL TALENT' },
);
return group ? [group] : [];
};
const skillChipMechanicGroups = (id: unknown) => {
if (!id) return [];
const group = databaseMechanicGroup(
{ type: 'skill_chip_talent', id: String(id) },
publicMechanics,
{ label: 'SKILL CHIP TALENT' },
);
return group ? [group] : [];
};
const specializationMechanicGroups = (specializationId: unknown) =>
specializationMechanicRows.flatMap((row) => {
if (String(row.specialization_id) !== String(specializationId)) return [];
const group = databaseMechanicGroup(
{ type: 'specialization_tree_rank', id: String(row.attachment_id) },
publicMechanics,
{
id: String(row.attachment_id),
label: String(row.combat_effect_name),
context: `${String(row.focus_name)} // ${String(row.node_name)} // RANK ${String(row.rank)} // ROW ${String(row.row_number)}, COLUMN ${String(row.column_number)} // EFFECT ${String(row.effect_order)}`,
},
);
return group ? [group] : [];
});
const items: DatabaseItem[] = rows.map((row) => {
switch (category.slug) {
@@ -737,7 +1028,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
...relation,
icon_updated_at: relation.updated_at,
},
mechanicsFor(relation.id),
talentMechanicGroups(relation.id),
),
};
});
@@ -825,9 +1116,13 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
: '',
};
});
const talentItems = (slot: number) =>
const talentItems = (slot: number | 'exotic') =>
relatedTalents
.filter((relation) => Number(relation.talent_slot) === slot)
.filter((relation) =>
slot === 'exotic'
? relation.relation_kind === 'exotic'
: Number(relation.talent_slot) === slot,
)
.map((relation) => {
const talentIcon = iconUrl(
'talent',
@@ -840,8 +1135,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
iconUrl: talentIcon,
preview: talentCardItem(
relation,
mechanicsFor(relation.id),
slot,
talentMechanicGroups(relation.id),
typeof slot === 'number' ? slot : undefined,
),
};
});
@@ -899,6 +1194,15 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
items: talentItems(2),
emptyLabel: 'NO TALENTS ASSIGNED',
},
...(Number(row.is_exotic)
? [
{
label: 'EXOTIC TALENT',
items: talentItems('exotic'),
emptyLabel: 'NO EXOTIC TALENT ASSIGNED',
},
]
: []),
],
};
}
@@ -961,7 +1265,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
},
],
};
case 'os-protocols':
case 'os-protocols': {
const mechanicGroups = protocolMechanicGroups(row.talent_id);
return {
id: String(row.id),
name: String(row.name),
@@ -979,22 +1284,14 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
primaryUnit: String(row.primary_unit ?? ''),
primaryBaseValue: Number(row.base_value),
talentDescription: row.talent_description
? String(row.talent_description)
: undefined,
talentEffects: protocolTalentEffects
.filter((effect) => effect.protocol_id === row.id)
.map((effect) => ({
baseValue: Number(effect.base_value),
unit: String(effect.unit ?? ''),
})),
talentDescription: mechanicGroups[0]?.variants[0]?.behaviorText,
talentEffects: [],
},
description: row.talent_description
? String(row.talent_description)
: undefined,
mechanicGroups,
};
}
case 'talents':
return talentCardItem(row, mechanicsFor(row.id));
return talentCardItem(row, talentMechanicGroups(row.id));
case 'brands': {
const effects = brandEffects.filter(
(effect) => effect.brand_set_id === row.id,
@@ -1020,7 +1317,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
})),
};
}
case 'skill-chips':
case 'skill-chips': {
const mechanicGroups = skillChipMechanicGroups(row.talent_id);
return {
id: String(row.id),
name: String(row.name),
@@ -1044,10 +1342,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
value: list(row.talent_name, 'NOT CONFIGURED'),
},
],
description: row.talent_description
? String(row.talent_description)
: undefined,
mechanicGroups,
};
}
case 'specializations':
return {
id: String(row.id),
@@ -1070,6 +1367,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
{ label: 'ABILITIES', value: list(row.ability_names) },
],
description: row.description ? String(row.description) : undefined,
mechanicGroups: specializationMechanicGroups(row.id),
};
}
}) as DatabaseItem[];
+2 -2
View File
@@ -26,8 +26,8 @@
<DatabaseCategoryNav active={data.category.slug}/>
<DatabaseDiscoveryConsole filters={data.filters} pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
<section class="database-item-grid">
{#each data.items as item}
<DatabaseItemCard item={item as any}/>
{#each data.items as item (item.id)}
<DatabaseItemCard {item}/>
{:else}
<div class="database-empty">
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
@@ -0,0 +1,674 @@
import type {
MechanicAuthoredOwner,
MechanicAuthoredSource,
} from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { DatabaseItem } from '$lib/database/items';
import type { MechanicPublicProjectionOwnerRef } from '$lib/server/mechanic-public-projection';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { saveMechanicAuthoredSourceInTransaction } from '$lib/server/mechanic-authored-sources';
import { saveMechanicModelRevisionInTransaction } from '$lib/server/mechanic-model-revisions';
import { refreshMechanicPublicProjectionInTransaction } from '$lib/server/mechanic-public-projection';
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/game-data-discussions', () => ({
gameDataDiscussionSummary: vi.fn(async () => new Map()),
gameDataTargetExists: vi.fn(async () => true),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => null),
}));
vi.mock('$lib/server/auth/authorization', () => ({
getPermissions: vi.fn(async () => []),
}));
const { load } = await import('./+page.server');
let database: Database;
const now = '2026-09-03T00:00:00.000Z';
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 execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
function variantKeys(owner: MechanicPublicProjectionOwnerRef) {
switch (owner.type) {
case 'talent':
return ['I', 'II'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
}
const authoredOwner = (
owner: MechanicPublicProjectionOwnerRef,
talentScope: 'standard' | 'exotic' = 'standard',
): MechanicAuthoredOwner =>
owner.type === 'talent' ? { ...owner, scope: talentScope } : owner;
async function createProjection(
owner: MechanicPublicProjectionOwnerRef,
text: string,
options: {
modeled?: boolean;
talentScope?: 'standard' | 'exotic';
keys?: string[];
} = {},
) {
const source: MechanicAuthoredSource = {
version: 1,
owner: authoredOwner(owner, options.talentScope),
template: text,
variants: (options.keys ?? variantKeys(owner)).map((key) => ({
key,
materialized: text,
slots: [],
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: 'Rank effect description.',
effectId: `effect-${owner.id}`,
effectTemplate: text,
},
}
: {}),
};
const saved = await saveMechanicAuthoredSourceInTransaction(
database,
source,
{
expectedSourceFingerprint: null,
},
);
if (options.modeled) {
const draft: ConditionalMechanicDraftV1 = {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'mark-target',
label: 'Mark target',
event: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'mark',
label: 'Mark',
phase: 'on_event',
kind: 'marker',
scope: 'target',
basis: 'flat',
variants: Object.fromEntries(
variantKeys(owner).map((key) => [key, { value: 1 }]),
),
},
],
},
],
};
await saveMechanicModelRevisionInTransaction(database, saved, draft, {
programName: 'Mark target',
origin: 'test',
actorId: null,
});
}
await refreshMechanicPublicProjectionInTransaction(database, saved);
return saved;
}
type CategoryPageData = {
items: DatabaseItem[];
pagination: { total: number };
};
const loadCategory = async (category: string, query = '') =>
(await load({
params: { category },
url: new URL(`http://localhost/database/${category}${query}`),
cookies: {},
} as never)) as unknown as CategoryPageData;
async function seedPublicOwners() {
await execute(
`INSERT INTO talents(id,name,category,created_at,updated_at)
VALUES('talent-direct','Talent One','Backpack',?,?),
('talent-weapon','Weapon Talent','Weapon',?,?),
('talent-corrupt','Corrupt Talent','Backpack',?,?)`,
now,
now,
now,
now,
now,
now,
);
await execute(
`INSERT INTO gear(
id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,
range_reference_level,created_at,updated_at
) VALUES('gear-1','Projection Backpack','Backpack',0,10,10,NULL,NULL,50,?,?)`,
now,
now,
);
await execute(
`INSERT INTO gear_talents(gear_id,talent_id) VALUES('gear-1','talent-direct')`,
);
await execute(
`INSERT INTO weapons(
id,name,category,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability,
created_at,updated_at
) VALUES('weapon-1','Projection Rifle','Rifle',0,'Pierce',1,1,1,1,1,?,?)`,
now,
now,
);
await execute(
`INSERT INTO weapon_tier_ranges(
weapon_id,tier,damage_min,damage_max,firepower_min,firepower_max,toughness_min,toughness_max,
engineering_min,engineering_max,reference_level
) VALUES('weapon-1',0,10,10,0,0,0,0,0,0,50)`,
);
await execute(
`INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id)
VALUES('weapon-1',1,'talent-weapon')`,
);
await execute(
`INSERT INTO os_protocols(
id,name,quality,primary_attribute_type,created_at,updated_at
) VALUES('protocol-1','Projection Protocol','Standard','Firepower',?,?)`,
now,
now,
);
await execute(
`INSERT INTO os_protocol_talents(
id,protocol_id,name,description_template,created_at,updated_at
) VALUES('protocol-talent','protocol-1','Protocol Talent','',?,?)`,
now,
now,
);
await execute(
`INSERT INTO specializations(id,name,description,created_at,updated_at)
VALUES('specialization-1','Projection Specialization','Legacy summary.',?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_abilities(
id,specialization_id,name,description,created_at,updated_at
) VALUES('ability-1','specialization-1','Projection Ability','',?,?)`,
now,
now,
);
await execute(
`INSERT INTO skill_chip_sets(
id,name,specialization_ability_id,created_at,updated_at
) VALUES('chip-1','Projection Chip','ability-1',?,?)`,
now,
now,
);
await execute(
`INSERT INTO skill_chip_talents(
id,skill_chip_set_id,name,description_template,created_at,updated_at
) VALUES('chip-talent','chip-1','Chip Talent','',?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES
('focus-a','specialization-1','Alpha Focus','Firepower',0,?,?),
('focus-b','specialization-1','Bravo Focus','Toughness',1,?,?)`,
now,
now,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES
('node-a','focus-a','Alpha Node','',1,1,1,?,?),
('node-b','focus-b','Bravo Node','',1,1,1,?,?)`,
now,
now,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-a',1,''),('node-b',1,'')`,
);
await execute(
`INSERT INTO specialization_combat_effects(
id,specialization_id,name,description_template,created_at,updated_at
) VALUES
('effect-a1','specialization-1','Alpha First','',?,?),
('effect-a2','specialization-1','Alpha Second','',?,?),
('effect-b','specialization-1','Bravo Effect','',?,?)`,
now,
now,
now,
now,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES
('attachment-a1','node-a',1,'effect-a1',1,'enemy_hits_within_window',1,1,1,1),
('attachment-a2','node-a',1,'effect-a2',2,'enemy_hits_within_window',1,1,1,1),
('attachment-b','node-b',1,'effect-b',1,'enemy_hits_within_window',1,1,1,1)`,
);
await createProjection(
{ type: 'talent', id: 'talent-direct' },
'Findable Projection behavior.',
);
await createProjection(
{ type: 'talent', id: 'talent-weapon' },
'Weapon Projection behavior.',
);
const corrupt = await createProjection(
{ type: 'talent', id: 'talent-corrupt' },
'Corrupt Projection behavior.',
);
await execute(
`UPDATE mechanic_public_variants
SET behavior_text='Findable Projection forged behavior.' WHERE source_id=?`,
corrupt.id,
);
await createProjection(
{ type: 'os_protocol_talent', id: 'protocol-talent' },
'Protocol Projection behavior.',
{ modeled: true },
);
await createProjection(
{ type: 'skill_chip_talent', id: 'chip-talent' },
'Chip Projection behavior.',
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-a1' },
'Special Projection behavior.',
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-a2' },
'Special Projection behavior.',
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-b' },
'Special Projection behavior.',
);
}
describe('public database category loader', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
mocks.database = database;
await seedPublicOwners();
});
afterEach(async () => {
await database.close();
mocks.database = null;
});
it('maps valid projections into direct cards and every quick-view owner', async () => {
const talents = await loadCategory('talents');
const direct = talents.items.find((item) => item.id === 'talent-direct');
const corrupt = talents.items.find((item) => item.id === 'talent-corrupt');
expect(direct?.mechanicGroups).toMatchObject([
{
ownerType: 'talent',
variants: [
{
label: 'TIER I',
behaviorText: 'Findable Projection behavior.',
status: 'not_modeled',
displaySource: 'authored',
},
{ label: 'TIER II', behaviorText: 'Findable Projection behavior.' },
],
},
]);
expect(direct?.description).toBeUndefined();
expect(corrupt?.mechanicGroups).toEqual([]);
const gear = await loadCategory('gear');
const gearTalent = gear.items[0]?.iconGroups?.[1]?.items[0]?.preview;
expect(gearTalent?.mechanicGroups?.[0]).toMatchObject({
ownerType: 'talent',
});
expect(gearTalent?.mechanicGroups?.[0]?.variants[0]).toMatchObject({
behaviorText: 'Findable Projection behavior.',
});
const weapons = await loadCategory('weapons');
const weaponTalent = weapons.items[0]?.iconGroups?.[0]?.items[0]?.preview;
expect(weaponTalent?.mechanicGroups?.[0]).toMatchObject({
ownerType: 'talent',
});
expect(weaponTalent?.mechanicGroups?.[0]?.variants[0]).toMatchObject({
behaviorText: 'Weapon Projection behavior.',
});
const protocols = await loadCategory('os-protocols');
expect(protocols.items[0]?.mechanicGroups?.[0]).toMatchObject({
ownerType: 'os_protocol_talent',
variants: [
expect.objectContaining({
label: 'BASE',
status: 'modeled',
displaySource: 'model',
}),
],
});
expect(protocols.items[0]?.protocolPreview?.talentDescription).toBe(
protocols.items[0]?.mechanicGroups?.[0]?.variants[0]?.behaviorText,
);
const chips = await loadCategory('skill-chips');
expect(chips.items[0]?.mechanicGroups?.[0]).toMatchObject({
ownerType: 'skill_chip_talent',
variants: [
{ label: 'MINIMUM', behaviorText: 'Chip Projection behavior.' },
{ label: 'MAXIMUM', behaviorText: 'Chip Projection behavior.' },
],
});
expect(chips.items[0]?.description).toBeUndefined();
});
it('maps a Tier I-only Exotic Talent into weapon cards, behavior search, and compatible-weapon filters', async () => {
await execute(
`INSERT INTO talents(id,name,category,created_at,updated_at)
VALUES('talent-exotic','Exotic Weapon Talent','Weapon',?,?)`,
now,
now,
);
await execute(
`INSERT INTO weapons(
id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability,
exotic_talent_id,created_at,updated_at
) VALUES('weapon-exotic','Exotic Owner Rifle','Rifle',1,0,'Pierce',1,1,1,1,1,'talent-exotic',?,?)`,
now,
now,
);
await createProjection(
{ type: 'talent', id: 'talent-exotic' },
'Exotic Projection behavior.',
{ talentScope: 'exotic', keys: ['I'] },
);
const page = await loadCategory('weapons');
const exoticWeapon = page.items.find((item) => item.id === 'weapon-exotic');
const exoticTalent = exoticWeapon?.iconGroups?.find(
(group) => group.label === 'EXOTIC TALENT',
)?.items[0]?.preview;
expect(exoticTalent?.badges).toContainEqual({
label: 'EXOTIC',
tone: 'exotic',
});
expect(exoticTalent?.mechanicGroups).toMatchObject([
{
ownerType: 'talent',
variants: [
{
label: 'TIER I',
behaviorText: 'Exotic Projection behavior.',
status: 'not_modeled',
displaySource: 'authored',
},
],
},
]);
expect(exoticTalent?.mechanicGroups?.[0]?.variants).toHaveLength(1);
expect(exoticTalent?.actions).toContainEqual({
label: 'FIND COMPATIBLE WEAPONS',
href: '/database/weapons?weaponTalent=talent-exotic',
});
await expect(
loadCategory('weapons', '?q=Exotic%20Projection'),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'weapon-exotic' }],
});
await expect(
loadCategory('weapons', '?weaponTalent=talent-exotic'),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'weapon-exotic' }],
});
});
it('excludes archived Talent behavior from Gear and Weapon search results', async () => {
await execute(
`INSERT INTO talents(id,name,category,active,created_at,updated_at)
VALUES('talent-archived','Archived Linked Talent','Weapon',0,?,?)`,
now,
now,
);
await execute(
`INSERT INTO gear(
id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,
range_reference_level,created_at,updated_at
) VALUES('gear-archived','Archived Backpack','Backpack',0,10,10,NULL,NULL,50,?,?)`,
now,
now,
);
await execute(
`INSERT INTO gear_talents(gear_id,talent_id) VALUES('gear-archived','talent-archived')`,
);
await execute(
`INSERT INTO weapons(
id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability,
exotic_talent_id,created_at,updated_at
) VALUES('weapon-archived','Archived Rifle','Rifle',0,0,'Pierce',1,1,1,1,1,NULL,?,?)`,
now,
now,
);
await execute(
`INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id)
VALUES('weapon-archived',1,'talent-archived')`,
);
await createProjection(
{ type: 'talent', id: 'talent-archived' },
'Archived Linked Projection behavior.',
);
await expect(
loadCategory('gear', '?q=Archived%20Linked%20Projection'),
).resolves.toMatchObject({
pagination: { total: 0 },
items: [],
});
await expect(
loadCategory('weapons', '?q=Archived%20Linked%20Projection'),
).resolves.toMatchObject({
pagination: { total: 0 },
items: [],
});
});
it('preserves an exact Talent identity and icon URL', async () => {
const talentId = '00000000-0000-4000-8000-000000000003';
await execute(
`INSERT INTO talents(id,name,category,created_at,updated_at)
VALUES(?, 'Phase 2 Oversight Validated', 'Backpack', ?, ?)`,
talentId,
now,
now,
);
await execute(
`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
VALUES('talent', ?, ?, 1, ?)`,
talentId,
`talent-${talentId}.webp`,
now,
);
const page = await loadCategory(
'talents',
'?q=Phase%202%20Oversight%20Validated',
);
expect(page.items).toEqual([
expect.objectContaining({
id: talentId,
name: 'Phase 2 Oversight Validated',
iconUrl: `/icons/game-data/talent/${talentId}?v=2026-09-03T00%3A00%3A00.000Z`,
}),
]);
});
it('keeps specialization rank-effect owners independent and ordered', async () => {
const page = await loadCategory('specializations');
const groups = page.items[0]?.mechanicGroups;
expect(groups?.map((group) => group.id)).toEqual([
'attachment-a1',
'attachment-a2',
'attachment-b',
]);
expect(groups?.map((group) => group.ownerType)).toEqual([
'specialization_tree_rank',
'specialization_tree_rank',
'specialization_tree_rank',
]);
expect(groups?.[0]).toMatchObject({
label: 'Alpha First',
context:
'Alpha Focus // Alpha Node // RANK 1 // ROW 1, COLUMN 1 // EFFECT 1',
variants: [{ behaviorText: 'Special Projection behavior.' }],
});
});
it('excludes cross-specialization combat effects from category cards and search', async () => {
await execute(
`INSERT INTO specializations(id,name,created_at,updated_at)
VALUES('specialization-cross','Other Specialization',?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES('focus-cross','specialization-cross','Cross Focus','Firepower',0,?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES('node-cross','focus-cross','Cross Node','',1,1,1,?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-cross',1,'')`,
);
await execute(
`INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES('attachment-cross','node-cross',1,'effect-a1',1,
'enemy_hits_within_window',1,1,1,1)`,
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-cross' },
'Cross-specialization behavior.',
);
const page = await loadCategory('specializations');
expect(
page.items.find((item) => item.id === 'specialization-cross')
?.mechanicGroups,
).toEqual([]);
await expect(
loadCategory('specializations', '?q=Cross-specialization'),
).resolves.toMatchObject({
pagination: { total: 0 },
items: [],
});
});
it('uses validated behavior predicates for category count and rows', async () => {
const page = await loadCategory('talents', '?q=Findable%20Projection');
expect(page.pagination.total).toBe(1);
expect(page.items).toHaveLength(1);
expect(page.items[0]?.id).toBe('talent-direct');
expect(page.items[0]?.mechanicGroups).toHaveLength(1);
await expect(
loadCategory('talents', '?q=Findable%20Projection%20forged'),
).resolves.toMatchObject({
pagination: { total: 0 },
items: [],
});
await expect(
loadCategory('gear', '?q=Findable%20Projection'),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'gear-1' }],
});
await expect(
loadCategory('weapons', '?q=Weapon%20Projection'),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'weapon-1' }],
});
const protocolBehavior = (await loadCategory('os-protocols')).items[0]
?.mechanicGroups?.[0]?.variants[0]?.behaviorText;
await expect(
loadCategory(
'os-protocols',
`?q=${encodeURIComponent(protocolBehavior ?? '')}`,
),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'protocol-1' }],
});
await expect(
loadCategory('skill-chips', '?q=Chip%20Projection'),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'chip-1' }],
});
await expect(
loadCategory('specializations', '?q=Special%20Projection'),
).resolves.toMatchObject({
pagination: { total: 1 },
items: [{ id: 'specialization-1' }],
});
});
});
+435
View File
@@ -0,0 +1,435 @@
import type {
MechanicAuthoredOwner,
MechanicAuthoredSource,
} from '$lib/calculations/mechanic-authored-description';
import type { DatabaseMechanicStatus } from '$lib/database/items';
import type { MechanicPublicProjectionOwnerRef } from '$lib/server/mechanic-public-projection';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { saveMechanicAuthoredSourceInTransaction } from '$lib/server/mechanic-authored-sources';
import { refreshMechanicPublicProjectionInTransaction } from '$lib/server/mechanic-public-projection';
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
const { load } = await import('./+page.server');
let database: Database;
const now = '2026-09-03T00:00:00.000Z';
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 execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
function variantKeys(owner: MechanicPublicProjectionOwnerRef) {
switch (owner.type) {
case 'talent':
return ['I', 'II'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
}
const authoredOwner = (
owner: MechanicPublicProjectionOwnerRef,
talentScope: 'standard' | 'exotic' = 'standard',
): MechanicAuthoredOwner =>
owner.type === 'talent' ? { ...owner, scope: talentScope } : owner;
async function createProjection(
owner: MechanicPublicProjectionOwnerRef,
text: string,
options: {
talentScope?: 'standard' | 'exotic';
keys?: string[];
} = {},
) {
const source: MechanicAuthoredSource = {
version: 1,
owner: authoredOwner(owner, options.talentScope),
template: text,
variants: (options.keys ?? variantKeys(owner)).map((key) => ({
key,
materialized: text,
slots: [],
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: 'Rank description.',
effectId: `effect-${owner.id}`,
effectTemplate: text,
},
}
: {}),
};
const saved = await saveMechanicAuthoredSourceInTransaction(
database,
source,
{
expectedSourceFingerprint: null,
},
);
await refreshMechanicPublicProjectionInTransaction(database, saved);
return saved;
}
type GlobalSearchResult = {
slug: string;
category: string;
name: string;
detail: string;
behaviorText?: string;
status?: DatabaseMechanicStatus;
};
const loadGlobal = async (query: string) =>
(await load({
url: new URL(`http://localhost/database${query}`),
} as never)) as unknown as { searchResults: GlobalSearchResult[] };
async function seedSearchData() {
await execute(
`INSERT INTO talents(id,name,category,created_at,updated_at) VALUES
('talent-valid','Legacy Talent Name','Backpack',?,?),
('talent-corrupt','Corrupt Talent','Backpack',?,?),
('talent-name-only','Existing Name Search','Weapon',?,?),
('talent-exotic','Exotic Weapon Talent','Weapon',?,?)`,
now,
now,
now,
now,
now,
now,
now,
now,
);
await execute(
`INSERT INTO talents(id,name,category,active,created_at,updated_at)
VALUES('talent-archived','Archived Linked Talent','Weapon',0,?,?)`,
now,
now,
);
const corrupt = await createProjection(
{ type: 'talent', id: 'talent-corrupt' },
'Corrupt Projection behavior.',
);
await execute(
`UPDATE mechanic_public_variants
SET behavior_text='Findable Projection forged behavior.' WHERE source_id=?`,
corrupt.id,
);
await createProjection(
{ type: 'talent', id: 'talent-valid' },
'Findable Projection behavior.',
);
await createProjection(
{ type: 'talent', id: 'talent-exotic' },
'Exotic Projection behavior.',
{ talentScope: 'exotic', keys: ['I'] },
);
await createProjection(
{ type: 'talent', id: 'talent-archived' },
'Archived Linked Projection behavior.',
);
await execute(
`INSERT INTO gear(
id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,
range_reference_level,created_at,updated_at
) VALUES('gear-archived','Archived Projection Backpack','Backpack',0,10,10,NULL,NULL,50,?,?)`,
now,
now,
);
await execute(
`INSERT INTO gear_talents(gear_id,talent_id) VALUES('gear-archived','talent-archived')`,
);
await execute(
`INSERT INTO weapons(
id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability,
exotic_talent_id,created_at,updated_at
) VALUES
('weapon-exotic','Exotic Projection Rifle','Rifle',1,0,'Pierce',1,1,1,1,1,'talent-exotic',?,?),
('weapon-archived','Archived Projection Rifle','Rifle',0,0,'Pierce',1,1,1,1,1,NULL,?,?)`,
now,
now,
now,
now,
);
await execute(
`INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id)
VALUES('weapon-archived',1,'talent-archived')`,
);
await execute(
`INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at)
VALUES('protocol-1','Protocol','Standard','Firepower',?,?)`,
now,
now,
);
await execute(
`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at)
VALUES('protocol-talent','protocol-1','Protocol Talent','',?,?)`,
now,
now,
);
await createProjection(
{ type: 'os_protocol_talent', id: 'protocol-talent' },
'Protocol Projection behavior.',
);
await execute(
`INSERT INTO specializations(id,name,created_at,updated_at)
VALUES('specialization-1','Specialization',?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_abilities(id,specialization_id,name,description,created_at,updated_at)
VALUES('ability-1','specialization-1','Ability','',?,?)`,
now,
now,
);
await execute(
`INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at)
VALUES('chip-1','Chip','ability-1',?,?)`,
now,
now,
);
await execute(
`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at)
VALUES('chip-talent','chip-1','Chip Talent','',?,?)`,
now,
now,
);
await createProjection(
{ type: 'skill_chip_talent', id: 'chip-talent' },
'Chip Projection behavior.',
);
await execute(
`INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES('focus-1','specialization-1','Focus','Firepower',0,?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES('node-1','focus-1','Node','',1,1,1,?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-1',1,'')`,
);
await execute(
`INSERT INTO specialization_combat_effects(
id,specialization_id,name,description_template,created_at,updated_at
) VALUES
('effect-1','specialization-1','Effect One','',?,?),
('effect-2','specialization-1','Effect Two','',?,?)`,
now,
now,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES
('attachment-1','node-1',1,'effect-1',1,'enemy_hits_within_window',1,1,1,1),
('attachment-2','node-1',1,'effect-2',2,'enemy_hits_within_window',1,1,1,1)`,
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-1' },
'Special Projection behavior.',
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-2' },
'Special Projection behavior.',
);
}
describe('public database global search loader', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
mocks.database = database;
await seedSearchData();
});
afterEach(async () => {
await database.close();
mocks.database = null;
});
it('searches only canonical behavior and excludes corrupt cache text', async () => {
const page = await loadGlobal('?q=Findable%20Projection');
const valid = page.searchResults.find(
(result) => result.name === 'Legacy Talent Name',
);
expect(valid).toMatchObject({
behaviorText: 'Findable Projection behavior.',
status: 'not_modeled',
});
expect(page.searchResults).not.toContainEqual(
expect.objectContaining({ name: 'Corrupt Talent' }),
);
await expect(
loadGlobal('?q=Findable%20Projection%20forged'),
).resolves.toMatchObject({ searchResults: [] });
});
it('finds an Exotic weapon through its Tier I-only Talent projection', async () => {
const page = await loadGlobal('?q=Exotic%20Projection');
expect(page.searchResults).toContainEqual(
expect.objectContaining({
slug: 'weapons',
name: 'Exotic Projection Rifle',
behaviorText: 'Exotic Projection behavior.',
status: 'not_modeled',
}),
);
});
it('excludes archived Talent projections from linked Gear and Weapon results', async () => {
await expect(loadGlobal('?q=Archived%20Linked%20Projection')).resolves.toMatchObject({
searchResults: [],
});
});
it('excludes cross-specialization combat effects from global behavior search', async () => {
await execute(
`INSERT INTO specializations(id,name,created_at,updated_at)
VALUES('specialization-cross','Other Specialization',?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES('focus-cross','specialization-cross','Cross Focus','Firepower',0,?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES('node-cross','focus-cross','Cross Node','',1,1,1,?,?)`,
now,
now,
);
await execute(
`INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-cross',1,'')`,
);
await execute(
`INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES('attachment-cross','node-cross',1,'effect-1',1,
'enemy_hits_within_window',1,1,1,1)`,
);
await createProjection(
{ type: 'specialization_tree_rank', id: 'attachment-cross' },
'Cross-specialization behavior.',
);
await expect(loadGlobal('?q=Cross-specialization')).resolves.toMatchObject({
searchResults: [],
});
});
it('preserves exact Talent identity in global search results', async () => {
await execute(
`INSERT INTO talents(id,name,category,created_at,updated_at)
VALUES('talent-identity','Phase 2 Oversight Validated','Backpack',?,?)`,
now,
now,
);
await expect(
loadGlobal('?q=Phase%202%20Oversight%20Validated'),
).resolves.toMatchObject({
searchResults: [
expect.objectContaining({
slug: 'talents',
name: 'Phase 2 Oversight Validated',
}),
],
});
});
it('deduplicates matching specialization attachments to one parent result', async () => {
const page = await loadGlobal('?q=Special%20Projection');
const results = page.searchResults.filter(
(result) => result.slug === 'specializations',
);
expect(results).toEqual([
expect.objectContaining({
name: 'Specialization',
behaviorText: 'Special Projection behavior.',
status: 'not_modeled',
}),
]);
});
it('finds projected protocol and chip behavior and preserves name search', async () => {
await expect(loadGlobal('?q=Protocol%20Projection')).resolves.toMatchObject(
{
searchResults: [
expect.objectContaining({
slug: 'os-protocols',
behaviorText: 'Protocol Projection behavior.',
status: 'not_modeled',
}),
],
},
);
await expect(loadGlobal('?q=Chip%20Projection')).resolves.toMatchObject({
searchResults: [
expect.objectContaining({
slug: 'skill-chips',
behaviorText: 'Chip Projection behavior.',
status: 'not_modeled',
}),
],
});
await expect(
loadGlobal('?q=Existing%20Name%20Search'),
).resolves.toMatchObject({
searchResults: [
expect.objectContaining({
slug: 'talents',
name: 'Existing Name Search',
}),
],
});
});
});
+73
View File
@@ -0,0 +1,73 @@
import type {
DatabaseMechanicGroup,
DatabaseMechanicStatus,
} from '$lib/database/items';
import {
mechanicPublicProjectionOwnerKey,
type MechanicPublicProjectionOwnerRef,
type MechanicPublicProjectionsByOwner,
} from '$lib/server/mechanic-public-projection';
const variantLabel = (key: string) => {
switch (key) {
case 'I':
return 'TIER I';
case 'II':
return 'TIER II';
case 'minimum':
return 'MINIMUM';
case 'maximum':
return 'MAXIMUM';
case 'base':
return 'BASE';
default:
return key.toUpperCase();
}
};
/** Converts only a validated public projection into a card-safe group. */
export function databaseMechanicGroup(
owner: MechanicPublicProjectionOwnerRef,
projections: MechanicPublicProjectionsByOwner,
options: { id?: string; label: string; context?: string },
): DatabaseMechanicGroup | null {
const variants = projections[mechanicPublicProjectionOwnerKey(owner)] ?? [];
if (!variants.length) return null;
return {
id: options.id ?? owner.id,
ownerType: owner.type,
label: options.label,
...(options.context ? { context: options.context } : {}),
variants: variants.map((variant) => ({
label: variantLabel(variant.variantKey),
behaviorText: variant.behaviorText,
status: variant.status,
displaySource: variant.displaySource,
})),
};
}
/** Matches only behavior returned by the canonical validated projection loader. */
export function databaseMechanicOwnersMatchSearch(
owners: MechanicPublicProjectionOwnerRef[],
projections: MechanicPublicProjectionsByOwner,
search: string,
) {
const normalizedSearch = search.toLocaleLowerCase();
return owners.some((owner) =>
(projections[mechanicPublicProjectionOwnerKey(owner)] ?? []).some(
(variant) =>
variant.behaviorText.toLocaleLowerCase().includes(normalizedSearch),
),
);
}
export function databaseMechanicSearchSummary(
group: DatabaseMechanicGroup | null,
): { behaviorText: string; status: DatabaseMechanicStatus } | null {
const first = group?.variants[0];
return first
? { behaviorText: first.behaviorText, status: first.status }
: null;
}
+438 -248
View File
@@ -3,15 +3,33 @@ import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import {
approvePhase2Proposal,
deserializeProposal,
failPhase2Proposal,
MAX_PROPOSAL_TEXT_ENTRY_CHARS,
ProposalCanonicalValidationError,
ProposalPayloadValidationError,
ProposalDependenciesBlockedError,
ProposalDependencyCycleError,
ProposalNotPendingError,
parseSerializedProposalEntries,
refreshProposalDependencies,
validateSerializedProposalEntries,
} from '$lib/server/data-moderation';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
import { isPreviewFieldHidden, safePreviewValue } from '$lib/components/ProposalChangePreview.helpers';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
import { talentOwnerAuditSnapshot } from '../talents/talent-save';
import { protocolSnapshot } from '../protocols/protocol-save';
import { snapshot as skillChipSnapshot } from '../skillchips/skillchip-save';
import {
combatEffectSnapshot,
nodeSnapshot,
} from '../specializations/specialization-save';
import {
getSkillChipPrimaryProfiles,
getSkillChipSecondaryPool,
@@ -20,6 +38,9 @@ import { gameDataIconMetadata } from '$lib/server/game-data-icons';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const INVALID_PROPOSAL_PAYLOAD_MESSAGE = 'The stored proposal payload is invalid.';
const CANONICAL_FAILURE_MESSAGE =
'The proposal did not pass current canonical validation.';
const modules = [
'configuration',
'calculations',
@@ -44,7 +65,31 @@ type SerializedEntry =
type: string;
};
async function specializationProposalSnapshot(database: any, proposal: any) {
function proposalPayloadIsValid(payload: unknown) {
try {
parseSerializedProposalEntries(String(payload));
return true;
} catch (error) {
if (error instanceof ProposalPayloadValidationError) return false;
throw error;
}
}
async function notifySafely(
input: Parameters<typeof createNotification>[0],
context: string,
) {
try {
await createNotification(input);
} catch (error) {
console.error(`Unable to notify Data Oversight ${context}`, error);
}
}
async function specializationProposalSnapshot(
database: Database,
proposal: Record<string, unknown>,
) {
const id = String(proposal.entity_id ?? '');
if (proposal.action === 'saveProgression') {
return {
@@ -108,21 +153,8 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
).all(id, id),
};
}
if (proposal.action === 'saveCombatEffect') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_combat_effects WHERE id=?',
)
).get(id)) ?? null,
attachments: await (
await database.prepare(
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order',
)
).all(id),
};
}
if (proposal.action === 'saveCombatEffect')
return combatEffectSnapshot(database, id);
if (proposal.action === 'saveFocusAbilities') {
return {
record:
@@ -138,72 +170,12 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
).all(id),
};
}
if (proposal.action === 'saveNode') {
return {
record:
(await (
await database.prepare(
'SELECT * FROM specialization_tree_nodes WHERE id=?',
)
).get(id)) ?? null,
ranks: await (
await database.prepare(
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank',
)
).all(id),
effects: await (
await database.prepare(
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order',
)
).all(id),
costs: await (
await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id',
)
).all(id),
combatEffects: await (
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order',
)
).all(id),
};
}
if (proposal.action === 'saveNode') return nodeSnapshot(database, id);
return null;
}
async function skillChipProposalSnapshot(database: any, id: string) {
const talent = await (
await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id);
return {
record:
(await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get(id)) ?? null,
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'skill_chip_talent',
String(talent.id),
)
: null,
};
async function skillChipProposalSnapshot(database: Database, id: string) {
return skillChipSnapshot(database, id);
}
async function calculationProposalSnapshot(database: any, proposal: any) {
@@ -316,40 +288,55 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const proposalsWithSnapshots = await Promise.all(
proposals.map(async (proposal) => ({
...proposal,
payloadValid: proposalPayloadIsValid(proposal.payload_json),
current_snapshot:
proposal.module === 'attributes' && proposal.entity_id
? ((await (
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
).get(String(proposal.entity_id))) ?? null)
: proposal.module === 'skillchips' &&
proposal.action === 'savePrimaryProfile'
? await getSkillChipPrimaryProfiles(database)
: proposal.module === 'skillchips' &&
proposal.action === 'saveSecondaryPool'
? await getSkillChipSecondaryPool(database)
: proposal.module === 'weaponmods' &&
proposal.action === 'saveSlotIcon' &&
proposal.entity_id
? {
slot: weaponModSlotDefinitionById(
String(proposal.entity_id),
),
icon: await gameDataIconMetadata(
database,
'weapon_mod_slot',
String(proposal.entity_id),
),
}
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null,
: proposal.module === 'talents' &&
proposal.action === 'saveTalent' &&
proposal.entity_id
? await talentOwnerAuditSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'protocols' &&
proposal.action === 'save' &&
proposal.entity_id
? await protocolSnapshot(String(proposal.entity_id), database)
: proposal.module === 'skillchips' &&
proposal.action === 'savePrimaryProfile'
? await getSkillChipPrimaryProfiles(database)
: proposal.module === 'skillchips' &&
proposal.action === 'saveSecondaryPool'
? await getSkillChipSecondaryPool(database)
: proposal.module === 'weaponmods' &&
proposal.action === 'saveSlotIcon' &&
proposal.entity_id
? {
slot: weaponModSlotDefinitionById(
String(proposal.entity_id),
),
icon: await gameDataIconMetadata(
database,
'weapon_mod_slot',
String(proposal.entity_id),
),
}
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(
database,
proposal,
)
: null,
})),
);
const summaryRows = await (
@@ -392,13 +379,19 @@ export const actions: Actions = {
moderationError: 'The proposed corrections could not be read.',
});
}
if (
new Set(corrections.map((item) => item.index)).size !== corrections.length
)
return fail(400, {
moderationError: 'Each proposal field may only be corrected once.',
});
if (
corrections.length > 250 ||
corrections.some(
(item) =>
!Number.isInteger(item.index) ||
item.index < 0 ||
item.value.length > 100_000,
Array.from(item.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS,
)
) {
return fail(400, {
@@ -407,87 +400,150 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const proposal = await (
await database.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(id);
if (!proposal)
return fail(404, {
moderationError: 'That pending proposal no longer exists.',
});
const entries = JSON.parse(
String(proposal.payload_json),
) as SerializedEntry[];
const changes: Array<{
index: number;
name: string;
before: string;
after: string;
}> = [];
for (const correction of corrections) {
const entry = entries[correction.index];
if (!entry || entry.kind !== 'text')
return fail(400, {
moderationError:
'Only existing text and numeric proposal fields may be corrected.',
});
if (entry.value !== correction.value) {
changes.push({
index: correction.index,
name: entry.name,
before: entry.value,
after: correction.value,
});
entry.value = correction.value;
}
}
if (!changes.length)
return fail(400, { moderationError: 'No proposal fields were changed.' });
const payload = JSON.stringify(entries);
const oldDependencies = (
await (
await database.prepare(
'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id',
const correctProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).all(id)
).map((row) => String(row.depends_on_proposal_id));
const name = entries
.find((entry) => entry.kind === 'text' && entry.name === 'name')
?.value.trim();
const summary = `${String(proposal.action)
.replace(/([A-Z])/g, ' $1')
.toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
await (
await database.prepare(
'UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?',
)
).run(payload, summary, id);
const dependencies = await refreshProposalDependencies(id, payload);
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
action: 'proposal.corrected',
entityId: id,
before: {
summary: proposal.summary,
dependencies: oldDependencies,
fields: changes.map((change) => ({
index: change.index,
name: change.name,
value: change.before,
})),
},
after: {
summary,
dependencies,
fields: changes.map((change) => ({
index: change.index,
name: change.name,
value: change.after,
})),
},
).get(id);
if (!proposal) throw new ProposalNotPendingError();
let entries: SerializedEntry[];
try {
entries = parseSerializedProposalEntries(String(proposal.payload_json));
} catch (error) {
if (error instanceof ProposalPayloadValidationError)
return {
ok: false as const,
moderationError: INVALID_PROPOSAL_PAYLOAD_MESSAGE,
};
throw error;
}
const changes: Array<{
index: number;
name: string;
before: string;
after: string;
}> = [];
for (const correction of corrections) {
const entry = entries[correction.index];
if (
entry &&
(entry.name === 'id' ||
entry.name === '_proposedEntityId' ||
entry.name === 'expectedTalentId' ||
entry.name === 'expectedOwnerUpdatedAt' ||
entry.name === 'ranksJson')
)
return {
ok: false as const,
moderationError: 'Proposal identities cannot be corrected.',
};
if (!entry || entry.kind !== 'text')
return {
ok: false as const,
moderationError:
'Only existing text and numeric proposal fields may be corrected.',
};
if (
isPreviewFieldHidden(entry.name) ||
safePreviewValue(entry.name, entry.value) !== entry.value ||
safePreviewValue(entry.name, correction.value) !== correction.value
)
return {
ok: false as const,
moderationError:
'Protected proposal fields cannot be corrected.',
};
if (entry.value !== correction.value) {
changes.push({
index: correction.index,
name: entry.name,
before: entry.value,
after: correction.value,
});
entry.value = correction.value;
}
}
if (!changes.length)
return {
ok: false as const,
moderationError: 'No proposal fields were changed.',
};
try {
validateSerializedProposalEntries(entries);
} catch (error) {
if (error instanceof ProposalPayloadValidationError)
return {
ok: false as const,
moderationError:
'One or more corrected fields exceed the safe proposal limits.',
};
throw error;
}
const payload = JSON.stringify(entries);
const name = entries
.find((entry) => entry.kind === 'text' && entry.name === 'name')
?.value.trim();
const summary = `${String(proposal.action)
.replace(/([A-Z])/g, ' $1')
.toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
const oldDependencies = (
await (
await tx.prepare(
'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id',
)
).all(id)
).map((row) => String(row.depends_on_proposal_id));
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=? AND status='pending'",
)
).run(payload, summary, id);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
const dependencies = await refreshProposalDependencies(id, payload, tx);
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
action: 'proposal.corrected',
entityId: id,
before: {
summaryLength: Array.from(String(proposal.summary)).length,
dependencies: oldDependencies,
fields: changes.map((change) => ({
index: change.index,
name: change.name,
changed: true,
valueLength: Array.from(change.before).length,
})),
},
after: {
summaryLength: Array.from(summary).length,
dependencies,
fields: changes.map((change) => ({
index: change.index,
name: change.name,
changed: true,
valueLength: Array.from(change.after).length,
})),
},
database: tx,
});
return { ok: true as const };
});
try {
const result = await correctProposal.immediate();
if (!result.ok)
return fail(400, { moderationError: result.moderationError });
} catch (error) {
if (error instanceof ProposalNotPendingError)
return fail(404, { moderationError: error.message });
if (error instanceof ProposalDependencyCycleError)
return fail(409, { moderationError: error.message });
throw error;
}
return { moderationSaved: true, moderationToken: randomUUID() };
},
approve: async ({ cookies, request, fetch, url }) => {
@@ -510,11 +566,81 @@ export const actions: Actions = {
return fail(404, {
moderationError: 'That pending proposal no longer exists.',
});
const phase2 =
(proposal.module === 'talents' && proposal.action === 'saveTalent') ||
(proposal.module === 'protocols' && proposal.action === 'save') ||
(proposal.module === 'skillchips' && proposal.action === 'save') ||
(proposal.module === 'specializations' &&
(proposal.action === 'saveNode' ||
proposal.action === 'saveCombatEffect'));
if (phase2) {
try {
const result = await approvePhase2Proposal(
database,
reviewer.id,
id,
note || null,
);
await notifySafely(
{
userId: String(result.proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution approved',
body: `${String(result.proposal.summary)} was approved and deployed to the live builder data set.`,
link: `/isac/${result.proposal.module}`,
mandatory: true,
},
'contributor of approved proposal',
);
return { moderationSaved: true, moderationToken: randomUUID() };
} catch (error) {
if (error instanceof ProposalDependenciesBlockedError)
return fail(409, { moderationError: error.message });
if (error instanceof ProposalNotPendingError)
return fail(404, { moderationError: error.message });
if (error instanceof ProposalCanonicalValidationError) {
const message = error.message;
let failedProposal;
try {
failedProposal = await failPhase2Proposal(
database,
reviewer.id,
id,
message,
);
} catch (failureError) {
if (failureError instanceof ProposalNotPendingError)
return fail(404, { moderationError: failureError.message });
throw failureError;
}
await notifySafely(
{
userId: String(failedProposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution could not be deployed',
body: `${String(failedProposal.summary)} did not pass current validation. Oversight evidence is available in your notification inbox.`,
link: `/isac/${failedProposal.module}`,
mandatory: true,
},
'contributor of failed proposal',
);
return fail(409, {
moderationError:
'The proposal did not pass the current canonical validation and was marked failed.',
});
}
throw error;
}
}
const unresolved = await (
await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
).all(id);
if (unresolved.length) {
const names = unresolved
@@ -528,7 +654,59 @@ export const actions: Actions = {
`/isac/${proposal.module}?/${proposal.action}`,
url.origin,
);
const proposalForm = deserializeProposal(String(proposal.payload_json));
let proposalForm: FormData;
try {
proposalForm = deserializeProposal(String(proposal.payload_json));
} catch (error) {
if (!(error instanceof ProposalPayloadValidationError)) throw error;
const failStoredPayload = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
)
).run(
reviewer.id,
INVALID_PROPOSAL_PAYLOAD_MESSAGE,
now,
id,
);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
action: 'proposal.failed',
entityId: id,
before: { status: 'pending' },
after: { status: 'failed', reason: 'invalid_payload' },
database: tx,
});
});
try {
await failStoredPayload.immediate();
} catch (failureError) {
if (failureError instanceof ProposalNotPendingError)
return fail(404, { moderationError: failureError.message });
throw failureError;
}
await notifySafely(
{
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution could not be deployed',
body: `${proposal.summary} could not be deployed because its stored payload is invalid.`,
link: `/isac/${proposal.module}`,
mandatory: true,
},
'contributor of invalid proposal',
);
return fail(409, {
moderationError:
'The proposal could not be deployed because its stored payload is invalid and was marked failed.',
});
}
if (proposal.draft_entity_id && !proposal.entity_id)
proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
const response = await fetch(target, {
@@ -538,17 +716,11 @@ export const actions: Actions = {
});
const now = new Date().toISOString();
if (!response.ok) {
const details = (await response.text()).slice(0, 1000);
await (
await database.prepare(
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
)
).run(
reviewer.id,
details || note || 'Canonical validation failed.',
now,
id,
);
).run(reviewer.id, CANONICAL_FAILURE_MESSAGE, now, id);
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
@@ -557,15 +729,18 @@ export const actions: Actions = {
before: { status: 'pending' },
after: { status: 'failed', responseStatus: response.status },
});
await createNotification({
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution could not be deployed',
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
await notifySafely(
{
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution could not be deployed',
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
link: `/isac/${proposal.module}`,
mandatory: true,
},
'contributor of failed proposal',
);
return fail(409, {
moderationError:
'The proposal did not pass the current canonical validation and was marked failed.',
@@ -598,7 +773,9 @@ export const actions: Actions = {
body: `${proposal.summary} was approved and deployed to the live builder data set.`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
}).catch((error) =>
console.error('Unable to notify Data Oversight contributor', error),
);
return { moderationSaved: true, moderationToken: randomUUID() };
},
reject: async ({ cookies, request }) => {
@@ -616,46 +793,59 @@ export const actions: Actions = {
moderationError: 'Add a brief reason for rejecting this proposal.',
});
const database = await getDatabase();
const proposal = await (
await database.prepare(
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(id);
if (!proposal)
return fail(404, {
moderationError: 'That pending proposal no longer exists.',
const rejectProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(id);
if (!proposal) throw new ProposalNotPendingError();
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
)
).run(reviewer.id, note, now, id);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
action: 'proposal.rejected',
entityId: id,
before: { status: 'pending' },
after: {
status: 'rejected',
module: proposal.module,
action: proposal.action,
proposedBy: proposal.proposed_by,
},
database: tx,
});
const now = new Date().toISOString();
await (
await database.prepare(
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
)
).run(reviewer.id, note, now, id);
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
action: 'proposal.rejected',
entityId: id,
before: { status: 'pending' },
after: {
status: 'rejected',
note,
module: proposal.module,
action: proposal.action,
proposedBy: proposal.proposed_by,
return proposal as Record<string, unknown>;
});
let proposal: Record<string, unknown>;
try {
proposal = await rejectProposal.immediate();
} catch (error) {
if (error instanceof ProposalNotPendingError)
return fail(404, { moderationError: error.message });
throw error;
}
await notifySafely(
{
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution needs revision',
body: `${String(proposal.action)
.replace(/([A-Z])/g, ' $1')
.trim()} was not approved. Oversight reason: ${note}`,
link: `/isac/${proposal.module}`,
mandatory: true,
},
});
await createNotification({
userId: String(proposal.proposed_by),
category: 'contributions',
source: 'ISAC DATA OVERSIGHT',
title: 'Contribution needs revision',
body: `${String(proposal.action)
.replace(/([A-Z])/g, ' $1')
.trim()} was not approved. Oversight reason: ${note}`,
link: `/isac/${proposal.module}`,
mandatory: true,
});
'contributor of rejected proposal',
);
return { moderationSaved: true, moderationToken: randomUUID() };
},
};
+62 -21
View File
@@ -5,26 +5,57 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import { isPreviewFieldHidden, parseProposalPreviewPayload, safePreviewValue } from '$lib/components/ProposalChangePreview.helpers';
import { correctionFieldIsEditable, correctionPayloadForSubmission, MAX_CONTRIBUTOR_NOTE_CHARS, MAX_REVIEW_NOTE_CHARS, renderBoundedPlainText, type CorrectionEntry } from './data-moderation.helpers';
let { data, form } = $props();
let reviewing = $state<any>(null);
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
let correctionEntries = $state<Array<{ index: number; name: string; value: string; kind: string; fileName?: string }>>([]);
let correctionEntries = $state<CorrectionEntry[]>([]);
let handledToken = $state('');
const omittedPreview = '[OMITTED FROM HUMAN PREVIEW]';
type EvidenceEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; fileName: string; type: string; encodedBytes: number };
const date = (value: string) => new Date(value).toLocaleString();
const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) =>
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
);
const evidence = (payload: string): EvidenceEntry[] => parseProposalPreviewPayload(payload).entries.reduce<EvidenceEntry[]>((result, entry) => {
if (isPreviewFieldHidden(entry.name)) return result;
if (entry.kind === 'file') {
result.push({
name: entry.name,
kind: 'file',
fileName: safePreviewValue('fileName', entry.fileName ?? 'UPLOADED FILE'),
type: safePreviewValue('type', entry.type ?? 'FILE EVIDENCE'),
encodedBytes: typeof entry.value === 'string' ? entry.value.length : 0
});
return result;
}
const value = safePreviewValue(entry.name, entry.value ?? '');
if (value !== omittedPreview) result.push({ name: entry.name, kind: 'text', value });
return result;
}, []);
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value === 'gearmods' ? 'GEAR MODS' : value.toUpperCase();
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
const openReview = (proposal: any) => {
reviewing = proposal;
decision = 'approve';
correctionEntries = (JSON.parse(proposal.payload_json) as any[]).map((entry, index) => ({
index, name: entry.name, value: entry.kind === 'text' ? entry.value : '', kind: entry.kind, fileName: entry.fileName
}));
correctionEntries = parseProposalPreviewPayload(String(proposal.payload_json)).entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => !isPreviewFieldHidden(entry.name))
.map(({ entry, index }) => {
const value = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value ?? '') : '';
return {
index,
name: entry.name,
value,
initialValue: value,
kind: entry.kind,
editable: entry.kind === 'text' && correctionFieldIsEditable(entry.name, value, entry.value ?? ''),
fileName: entry.kind === 'file' ? safePreviewValue('fileName', entry.fileName ?? 'UPLOADED FILE') : undefined
};
});
};
const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value })));
const correctionPayload = () => correctionPayloadForSubmission(correctionEntries);
const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses', 'qualityValues'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{');
$effect(() => {
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
@@ -71,24 +102,33 @@
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
{#if reviewing.contributor_note}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{reviewing.contributor_note}</p></div>{/if}
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
<details>
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
</details>
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
{#if renderBoundedPlainText(reviewing.contributor_note, MAX_CONTRIBUTOR_NOTE_CHARS)}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{renderBoundedPlainText(reviewing.contributor_note, MAX_CONTRIBUTOR_NOTE_CHARS)}</p></div>{/if}
{#if reviewing.payloadValid === false}
<div class="proposal-empty-state error" role="status">
<strong>PROPOSAL EVIDENCE UNAVAILABLE</strong>
<span>The stored submission payload is invalid and cannot be previewed or corrected. Approval remains available for canonical validation; rejection remains available for contributor revision.</span>
</div>
{:else}
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
<details>
<summary>SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
</details>
{/if}
{#if renderBoundedPlainText(reviewing.review_note, MAX_REVIEW_NOTE_CHARS)}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{renderBoundedPlainText(reviewing.review_note, MAX_REVIEW_NOTE_CHARS)}</p></div>{/if}
{#if reviewing.status === 'pending'}
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>FIX MINOR ERRORS</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>
{#if decision === 'correct'}
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} disabled={reviewing.payloadValid === false} title={reviewing.payloadValid === false ? 'Correction is unavailable because the stored payload is invalid.' : undefined} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>{reviewing.payloadValid === false ? 'UNAVAILABLE' : 'FIX MINOR ERRORS'}</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>
{#if decision === 'correct' && reviewing.payloadValid === true}
<PendingForm action="?/correct">
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/><input type="hidden" name="corrections" value={correctionPayload()}/>
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text').length} EDITABLE FIELDS</b></div>
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES AND REDACTED FIELDS REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text' && entry.editable).length} EDITABLE FIELDS</b></div>
<div class="proposal-correction-grid">
{#each correctionEntries as entry}
{#if entry.kind === 'text'}
{#if entry.kind === 'text' && entry.editable}
<label class="field"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea bind:value={entry.value}></textarea>{:else}<input bind:value={entry.value}/>{/if}</label>
{:else if entry.kind === 'text'}
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea readonly aria-label={`${operationLabel(entry.name)} safe preview`} value={entry.value}></textarea>{:else}<input readonly aria-label={`${operationLabel(entry.name)} safe preview`} value={entry.value}/>{/if}<small>SAFE PREVIEW DIFFERS FROM STORED VALUE · PROTECTED METADATA IS IMMUTABLE</small></div>
{:else}
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
{/if}
@@ -99,16 +139,17 @@
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CORRECTIONS →'}</button></div>
{/snippet}
</PendingForm>
{:else}
{:else if decision === 'approve' || decision === 'reject'}
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/>
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for the audit trail…' : 'Explain what the contributor should revise…'}></textarea></label>
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for this proposal…' : 'Explain what the contributor should revise…'}></textarea></label>
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
<div class="moderation-warning" class:rejecting={decision === 'reject'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL WILL RUN VALIDATION AND MUTATE THE LIVE BUILDER DATA SET.' : 'REJECTION WILL PRESERVE THIS REQUEST AND YOUR REASON IN THE AUDIT TRAIL.'}</span></div>
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM & DEPLOY →' : 'CONFIRM REJECTION →'}</button></div>
{/snippet}
</PendingForm>
{:else}<div class="proposal-empty-state error" role="status"><strong>CORRECTION UNAVAILABLE</strong><span>This stored proposal cannot be corrected until its payload is valid.</span></div>
{/if}
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
correctionFieldIsEditable,
correctionPayloadForSubmission,
MAX_CONTRIBUTOR_NOTE_CHARS,
MAX_REVIEW_NOTE_CHARS,
renderBoundedPlainText,
type CorrectionEntry,
} from './data-moderation.helpers.js';
const entry = (overrides: Partial<CorrectionEntry> = {}): CorrectionEntry => ({
index: 0,
name: 'name',
value: 'safe value',
initialValue: 'safe value',
kind: 'text',
editable: true,
...overrides,
});
describe('Data Oversight correction editor safety', () => {
it('locks a field when preview redaction changes its serialized value', () => {
expect(
correctionFieldIsEditable(
'authoredSource',
'{"rank":3}',
'{"rank":3,"sourceFingerprint":"protected"}',
),
).toBe(false);
expect(
correctionFieldIsEditable('name', 'safe value', 'safe value'),
).toBe(true);
});
it.each([
'id',
'_proposedEntityId',
'expectedTalentId',
'expectedOwnerUpdatedAt',
'ranksJson',
])(
'locks the protected %s identity field even when preview redaction is unchanged',
(fieldName) => {
expect(correctionFieldIsEditable(fieldName, '', '')).toBe(false);
},
);
it('does not submit changed protected identity entries from stale UI state', () => {
expect(
correctionPayloadForSubmission([
entry({ name: 'id', value: 'forged-id', editable: false }),
]),
).toBe('[]');
});
it('never submits an immutable redacted entry even if its displayed value changes', () => {
expect(
correctionPayloadForSubmission([
entry({
index: 4,
value: '{"rank":4}',
initialValue: '{"rank":3}',
editable: false,
}),
entry({ index: 5, value: 'corrected' }),
]),
).toBe('[{"index":5,"value":"corrected"}]');
});
});
describe('Data Oversight note renderer', () => {
it('keeps notes plain text and bounded without parsing structured values', () => {
const note = '<b>review rationale</b>';
expect(renderBoundedPlainText(note, MAX_REVIEW_NOTE_CHARS)).toBe(note);
expect(renderBoundedPlainText({ note }, MAX_REVIEW_NOTE_CHARS)).toBe('');
expect(
renderBoundedPlainText('x'.repeat(MAX_REVIEW_NOTE_CHARS + 1), MAX_REVIEW_NOTE_CHARS),
).toHaveLength(MAX_REVIEW_NOTE_CHARS);
expect(
renderBoundedPlainText(
'x'.repeat(MAX_CONTRIBUTOR_NOTE_CHARS + 1),
MAX_CONTRIBUTOR_NOTE_CHARS,
),
).toHaveLength(MAX_CONTRIBUTOR_NOTE_CHARS);
});
});
@@ -0,0 +1,57 @@
import type { ProposalPreviewEntry } from '$lib/components/ProposalChangePreview.helpers';
export const MAX_REVIEW_NOTE_CHARS = 500;
export const MAX_CONTRIBUTOR_NOTE_CHARS = 1_000;
export type CorrectionEntry = {
index: number;
name: string;
value: string;
/** The safe value shown in the editor before an operator makes a change. */
initialValue: string;
kind: ProposalPreviewEntry['kind'];
fileName?: string;
editable: boolean;
};
/**
* A correction is safe only when redaction left the serialized field unchanged.
* The serialized value is compared transiently and is never retained in the UI state.
*/
const IMMUTABLE_CORRECTION_FIELDS = new Set([
'id',
'_proposedEntityId',
'expectedTalentId',
'expectedOwnerUpdatedAt',
'ranksJson',
]);
export const correctionFieldIsEditable = (
fieldName: string,
safeValue: string,
serializedValue: string,
): boolean =>
!IMMUTABLE_CORRECTION_FIELDS.has(fieldName) && safeValue === serializedValue;
export const correctionPayloadForSubmission = (
entries: readonly CorrectionEntry[],
): string =>
JSON.stringify(
entries
.filter(
(entry) =>
entry.kind === 'text' &&
entry.editable &&
entry.value !== entry.initialValue,
)
.map(({ index, value }) => ({ index, value })),
);
/** Render separately stored notes as bounded plain text, never as structured data. */
export const renderBoundedPlainText = (
value: unknown,
maxCharacters: number,
): string =>
typeof value === 'string'
? Array.from(value).slice(0, maxCharacters).join('')
: '';
+131 -455
View File
@@ -1,4 +1,4 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { recordIsacAudit } from '$lib/server/audit';
import {
requireDataView,
requirePermission,
@@ -13,110 +13,38 @@ import {
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
ProtocolFormValidationError,
ProtocolNotFoundError,
loadProtocolTalentMechanic,
parseProtocolForm,
protocolSnapshot,
saveProtocolInTransaction,
} from './protocol-save';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from '$lib/server/os-protocol-profiles';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
// SvelteKit permits underscored exports from route modules. Keep this alias
// available to Data Oversight and transaction-focused tests without exposing a
// non-route page export.
export {
saveProtocolInTransaction as _saveProtocolInTransaction,
} from './protocol-save';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) => rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) =>
mechanicOutcomes(rule)
.filter((outcome) => outcome.descriptionSlot)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
const talent = await (
await database.prepare(
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id);
return {
record: await auditRecord('os_protocols', id, database),
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='os_protocol_render' AND source_entity_id=?",
)
).get(id),
),
primaryEffect:
(await (
await database.prepare(
'SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'os_protocol_talent',
String(talent.id),
)
: null,
};
}
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
return {
@@ -221,7 +149,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
profiles,
secondaryEffects,
talents,
talentEffects,
stats,
icons,
renders,
@@ -243,10 +170,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_talents',
)
).all(),
(
@@ -282,8 +208,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'primaryBaseValue',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -294,6 +222,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(effect) => effect.profile_id === profile.id,
),
}));
const talentMechanics = new Map(
await Promise.all(
talents.map(async (talent) => [
String(talent.id),
await loadProtocolTalentMechanic(database, String(talent.id)),
] as const),
),
);
const pendingProtocols = pending
.filter((item) => item.kind === 'protocol')
.filter((item) =>
@@ -334,14 +270,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
protocol.hasTalent === '1'
? {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
authoredSource: null,
expectedSourceFingerprint: null,
selectedRevision: null,
modelDraft: null,
}
: null,
}));
@@ -381,15 +313,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'os_protocol_talent',
String(talent.id),
['base'],
),
authoredSource:
talentMechanics.get(String(talent.id))?.authoredSource.source ??
null,
expectedSourceFingerprint:
talentMechanics.get(String(talent.id))?.authoredSource
.fingerprint ?? null,
selectedRevision:
talentMechanics.get(String(talent.id))?.selectedRevision ?? null,
publicProjection:
talentMechanics.get(String(talent.id))?.projection ?? [],
}
: null,
};
@@ -532,357 +465,100 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { protocolError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { protocolError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
const journal = new MediaMutationJournal();
try {
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseProtocolForm(tx, form, operator.id);
return saveProtocolInTransaction(tx, { ...input, journal });
});
const result = await saveProtocol.immediate();
journal.clear();
return {
protocolSaved: true,
protocolMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
return fail(400, {
protocolError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
});
await journal.rollback(error);
if (error instanceof ProtocolFormValidationError)
return fail(error.status, { protocolError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { protocolError: error.message });
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
if (name.length < 2 || name.length > 80)
return fail(400, {
protocolError: 'Protocol names must be 2–80 characters.',
});
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType))
return fail(400, {
protocolError: 'Choose a valid quality and primary attribute type.',
});
if (
!primaryStatId ||
!Number.isFinite(primaryBaseValue) ||
primaryBaseValue < 0
)
return fail(400, {
protocolError:
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
});
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const requiresCompleteMechanic =
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
if (
hasTalent &&
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
)
return fail(400, {
protocolError:
'The assigned Talent requires a name and description template.',
});
if (
requiresCompleteMechanic &&
(!conditionalRule?.clauses.length || !outcomes.length)
)
return fail(400, {
protocolError:
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
requiresCompleteMechanic &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
protocolError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
protocolError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{max_stacks}') &&
!maxStacks
)
return fail(400, {
protocolError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
return fail(400, {
protocolError:
'Configure the shared secondary profile for this quality and attribute type first.',
});
if (
await (
await database.prepare(
'SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, { protocolError: 'That OS Protocol already exists.' });
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
protocolError:
'Confirm removal of the existing Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
protocolError: 'That protocol talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((item) => item.attributeId)
.filter((value): value is string => Boolean(value));
const statIds = [...new Set([primaryStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
protocolError: 'One or more selected attributes are unavailable.',
});
const protocolId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
talentId = String(existingTalent?.id ?? randomUUID()),
now = new Date().toISOString();
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = id ? await protocolSnapshot(id, tx) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
if (id) {
await (
await tx.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(name, quality, attributeType, now, id);
} else
await (
await tx.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(protocolId, name, quality, attributeType, now, now);
await (
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
if (hasTalent && conditionalRule) {
await (
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await tx.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await tx.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
tx,
'os_protocol_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await tx.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
tx,
'os_protocol',
protocolId,
iconMutation,
operator.id,
);
await applyGameDataRender(
tx,
'os_protocol',
protocolId,
name,
renderMutation,
operator.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: await protocolSnapshot(protocolId, tx),
database: tx,
});
return null;
});
const transactionResult = await saveProtocol.immediate();
if (transactionResult) return transactionResult;
return {
protocolSaved: true,
protocolMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await protocolSnapshot(id);
if (!before.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
await (
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const archiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await getDatabase()
).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.archived',
entityId: id,
before,
after: await protocolSnapshot(id),
await tx.prepare(
'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.archived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
return { protocolArchived: true };
try {
await archiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolArchived: true, protocolId: id, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const unarchiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await tx.prepare(
'UPDATE os_protocols SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.unarchived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
try {
await unarchiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolUnarchived: true, protocolId: id, resultToken: randomUUID() };
},
};
+123 -37
View File
@@ -12,6 +12,8 @@
import ItemIcon from '$lib/components/ItemIcon.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
@@ -24,6 +26,11 @@
Health: 'Health'
});
const talentValueColumns = [{ key: 'base', variantKey: 'base', label: 'BASE' }];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE CURRENT MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'captured_only',
@@ -31,8 +38,28 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
type ProtocolAuthoredFormSource = Pick<MechanicAuthoredSource, 'version' | 'template' | 'variants'>;
const blankAuthoredSource = (): ProtocolAuthoredFormSource => ({
version: 1,
template: '',
variants: [{ key: 'base', materialized: '', slots: [] }]
});
const authoredPayload = (source: ProtocolAuthoredFormSource) => ({
version: source.version,
template: source.template,
variants: source.variants
});
const blankTalent = () => ({
id: '',
name: '',
authoredSource: blankAuthoredSource(),
expectedTalentId: '',
expectedSourceFingerprint: null,
selectedRevision: null,
mechanicSelectionIntent: 'clear',
modelDraft: blankRule()
});
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', expectedOwnerUpdatedAt: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
@@ -59,23 +86,30 @@
const profileFor = (quality: string, type: string) => data.profiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === type);
const profileEffect = (profile: any, role: string) => profile.effects.find((effect: any) => effect.effect_role === role);
const configuredProfile = () => profileFor(editing.quality, editing.primary_attribute_type)?.effects.length === 3;
const previewOutcomes = () => editing.talent.conditionalRule.clauses
const mechanicModeled = (talent: any) =>
Boolean(talent.publicProjection?.length) &&
talent.publicProjection.every((variant: any) => variant.status === 'modeled');
const previewOutcomes = () => editing.talent.modelDraft.clauses
.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const baseValue = outcome?.variants?.base?.value;
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
.includes('protocolTalentUpgrades');
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
: placeholder;
});
const authoredPreview = () => editing.talent.authoredSource.variants.find((variant: any) => variant.key === 'base')?.materialized ?? editing.talent.authoredSource.template;
const talentPreview = (upgrades: number) => {
if (editing.talent.mechanicSelectionIntent !== 'save') return authoredPreview();
return editing.talent.authoredSource.template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.modelDraft.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.modelDraft.pools.length ? Math.max(...editing.talent.modelDraft.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const baseValue = outcome?.variants?.base?.value;
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
.includes('protocolTalentUpgrades');
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
: placeholder;
});
};
const editProfile = (profile: any) => {
profileEditing = {
...profile,
@@ -84,15 +118,34 @@
editing = null;
};
const edit = (protocol: any) => {
const source = protocol.talent?.authoredSource;
const selectedRevision = protocol.talent?.selectedRevision;
const talent = protocol.talent
? {
...protocol.talent,
authoredSource: source
? {
...source,
variants: source.variants.map((variant: any) => ({
...variant,
slots: variant.slots.map((slot: any) => ({ ...slot }))
}))
}
: blankAuthoredSource(),
expectedTalentId: String(protocol.talent.id ?? ''),
expectedSourceFingerprint: protocol.talent.expectedSourceFingerprint ?? null,
selectedRevision: selectedRevision ?? null,
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
modelDraft: selectedRevision?.draft ?? blankRule()
}
: blankTalent();
editing = {
...protocol,
expectedOwnerUpdatedAt: protocol.updated_at ?? '',
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
hasTalent: Boolean(protocol.talent),
talentOpen: false,
talent: protocol.talent ? {
...protocol.talent,
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
} : blankTalent()
talent
};
profileEditing = null;
talentUpgrade = 0;
@@ -108,6 +161,11 @@
handledResultToken = form.resultToken;
profileEditing = null;
}
if ((form?.protocolArchived || form?.protocolUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (editing?.id === form.protocolId) editing = null;
return;
}
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.protocolMode === 'add') {
@@ -169,8 +227,17 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={editing.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
<input type="hidden" name="expectedTalentId" value={editing.talent.expectedTalentId ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={editing.talent.expectedSourceFingerprint ?? ''}/>
{#if editing.hasTalent}
<input type="hidden" name="authoredSource" value={JSON.stringify(authoredPayload(editing.talent.authoredSource))}/>
<input type="hidden" name="mechanicSelectionIntent" value={editing.talent.mechanicSelectionIntent}/>
{#if editing.talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.modelDraft)} />
{/if}
{/if}
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
@@ -189,22 +256,41 @@
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>AUTHORED BASE VARIANT · {editing.talent.mechanicSelectionIntent.toUpperCase()} MODEL</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label></div>
<AuthoredDescriptionEditor
idPrefix={`protocol-talent-authored-${editing.talent.id || 'new'}`}
variantLabels={talentValueColumns}
bind:template={editing.talent.authoredSource.template}
bind:variants={editing.talent.authoredSource.variants}
/>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<SegmentedChoice
legend="MECHANIC PROGRAM"
name="mechanic-selection-choice"
options={mechanicSelectionOptions}
value={editing.talent.mechanicSelectionIntent}
onchange={(values) => { editing.talent.mechanicSelectionIntent = values[0]; }}
/>
{#if editing.talent.mechanicSelectionIntent === 'save' || editing.talent.selectedRevision}
<MechanicProgramEditor
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
disabled={editing.talent.mechanicSelectionIntent !== 'save'}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.authoredSource?.template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.modelDraft}
/>
{/if}
{#if editing.talent.mechanicSelectionIntent !== 'save'}
<p class="console-note">{editing.talent.mechanicSelectionIntent === 'preserve' ? 'The selected model revision is preserved exactly. Choose SAVE MODEL to append a revision.' : 'No combat model will be selected. Complete authored prose is sufficient for this Talent.'}</p>
{/if}
</div>
</details>
{:else}
@@ -213,7 +299,7 @@
{/if}
</section>
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}{#if Number(editing.active)}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{:else}<button class="danger" formaction="?/unarchive" name="id" value={editing.id}>UNARCHIVE PROTOCOL</button>{/if}{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
</PendingForm>
</div>
{/if}
@@ -224,7 +310,7 @@
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {mechanicModeled(protocol.talent) || protocol.pending ? 'MODELED · ' : 'AUTHORED · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
@@ -58,13 +58,6 @@ vi.mock('$lib/server/managed-images', () => ({
readGameDataRenderMutation: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const request = (includeValues = true) => {
+647
View File
@@ -0,0 +1,647 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconMetadata,
} from '$lib/server/game-data-icons';
import { applyGameDataRender } from '$lib/server/managed-images';
import {
loadMechanicAuthoredSource,
mechanicAuthoredSourceSnapshot,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import {
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
ManagedImageValidationError,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
} from '$lib/server/mechanic-owner-mutation';
import { getOsProtocolProfileAttributeIds, type OsProtocolAttributeType } from '$lib/server/os-protocol-profiles';
import type { MediaMutationJournal } from '$lib/server/media-journal';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
export class ProtocolFormValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ProtocolFormValidationError';
this.status = status;
}
}
export class ProtocolTalentIdentityConflictError extends ProtocolFormValidationError {
constructor(expectedTalentId: string, currentTalentId: string) {
super(
409,
`The protocol Talent changed after this editor was loaded (expected ${expectedTalentId || 'none'}, found ${currentTalentId || 'none'}). Reload the latest definition and try again.`,
);
this.name = 'ProtocolTalentIdentityConflictError';
}
}
function parseExpectedTalentId(value: FormDataEntryValue | null): string {
if (value === null) return '';
if (
typeof value !== 'string' ||
value !== value.trim() ||
value.length > 200 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
)
throw new ProtocolFormValidationError(
400,
'The expected protocol Talent identity is invalid.',
);
return value;
}
/**
* Parses and validates the canonical OS Protocol form. Both the route action
* and Data Oversight replay use this function before calling the transaction
* save seam.
*/
export async function parseProtocolForm(
database: Database,
form: FormData,
actorId: string,
mutationContext?: ProtocolMutationContext,
): Promise<ProtocolSaveInput> {
const id = String(form.get('id') ?? '').trim();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
throw new ProtocolFormValidationError(
400,
error instanceof Error ? error.message : 'The expected owner version is invalid.',
);
}
const existingProtocol = id
? await (
await database.prepare('SELECT updated_at FROM os_protocols WHERE id=?')
).get(id)
: null;
if (id && !existingProtocol)
throw new ProtocolFormValidationError(404, 'OS Protocol not found.');
if (
id &&
expectedOwnerUpdatedAt !== String(existingProtocol?.updated_at ?? '')
)
throw new ProtocolFormValidationError(
409,
'The OS Protocol changed after this editor was loaded. Reload the latest definition and try again.',
);
const expectedTalentId = parseExpectedTalentId(form.get('expectedTalentId'));
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id)
: null;
const currentTalentId = String(existingTalent?.id ?? '');
// The child identity is the optimistic concurrency token for this owner.
if (expectedTalentId !== currentTalentId)
throw new ProtocolTalentIdentityConflictError(
expectedTalentId,
currentTalentId,
);
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
throw new ProtocolFormValidationError(400, error.message);
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
throw new ProtocolFormValidationError(400, error.message);
throw error;
}
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
if (name.length < 2 || name.length > 80)
throw new ProtocolFormValidationError(400, 'Protocol names must be 2–80 characters.');
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType))
throw new ProtocolFormValidationError(400, 'Choose a valid quality and primary attribute type.');
if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0)
throw new ProtocolFormValidationError(400, 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.');
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
throw new ProtocolFormValidationError(400, 'Configure the shared secondary profile for this quality and attribute type first.');
if (
await (
await database.prepare(
'SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
throw new ProtocolFormValidationError(409, 'That OS Protocol already exists.');
if (existingTalent && !hasTalent && form.get('confirmTalentRemoval') !== 'REMOVE')
throw new ProtocolFormValidationError(400, 'Confirm removal of the existing Talent and mechanic program.');
if (hasTalent && (talentName.length < 2 || talentName.length > 80))
throw new ProtocolFormValidationError(400, 'The assigned Talent requires a name.');
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
throw new ProtocolFormValidationError(409, 'That protocol talent name is already in use.');
const talentId = String(existingTalent?.id ?? randomUUID());
let authoredSource: MechanicAuthoredSource | null = null;
let expectedSourceFingerprint: string | null = null;
if (hasTalent || existingTalent)
try {
expectedSourceFingerprint = parseExpectedSourceFingerprint(
form.get('expectedSourceFingerprint'),
);
} catch (error) {
throw new ProtocolFormValidationError(
400,
error instanceof Error
? error.message
: 'The expected authored-source fingerprint is invalid.',
);
}
let selection: MechanicOwnerSelection = { intent: 'clear' };
let activeStats: Array<Record<string, unknown>> = [];
if (hasTalent) {
activeStats = await (
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
).all();
try {
authoredSource = parseMechanicAuthoredSourcePayload(
form.get('authoredSource'),
{ type: 'os_protocol_talent', id: talentId },
);
selection = parseMechanicSelectionIntent(
form.get('mechanicSelectionIntent'),
form.get('conditionalRule'),
{
variantKeys: ['base'],
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: talentName,
ownerType: 'os_protocol_talent',
},
);
const authoredAttributeIds = [
...new Set(
authoredSource.variants.flatMap((variant) =>
variant.slots
.map((slot) => slot.attributeId)
.filter((value): value is string => Boolean(value)),
),
),
];
if (authoredAttributeIds.some((attributeId) => !activeStats.some((row) => String(row.id) === attributeId)))
throw new Error('One or more authored Attributes are unavailable.');
} catch (error) {
throw new ProtocolFormValidationError(
400,
error instanceof Error ? error.message : 'The authored mechanic is invalid.',
);
}
}
const validStats = await (
await database.prepare(
'SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id=?',
)
).get(primaryStatId);
if (Number(validStats?.count) !== 1)
throw new ProtocolFormValidationError(400, 'One or more selected attributes are unavailable.');
return {
id,
proposedEntityId: String(form.get('_proposedEntityId') ?? '').trim(),
name,
quality,
attributeType,
primaryStatId,
primaryBaseValue,
hasTalent,
talentName,
talentId,
expectedTalentId,
expectedOwnerUpdatedAt,
authoredSource,
expectedSourceFingerprint,
selection,
confirmTalentRemoval: form.get('confirmTalentRemoval') === 'REMOVE',
iconMutation,
renderMutation,
actorId,
mutationContext,
resolveAttributeLabel: (attributeId) =>
activeStats.find((row) => String(row.id) === attributeId)?.name as string | undefined,
};
}
const renderAuditMetadata = (render: Record<string, unknown> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
export async function loadProtocolTalentMechanic(
database: Database,
talentId: string,
) {
const authoredSource = await loadMechanicAuthoredSource(
database,
'os_protocol_talent',
talentId,
);
if (!authoredSource) return null;
const selectedRevision = await loadSelectedMechanicModelRevision(
database,
authoredSource.id,
);
return {
authoredSource,
selectedRevision,
projection: await loadMechanicPublicProjection(database, authoredSource.id),
};
}
function mechanicAuditSnapshot(
state: Awaited<ReturnType<typeof loadProtocolTalentMechanic>>,
) {
if (!state) return null;
return {
authoredSource: mechanicAuthoredSourceSnapshot(state.authoredSource),
selectedRevision: mechanicModelRevisionSnapshot(state.selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
state.selectedRevision,
state.projection,
),
};
}
export async function protocolSnapshot(
id: string,
suppliedDatabase?: Database,
) {
const database = suppliedDatabase ?? (await getDatabase());
const [record, talent] = await Promise.all([
auditRecord('os_protocols', id, database),
(
await database.prepare(
'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id),
]);
const [secondaryProfile, mechanic] = await Promise.all([
record
? (async () => {
const profile = await (
await database.prepare(
`SELECT * FROM os_protocol_secondary_profiles
WHERE quality=? AND primary_attribute_type=?`,
)
).get(record.quality, record.primary_attribute_type);
return profile
? {
record: profile,
effects: await (
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role',
)
).all(profile.id),
}
: null;
})()
: null,
talent
? loadProtocolTalentMechanic(database, String(talent.id))
: null,
]);
return {
record,
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='os_protocol_render' AND source_entity_id=?",
)
).get(id),
),
primaryEffect:
(await (
await database.prepare(
'SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?',
)
).get(id)) ?? null,
secondaryProfile,
talent: talent
? {
id: talent.id,
protocol_id: talent.protocol_id,
name: talent.name,
created_at: talent.created_at,
updated_at: talent.updated_at,
}
: null,
mechanic: mechanicAuditSnapshot(mechanic),
};
}
export type ProtocolMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
export type ProtocolSaveInput = {
id: string;
proposedEntityId: string;
name: string;
quality: string;
attributeType: string;
primaryStatId: string;
primaryBaseValue: number;
hasTalent: boolean;
talentName: string;
talentId: string;
expectedTalentId: string;
expectedOwnerUpdatedAt?: string | null;
authoredSource: MechanicAuthoredSource | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
confirmTalentRemoval: boolean;
iconMutation: { output: Buffer | null; remove: boolean };
renderMutation: {
file: File | null;
remove: boolean;
processed?: { output: Buffer; width: number; height: number };
};
actorId: string;
mutationContext?: ProtocolMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class ProtocolNotFoundError extends Error {
constructor() {
super('OS Protocol not found.');
this.name = 'ProtocolNotFoundError';
}
}
/**
* Persists one OS Protocol and its owned Phase 2 mechanic in an already-open
* transaction. The route action owns the transaction today; Data Oversight
* can call this function from its own atomic replay transaction later.
*/
export async function saveProtocolInTransaction(
database: Database,
input: ProtocolSaveInput,
): Promise<{ protocolId: string; mode: 'add' | 'edit' }> {
const protocolId = input.id || input.proposedEntityId || randomUUID();
const before = input.id ? await protocolSnapshot(input.id, database) : null;
if (input.id && !before?.record) throw new ProtocolNotFoundError();
if (
input.id &&
input.expectedOwnerUpdatedAt !== String(before?.record?.updated_at ?? '')
)
throw new ProtocolFormValidationError(
409,
'The OS Protocol changed after this editor was loaded. Reload the latest definition and try again.',
);
const existingTalent = input.id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(input.id)
: null;
const currentTalentId = String(existingTalent?.id ?? '');
// Recheck inside the write transaction before any owner or media writes.
if (input.expectedTalentId !== currentTalentId)
throw new ProtocolTalentIdentityConflictError(
input.expectedTalentId,
currentTalentId,
);
const talentId = String(existingTalent?.id ?? input.talentId);
const authoredSource = input.authoredSource;
if (
authoredSource &&
(authoredSource.owner.type !== 'os_protocol_talent' ||
authoredSource.owner.id !== talentId)
) {
throw new Error('The authored mechanic owner does not match the protocol Talent.');
}
if (input.hasTalent && !authoredSource)
throw new Error('The authored description is required for a Talent.');
if (existingTalent && !input.hasTalent && !input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing Talent and mechanic program.',
);
if (existingTalent && !input.hasTalent) {
const currentSource = await loadMechanicAuthoredSource(
database,
'os_protocol_talent',
talentId,
);
if (input.expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
currentSource?.fingerprint ?? null,
);
}
const now = new Date().toISOString();
if (input.id) {
await (
await database.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(
input.name,
input.quality,
input.attributeType,
now,
input.id,
);
} else {
await (
await database.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(
protocolId,
input.name,
input.quality,
input.attributeType,
now,
now,
);
}
await (
await database.prepare(`INSERT INTO os_protocol_primary_effects
(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET
stat_attribute_id=excluded.stat_attribute_id,
base_value=excluded.base_value`)
).run(randomUUID(), protocolId, input.primaryStatId, input.primaryBaseValue);
if (input.hasTalent) {
if (!authoredSource)
throw new Error('The authored description is required for a Talent.');
await (
await database.prepare(`INSERT INTO os_protocol_talents
(id,protocol_id,name,description_template,created_at,updated_at)
VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET
name=excluded.name,
updated_at=excluded.updated_at`)
).run(talentId, protocolId, input.talentName, '', now, now);
await persistMechanicOwnerStateInTransaction(database, {
source: authoredSource,
expectedSourceFingerprint: input.expectedSourceFingerprint,
selection: input.selection,
programName: input.talentName,
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.actorId,
resolveAttributeLabel: input.resolveAttributeLabel,
});
} else if (existingTalent) {
if (!input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing Talent and mechanic program.',
);
// Preserve the authored source, selection, projection, and immutable
// revisions as append-only history. Public readers start from the owner
// identity row, so removing that row cannot expose the detached mechanic.
await (
await database.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'os_protocol',
protocolId,
input.iconMutation,
input.actorId,
input.journal,
);
await applyGameDataRender(
database,
'os_protocol',
protocolId,
input.name,
input.renderMutation,
input.actorId,
input.journal,
);
await recordIsacAudit({
actorId: input.actorId,
category: 'game_data',
action: input.id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: {
...(await protocolSnapshot(protocolId, database)),
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { protocolId, mode: input.id ? 'edit' : 'add' };
}
export { MechanicSourceFingerprintConflictError };
File diff suppressed because it is too large Load Diff
+239 -50
View File
@@ -10,6 +10,7 @@ const capturedRule = {
const mocks = vi.hoisted(() => {
const events: string[] = [];
const applyIconMutation = vi.fn();
const database = {
exec: vi.fn(async (sql: string) => {
events.push(sql);
@@ -35,17 +36,34 @@ const mocks = vi.hoisted(() => {
);
return {
events,
applyIconMutation,
talentExists: false,
profileConfigured: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
parseConditionalRulePayload: vi.fn(),
loadMechanicAuthoredSource: vi.fn(async () => null),
mechanicAuthoredSourceSnapshot: vi.fn((value) => value),
loadSelectedMechanicModelRevision: vi.fn(async () => null),
mechanicModelRevisionSnapshot: vi.fn((value) => value),
loadMechanicPublicProjection: vi.fn(async () => []),
mechanicPublicProjectionSnapshot: vi.fn(() => null),
parseMechanicAuthoredSourcePayload: vi.fn(),
parseExpectedOwnerUpdatedAt: vi.fn(),
parseExpectedSourceFingerprint: vi.fn(),
parseMechanicSelectionIntent: vi.fn(),
persistMechanicOwnerStateInTransaction: vi.fn(async () => {
events.push('persist mechanic');
}),
MechanicSourceFingerprintConflictError: class extends Error {
constructor(
message = 'The authored mechanic changed after this editor was loaded.',
) {
super(message);
this.name = 'MechanicSourceFingerprintConflictError';
}
},
recordIsacAudit: vi.fn(async () => {
events.push('audit');
}),
saveConditionalRuleInTransaction: vi.fn(async () => {
events.push('save conditional rule');
}),
database,
};
});
@@ -81,7 +99,7 @@ vi.mock('$lib/server/os-protocol-profiles', () => ({
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
applyIconMutation: mocks.applyIconMutation,
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
@@ -96,11 +114,30 @@ vi.mock('$lib/server/managed-images', () => ({
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: mocks.parseConditionalRulePayload,
saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction,
vi.mock('$lib/server/mechanic-authored-sources', () => ({
loadMechanicAuthoredSource: mocks.loadMechanicAuthoredSource,
mechanicAuthoredSourceSnapshot: mocks.mechanicAuthoredSourceSnapshot,
MechanicSourceFingerprintConflictError:
mocks.MechanicSourceFingerprintConflictError,
}));
vi.mock('$lib/server/mechanic-model-revisions', () => ({
loadSelectedMechanicModelRevision: mocks.loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot: mocks.mechanicModelRevisionSnapshot,
}));
vi.mock('$lib/server/mechanic-public-projection', () => ({
loadMechanicPublicProjection: mocks.loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot: mocks.mechanicPublicProjectionSnapshot,
}));
vi.mock('$lib/server/mechanic-owner-mutation', () => ({
parseMechanicAuthoredSourcePayload: mocks.parseMechanicAuthoredSourcePayload,
parseExpectedOwnerUpdatedAt: mocks.parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint: mocks.parseExpectedSourceFingerprint,
parseMechanicSelectionIntent: mocks.parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction:
mocks.persistMechanicOwnerStateInTransaction,
}));
import { actions } from './+page.server';
@@ -113,6 +150,7 @@ const protocolRecord = {
quality: 'Standard',
primary_attribute_type: 'Firepower',
active: 1,
updated_at: '2026-08-02T12:00:00.000Z',
};
const primaryEffect = {
protocol_id: protocolId,
@@ -123,7 +161,25 @@ const talentRecord = {
id: talentId,
protocol_id: protocolId,
name: 'Escalation Loop',
description_template: 'Captured Talent',
description_template: '',
};
const authoredPayload = {
version: 1,
template: 'After {1} hits, gain {duration} seconds.',
variants: [
{
key: 'base',
materialized: 'After 10 hits, gain 10 seconds.',
slots: [
{ key: '1', value: '10', order: 0 },
{ key: 'duration', value: '10', order: 1 },
],
},
],
};
const authoredSource = {
...authoredPayload,
owner: { type: 'os_protocol_talent' as const, id: talentId },
};
const saveRequest = ({
@@ -131,11 +187,17 @@ const saveRequest = ({
hasTalent = false,
confirmRemoval = false,
includePrimaryValue = true,
selection = 'clear',
expectedTalentId = id && mocks.talentExists ? talentId : '',
expectedOwnerUpdatedAt = id ? protocolRecord.updated_at : '',
}: {
id?: string;
hasTalent?: boolean;
confirmRemoval?: boolean;
includePrimaryValue?: boolean;
selection?: 'preserve' | 'save' | 'clear';
expectedTalentId?: string;
expectedOwnerUpdatedAt?: string;
} = {}) => {
const form = new FormData();
form.set('id', id);
@@ -145,10 +207,15 @@ const saveRequest = ({
form.set('primaryStatId', 'weapon-damage');
if (includePrimaryValue) form.set('primaryBaseValue', '5');
form.set('hasTalent', hasTalent ? '1' : '0');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
if (hasTalent) {
form.set('talentName', 'Escalation Loop');
form.set('descriptionTemplate', 'Captured Talent');
form.set('conditionalRule', JSON.stringify(capturedRule));
form.set('authoredSource', JSON.stringify(authoredPayload));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', selection);
if (selection === 'save')
form.set('conditionalRule', JSON.stringify(capturedRule));
}
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/protocols?/save', {
@@ -169,16 +236,25 @@ describe('OS Protocol optional Talent action', () => {
mocks.events.length = 0;
mocks.talentExists = false;
mocks.profileConfigured = true;
mocks.parseConditionalRulePayload.mockReturnValue(capturedRule);
mocks.conditionalRuleSnapshot.mockResolvedValue({
id: 'rule-1',
source_id: talentId,
});
mocks.parseMechanicAuthoredSourcePayload.mockImplementation(
(_payload, owner) => ({ ...authoredSource, owner }),
);
mocks.parseExpectedOwnerUpdatedAt.mockImplementation(
(raw: FormDataEntryValue | string | null, required: boolean) => {
const value = String(raw ?? '');
if (required && !value) throw new Error('The expected owner version is required.');
return value || null;
},
);
mocks.parseExpectedSourceFingerprint.mockReturnValue(null);
mocks.parseMechanicSelectionIntent.mockReturnValue({ intent: 'clear' });
mocks.auditRecord.mockResolvedValue(protocolRecord);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('FROM os_protocol_secondary_profiles'))
return mocks.profileConfigured ? { id: 'profile-1' } : null;
if (sql.includes('SELECT updated_at FROM os_protocols WHERE id='))
return { updated_at: protocolRecord.updated_at };
if (sql.includes('FROM os_protocols WHERE name=')) return null;
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
return mocks.talentExists ? talentRecord : null;
@@ -194,8 +270,6 @@ describe('OS Protocol optional Talent action', () => {
run: vi.fn(async () => {
if (sql.includes('INSERT INTO os_protocol_talents'))
mocks.talentExists = true;
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM os_protocol_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
@@ -212,8 +286,8 @@ describe('OS Protocol optional Talent action', () => {
protocolMode: 'add',
resultToken: expect.any(String),
});
expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled();
expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled();
expect(mocks.parseMechanicAuthoredSourcePayload).not.toHaveBeenCalled();
expect(mocks.persistMechanicOwnerStateInTransaction).not.toHaveBeenCalled();
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('INSERT INTO os_protocol_talents'),
@@ -225,7 +299,7 @@ describe('OS Protocol optional Talent action', () => {
category: 'game_data',
action: 'os_protocol.created',
before: null,
after: expect.objectContaining({ talent: null, conditional: null }),
after: expect.objectContaining({ talent: null, mechanic: null }),
}),
);
});
@@ -242,7 +316,8 @@ describe('OS Protocol optional Talent action', () => {
'Configure the shared secondary profile for this quality and attribute type first.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
@@ -250,35 +325,116 @@ describe('OS Protocol optional Talent action', () => {
const result = await runSave({ includePrimaryValue: false });
expect(result).toMatchObject({ status: 400 });
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('accepts an assigned CAPTURE ONLY Talent with no mechanic branches', async () => {
it('saves exact authored prose without selecting a model', async () => {
const result = await runSave({ hasTalent: true });
expect(result).toMatchObject({
protocolSaved: true,
protocolMode: 'add',
});
expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce();
expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith(
mocks.database,
'os_protocol_talent',
expect.any(String),
capturedRule,
expect(mocks.parseMechanicAuthoredSourcePayload).toHaveBeenCalledOnce();
expect(mocks.parseMechanicSelectionIntent).toHaveBeenCalledWith(
'clear',
null,
expect.objectContaining({ variantKeys: ['base'] }),
);
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({
source: expect.objectContaining({
owner: { type: 'os_protocol_talent', id: expect.any(String) },
template: authoredSource.template,
}),
selection: { intent: 'clear' },
}),
);
});
it('appends and selects a submitted model through the owner persistence seam', async () => {
mocks.parseMechanicSelectionIntent.mockReturnValue({
intent: 'save',
draft: capturedRule,
});
const result = await runSave({ hasTalent: true, selection: 'save' });
expect(result).toMatchObject({ protocolSaved: true, protocolMode: 'add' });
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({
selection: { intent: 'save', draft: capturedRule },
}),
);
});
it('preserves a selected revision while allowing authored prose to become stale', async () => {
mocks.parseMechanicSelectionIntent.mockReturnValue({ intent: 'preserve' });
const result = await runSave({
id: protocolId,
hasTalent: true,
selection: 'preserve',
});
expect(result).toMatchObject({ protocolSaved: true, protocolMode: 'edit' });
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({ selection: { intent: 'preserve' } }),
);
});
it('clears selection without deleting authored source or revision history', async () => {
mocks.parseMechanicSelectionIntent.mockReturnValue({ intent: 'clear' });
const result = await runSave({
id: protocolId,
hasTalent: true,
selection: 'clear',
});
expect(result).toMatchObject({ protocolSaved: true, protocolMode: 'edit' });
expect(mocks.persistMechanicOwnerStateInTransaction).toHaveBeenCalledWith(
mocks.database,
expect.objectContaining({ selection: { intent: 'clear' } }),
);
});
it('rolls back the owner transaction on an authored-source fingerprint conflict', async () => {
mocks.parseExpectedSourceFingerprint.mockReturnValue('f'.repeat(64));
mocks.persistMechanicOwnerStateInTransaction.mockImplementationOnce(
async () => {
throw new mocks.MechanicSourceFingerprintConflictError();
},
);
const result = await runSave({ hasTalent: true });
expect(result).toMatchObject({
status: 409,
data: {
protocolError:
'The authored mechanic changed after this editor was loaded.',
},
});
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it.each(['ready', 'requires_assumptions'] as const)(
'rejects an empty %s mechanic program',
async (simulationReadiness) => {
mocks.parseConditionalRulePayload.mockReturnValue({
...capturedRule,
simulationReadiness,
mocks.parseMechanicSelectionIntent.mockImplementationOnce(() => {
throw new Error(
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
);
});
const result = await runSave({ hasTalent: true });
const result = await runSave({ hasTalent: true, selection: 'save' });
expect(result).toMatchObject({
status: 400,
@@ -287,11 +443,40 @@ describe('OS Protocol optional Talent action', () => {
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
},
);
it('rolls back the owner transaction when audit persistence fails', async () => {
mocks.recordIsacAudit.mockImplementationOnce(async () => {
mocks.events.push('audit failed');
throw new Error('audit unavailable');
});
await expect(runSave()).rejects.toThrow('audit unavailable');
expect(mocks.events).toEqual(['BEGIN', 'audit failed', 'ROLLBACK']);
});
it('rolls back direct-owner media when audit persistence fails', async () => {
let restored = 0;
mocks.applyIconMutation.mockImplementationOnce(
async (...args: unknown[]) => {
const journal = args[5] as {
register: (compensation: () => Promise<void>) => void;
};
journal.register(async () => {
restored += 1;
});
},
);
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit unavailable'));
await expect(runSave()).rejects.toThrow('audit unavailable');
expect(restored).toBe(1);
});
it('requires confirmation before removing an existing Talent', async () => {
mocks.talentExists = true;
@@ -304,11 +489,12 @@ describe('OS Protocol optional Talent action', () => {
'Confirm removal of the existing Talent and mechanic program.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual(['BEGIN', 'ROLLBACK']);
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes an existing Talent and its mechanic in the audited transaction', async () => {
it('removes an existing Talent while preserving immutable mechanic history', async () => {
mocks.talentExists = true;
const result = await runSave({
@@ -320,13 +506,12 @@ describe('OS Protocol optional Talent action', () => {
protocolSaved: true,
protocolMode: 'edit',
});
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.events).toEqual(['BEGIN', 'delete talent', 'audit', 'COMMIT']);
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('DELETE FROM mechanic_authored_sources'),
),
).toBe(false);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
@@ -334,10 +519,14 @@ describe('OS Protocol optional Talent action', () => {
action: 'os_protocol.updated',
entityId: protocolId,
before: expect.objectContaining({
talent: talentRecord,
conditional: expect.objectContaining({ source_id: talentId }),
talent: expect.objectContaining({
id: talentId,
protocol_id: protocolId,
name: 'Escalation Loop',
}),
mechanic: null,
}),
after: expect.objectContaining({ talent: null, conditional: null }),
after: expect.objectContaining({ talent: null, mechanic: null }),
}),
);
});
+94 -456
View File
@@ -6,20 +6,8 @@ import {
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import {
getPagination,
getRegistrySearch,
@@ -29,16 +17,28 @@ import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import {
SkillChipFormValidationError,
parseSkillChipForm,
_saveSkillChipInTransaction,
SkillChipNotFoundError,
loadTalent,
refreshAllFocusReadiness,
snapshot,
} from './skillchip-save';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
} from '$lib/server/mechanic-model-revisions';
import { loadMechanicPublicProjection } from '$lib/server/mechanic-public-projection';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_QUALITIES,
@@ -52,87 +52,7 @@ import {
replaceSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) => rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) =>
mechanicOutcomes(rule)
.filter((outcome) => outcome.descriptionSlot)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
async function snapshot(id: string) {
const database = await getDatabase();
const talent = await (
await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id);
return {
record: await auditRecord('skill_chip_sets', id),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'skill_chip_talent',
String(talent.id),
)
: null,
};
}
export { _saveSkillChipInTransaction } from './skillchip-save';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
@@ -240,8 +160,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'twoPieceMax',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -266,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets,
twoPiece,
talents,
talentEffects,
stats,
abilities,
specializations,
@@ -285,10 +206,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents`,
)
).all(),
(
@@ -358,7 +279,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const currentPendingSets = pendingSets.filter(
(set) =>
Boolean(set.abilityId) &&
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
(String(set.hasTalent) !== '1' || Boolean(set.authoredSource)),
);
const pendingSetRows = requestedItemId
? currentPendingSets.filter((set) => set.id === requestedItemId)
@@ -387,16 +308,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
authoredSource: (() => {
try {
return JSON.parse(
String(set.conditionalRule ?? 'null'),
);
return JSON.parse(String(set.authoredSource ?? 'null'));
} catch {
return null;
}
})(),
expectedSourceFingerprint:
String(set.expectedSourceFingerprint ?? ''),
selectedRevision: null,
publicProjection: [],
mechanicSelectionIntent:
String(set.mechanicSelectionIntent ?? 'clear'),
}
: null,
}));
@@ -404,6 +328,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets.map(async (set) => {
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
const icon = icons.find((item) => item.entity_id === set.id);
const authoredSource = talent
? await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
String(talent.id),
)
: null;
const selectedRevision = authoredSource
? await loadSelectedMechanicModelRevision(database, authoredSource.id)
: null;
const publicProjection = authoredSource
? await loadMechanicPublicProjection(database, authoredSource.id)
: [];
return {
...set,
iconUrl: icon
@@ -422,15 +359,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'skill_chip_talent',
String(talent.id),
['minimum', 'maximum'],
),
authoredSource: authoredSource
? {
version: authoredSource.source.version,
template: authoredSource.source.template,
variants: authoredSource.source.variants,
}
: null,
expectedSourceFingerprint: authoredSource?.fingerprint ?? null,
selectedRevision,
publicProjection,
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
}
: null,
};
@@ -472,340 +411,31 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { skillChipError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { skillChipError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const abilityId = String(form.get('abilityId') ?? '');
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
const journal = new MediaMutationJournal();
try {
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
const save = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseSkillChipForm(tx, form, operator.id);
return _saveSkillChipInTransaction(tx, { ...input, journal });
});
const result = await save.immediate();
journal.clear();
return {
skillChipSaved: true,
skillChipMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
return fail(400, {
skillChipError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
});
}
if (name.length < 2 || name.length > 80)
return fail(400, {
skillChipError: 'Skill Chip names must be 2–80 characters.',
});
if (!abilityId)
return fail(400, {
skillChipError: 'Assign this Skill Chip to a specialization ability.',
});
if (
!twoPieceStatId ||
!Number.isFinite(twoPieceMin) ||
!Number.isFinite(twoPieceMax) ||
twoPieceMax < twoPieceMin
)
return fail(400, {
skillChipError: 'Configure a valid 2-piece stat range.',
});
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
if (
hasTalent &&
(talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule?.clauses.length ||
!outcomes.length)
)
return fail(400, {
skillChipError:
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
});
if (
hasTalent &&
outcomes.some(
(outcome) =>
outcome.variants.maximum.value < outcome.variants.minimum.value,
)
)
return fail(400, {
skillChipError:
'Every talent outcome maximum must be at least its minimum.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
hasTalent &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
skillChipError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
hasTalent &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
skillChipError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
return fail(400, {
skillChipError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError: 'Choose an active specialization ability.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(abilityId, id || 'new')
)?.count ?? 0,
);
if (assignedCount >= 3) {
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
});
}
if (
await (
await database.prepare(
'SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, {
skillChipError: 'That Skill Chip set already exists.',
});
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
skillChipError:
'Confirm removal of the existing 3-piece Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
skillChipError: 'That Skill Chip talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
)
).get(...allStatIds);
if (Number(validStats?.count) !== allStatIds.length)
return fail(400, {
skillChipError: 'One or more selected stats are unavailable.',
});
const setId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
if (id && !before?.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
await database.exec('BEGIN');
try {
if (id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(name, abilityId, now, id);
} else
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, name, abilityId, now, now);
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
if (hasTalent && conditionalRule) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await database.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
iconMutation,
operator.id,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
name,
renderMutation,
operator.id,
);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: await snapshot(setId),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
await journal.rollback(error);
if (error instanceof SkillChipFormValidationError)
return fail(error.status, { skillChipError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { skillChipError: error.message });
if (error instanceof SkillChipNotFoundError)
return fail(404, { skillChipError: error.message });
throw error;
}
return {
skillChipSaved: true,
skillChipMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
savePrimaryProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -936,7 +566,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -956,7 +586,7 @@ export const actions: Actions = {
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -964,7 +594,11 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
return {
skillChipArchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -975,7 +609,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -1021,7 +655,7 @@ export const actions: Actions = {
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -1029,6 +663,10 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
return {
skillChipUnarchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
};
+91 -47
View File
@@ -10,34 +10,58 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
let { data, form } = $props();
const talentValueColumns = [
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' }
];
const authoredVariantLabels = [
{ key: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', label: 'MAXIMUM' }
];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE CURRENT MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
const blankAuthoredVariants = (): MechanicAuthoredVariant[] => [
{ key: 'minimum', materialized: '', slots: [] },
{ key: 'maximum', materialized: '', slots: [] }
];
const blankAuthoredSource = () => ({ version: 1, template: '', variants: blankAuthoredVariants() });
const blankTalent = () => ({
id: '',
name: '',
authoredSource: blankAuthoredSource(),
expectedTalentId: '',
expectedSourceFingerprint: '',
selectedRevision: null,
mechanicSelectionIntent: 'clear',
modelDraft: blankRule()
});
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', expectedOwnerUpdatedAt: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const qualitySteps = [
{ value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' },
{ value: '2', label: '2 HIGH-END' },
{ value: '3', label: '3 HIGH-END' }
];
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const ability = (id: string) => data.abilities.find((item: any) => item.id === id);
const mechanicModeled = (talent: any) =>
Boolean(talent?.publicProjection?.length) &&
talent.publicProjection.every((variant: any) => variant.status === 'modeled');
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
@@ -45,32 +69,25 @@
const unit = stat(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const scaled = (minimum: number | string, maximum: number | string) => Number(minimum) + (Number(maximum) - Number(minimum)) * Number(highEndCount) / 3;
const previewOutcomes = () => editing.talent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = () => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const minimum = outcome?.variants?.minimum?.value;
const maximum = outcome?.variants?.maximum?.value;
return outcome && minimum !== '' && maximum !== '' && minimum !== null && maximum !== null && minimum !== undefined && maximum !== undefined
? format(scaled(minimum, maximum), outcome.attributeId)
: placeholder;
});
const scaled = (minimum: number | string, _maximum: number | string) => Number(minimum);
const edit = (item: any) => {
const selectedRevision = item.talent?.selectedRevision ?? null;
const source = item.talent?.authoredSource ?? blankAuthoredSource();
editing = {
...item,
expectedOwnerUpdatedAt: item.updated_at ?? '',
abilityId: String(item.specialization_ability_id),
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
hasTalent: Boolean(item.talent),
talentOpen: false,
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
talent: item.talent ? structuredClone({
...item.talent,
expectedTalentId: String(item.talent.id ?? ''),
authoredSource: source,
modelDraft: selectedRevision?.draft ?? blankRule(),
mechanicSelectionIntent: item.talent.mechanicSelectionIntent ?? (selectedRevision ? 'preserve' : 'clear')
}) : blankTalent()
};
highEndCount = '0';
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
@@ -78,6 +95,11 @@
if (requested) edit(requested);
openedRequestedItem = true;
}
if ((form?.skillChipArchived || form?.skillChipUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (editing?.id === form.skillChipId) editing = null;
return;
}
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.skillChipMode === 'add') {
@@ -85,7 +107,6 @@
renderResetToken += 1;
}
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
});
@@ -120,8 +141,17 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={editing.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<input type="hidden" name="expectedTalentId" value={editing.talent.expectedTalentId ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={editing.talent.expectedSourceFingerprint ?? ''}/>
{#if editing.hasTalent}
<input type="hidden" name="authoredSource" value={JSON.stringify(editing.talent.authoredSource)}/>
<input type="hidden" name="mechanicSelectionIntent" value={editing.talent.mechanicSelectionIntent}/>
{#if editing.talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.modelDraft)}/>
{/if}
{/if}
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
@@ -137,22 +167,36 @@
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>OPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>AUTHORED MINIMUM + MAXIMUM · {editing.talent.mechanicSelectionIntent.toUpperCase()} MODEL</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'skill_chip_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
bind:rule={editing.talent.conditionalRule}
/>
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label></div>
<AuthoredDescriptionEditor
variantLabels={authoredVariantLabels}
idPrefix={`skill-chip-authored-${editing.talent.id || 'new'}`}
bind:template={editing.talent.authoredSource.template}
bind:variants={editing.talent.authoredSource.variants}
/>
<div class="mechanic-selection">
<SegmentedChoice legend="MECHANIC PROGRAM" name="mechanic-selection" options={mechanicSelectionOptions} value={editing.talent.mechanicSelectionIntent} onchange={(values) => { editing.talent.mechanicSelectionIntent = values[0]; }}/>
{#if editing.talent.mechanicSelectionIntent === 'save' || editing.talent.selectedRevision}
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
disabled={editing.talent.mechanicSelectionIntent !== 'save'}
aiContext={{
sourceKind: 'skill_chip_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.authoredSource?.template ?? ''
}}
bind:rule={editing.talent.modelDraft}
/>
{/if}
{#if editing.talent.mechanicSelectionIntent !== 'save'}
<p class="console-note">{editing.talent.mechanicSelectionIntent === 'preserve' ? 'The selected model revision is preserved exactly. Choose SAVE MODEL to replace it.' : 'No combat model will be selected. Complete authored prose is sufficient for this Talent.'}</p>
{/if}
</div>
</div>
</details>
{:else}<p class="console-note">This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.</p>{#if editing.talent.id}<label class="skill-chip-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}{/if}
@@ -169,7 +213,7 @@
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? mechanicModeled(item.talent) ? 'MODELED · CURRENT REVISION' : item.talent.authoredSource ? 'AUTHORED FALLBACK' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
</div>
@@ -0,0 +1,834 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
const mocks = vi.hoisted(() => ({
database: null as Database | null,
applyIconMutation: vi.fn(),
applyGameDataRender: vi.fn(),
auditRecord: vi.fn(),
recordIsacAudit: vi.fn(),
refreshAbilityFocuses: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: mocks.applyIconMutation,
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: mocks.applyGameDataRender,
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshAbilityFocuses: mocks.refreshAbilityFocuses,
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'actor-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
import { actions, _saveSkillChipInTransaction } from './+page.server';
import { parseSkillChipForm } from './skillchip-save';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import { loadSelectedMechanicModelRevision } from '$lib/server/mechanic-model-revisions';
const source = (value = '10'): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'skill_chip_talent', id: 'talent-1' },
template: 'Gain {1}% Skill Damage.',
variants: [
{
key: 'minimum',
materialized: 'Gain 10% Skill Damage.',
slots: [
{
key: '1',
value: '10',
order: 0,
unit: '%',
attributeId: 'skill-damage',
},
],
},
{
key: 'maximum',
materialized: `Gain ${value}% Skill Damage.`,
slots: [
{ key: '1', value, order: 0, unit: '%', attributeId: 'skill-damage' },
],
},
],
});
const model = (value = 10): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'skill-hit',
label: 'Skill hit',
event: 'skill_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'skill-damage',
label: 'Skill Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'assigned_ability',
basis: 'flat',
attributeId: 'skill-damage',
perStack: false,
variants: {
minimum: { value },
maximum: { value: value + 5 },
},
},
],
},
],
});
const input = (
databaseSource: MechanicAuthoredSource,
options: Partial<Parameters<typeof _saveSkillChipInTransaction>[1]> = {},
): Parameters<typeof _saveSkillChipInTransaction>[1] => ({
id: '',
proposedEntityId: 'set-1',
name: 'Pulse Matrix',
abilityId: 'ability-1',
twoPieceStatId: 'skill-duration',
twoPieceMin: 5,
twoPieceMax: 10,
hasTalent: true,
talentName: 'Signal Cascade',
talentId: 'talent-1',
expectedTalentId: '',
authoredSource: databaseSource,
expectedSourceFingerprint: null,
selection: { intent: 'clear' },
confirmTalentRemoval: false,
iconMutation: { output: null, remove: false },
renderMutation: { file: null, remove: false },
actorId: 'actor-1',
resolveAttributeLabel: (attributeId) =>
attributeId === 'skill-damage' ? 'Skill Damage' : null,
...options,
});
const removeTalentRequest = (
expectedSourceFingerprint: string,
expectedTalentId: string,
expectedOwnerUpdatedAt: string,
) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
form.set('expectedSourceFingerprint', expectedSourceFingerprint);
form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
const talentSaveRequest = (
expectedTalentId: string,
expectedOwnerUpdatedAt: string,
) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '1');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
form.set('talentName', 'Signal Cascade');
form.set('authoredSource', JSON.stringify(source()));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', 'clear');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
async function applySchema(database: Database) {
const schema = await readFile(
new URL('../../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await database.exec(statement);
}
}
const count = async (table: string) =>
Number(
(
await (
await mocks.database!.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get()
)?.count ?? 0,
);
const ownerUpdatedAt = async (database: Database, id = 'set-1') =>
String(
(
await (await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?')).get(id)
)?.updated_at ?? '',
);
describe('Skill Chip Phase 2 owner persistence', () => {
let database: Database;
beforeEach(async () => {
vi.clearAllMocks();
database = await connect(':memory:');
mocks.database = database;
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await database.exec(`INSERT INTO users(id,username,role,created_at)
VALUES('actor-1','Operator','admin','now')`);
await database.exec(`INSERT INTO specializations(id,name,created_at,updated_at)
VALUES('specialization-1','Tactical','now','now')`);
await database.exec(`INSERT INTO specialization_abilities(id,specialization_id,name,description,created_at,updated_at)
VALUES('ability-1','specialization-1','Pulse','Pulse description','now','now'),
('ability-2','specialization-1','Pulse Alt','Pulse alt description','now','now')`);
await database.exec(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,created_at,updated_at)
VALUES('skill-duration','Skill Duration','Offensive','Skill','Additive','s','now','now'),
('skill-damage','Skill Damage','Offensive','Skill','Additive','%','now','now')`);
mocks.auditRecord.mockImplementation(
async (_table: string, id: string, supplied: Database) =>
(await (
await supplied.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get(id)) ?? null,
);
mocks.recordIsacAudit.mockResolvedValue(undefined);
});
afterEach(async () => {
await database.close();
mocks.database = null;
});
it('parses an absent expected Talent identity as blank in the direct parser', async () => {
const form = new FormData();
form.set('name', 'Direct Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
await expect(parseSkillChipForm(database, form, 'actor-1')).resolves.toMatchObject({
expectedTalentId: '',
});
});
it('archives and unarchives a Skill Chip with lifecycle result tokens', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { hasTalent: false, authoredSource: null }),
);
await database.exec('COMMIT');
const lifecycleRequest = (action: 'archive' | 'unarchive') => {
const form = new FormData();
form.set('id', 'set-1');
return new Request(`http://localhost/isac/skillchips?/${action}`, {
method: 'POST',
body: form,
});
};
const archived = await actions.archive?.({
cookies: {} as never,
request: lifecycleRequest('archive'),
} as never);
expect(archived).toMatchObject({
skillChipArchived: true,
skillChipId: 'set-1',
resultToken: expect.any(String),
});
expect(
await (
await database.prepare('SELECT active FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual({ active: 0 });
const unarchived = await actions.unarchive?.({
cookies: {} as never,
request: lifecycleRequest('unarchive'),
} as never);
expect(unarchived).toMatchObject({
skillChipUnarchived: true,
skillChipId: 'set-1',
resultToken: expect.any(String),
});
expect(
await (
await database.prepare('SELECT active FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual({ active: 1 });
});
it('accepts a prose-only action, returns a result token, and persists the canonical fields', async () => {
const payload = source();
delete (payload as unknown as { owner?: unknown }).owner;
const form = new FormData();
form.set('name', 'Action Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '1');
form.set('expectedTalentId', '');
form.set('talentName', 'Action Signal Cascade');
form.set('authoredSource', JSON.stringify(payload));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', 'clear');
const result = await actions.save?.({
cookies: {} as never,
request: new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
}),
} as never);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'add',
resultToken: expect.any(String),
});
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
});
it('saves prose-only authored source without legacy mechanic writes', async () => {
await database.exec('BEGIN IMMEDIATE');
const result = await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
expect(result).toEqual({ setId: 'set-1', mode: 'add' });
expect(
await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
),
).toMatchObject({
source: source(),
});
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
expect(await count('skill_chip_talent_effects')).toBe(0);
expect(
(
await (
await database.prepare(
'SELECT description_template FROM skill_chip_talents',
)
).get()
)?.description_template,
).toBe('');
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'skill_chip.created',
entityId: 'set-1',
before: null,
after: expect.objectContaining({
mechanic: expect.objectContaining({
authoredSource: expect.objectContaining({
owner: { type: 'skill_chip_talent', id: 'talent-1' },
}),
selectedRevision: null,
publicProjection: expect.objectContaining({
variants: expect.any(Array),
}),
}),
}),
}),
);
});
it('rejects a canonical authored-source owner mismatch before writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
authoredSource: {
...source(),
owner: { type: 'skill_chip_talent', id: 'client-supplied' },
},
}),
),
).rejects.toThrow(/owner does not match/);
await database.exec('ROLLBACK');
expect(await count('skill_chip_sets')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(0);
});
it('rechecks the active three-chip limit inside the write transaction', async () => {
await database.exec(`INSERT INTO skill_chip_sets
(id,name,specialization_ability_id,created_at,updated_at)
VALUES ('set-a','Set A','ability-1','now','now'),
('set-b','Set B','ability-1','now','now'),
('set-c','Set C','ability-1','now','now')`);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
hasTalent: false,
authoredSource: null,
talentName: '',
selection: { intent: 'clear' },
}),
),
).rejects.toThrow(/all three active Skill Chips/);
await database.exec('ROLLBACK');
expect(await count('skill_chip_sets')).toBe(3);
});
it('appends a revision, skips an identical retry, and clears selection without deleting history', async () => {
const first = input(source(), {
authoredSource: source(),
selection: { intent: 'save', draft: model() },
});
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const firstEditOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: firstEditOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
});
await database.exec('COMMIT');
expect(await count('mechanic_model_revisions')).toBe(1);
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toMatchObject({ revisionNumber: 1 });
const secondEditOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: secondEditOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'clear' },
});
await database.exec('COMMIT');
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toBeNull();
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('rejects a stale aggregate after scalar, relationship, and selected revision changes without writing', async () => {
const first = input(source(), {
selection: { intent: 'save', draft: model() },
});
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const staleOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
name: 'Concurrent Pulse Matrix',
abilityId: 'ability-2',
twoPieceMin: 6,
twoPieceMax: 12,
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'save', draft: model(20) },
});
await database.exec('COMMIT');
await (
await database.prepare('UPDATE skill_chip_sets SET updated_at=? WHERE id=?')
).run('2026-08-02T12:00:01.000Z', 'set-1');
const ownerBeforeStale = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const relationshipBeforeStale = await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get('set-1');
const selectedRevisionBeforeStale = await loadSelectedMechanicModelRevision(
database,
savedSource!.id,
);
expect(ownerBeforeStale).toMatchObject({
name: 'Concurrent Pulse Matrix',
specialization_ability_id: 'ability-2',
updated_at: '2026-08-02T12:00:01.000Z',
});
expect(relationshipBeforeStale).toMatchObject({
min_value: 6,
max_value: 12,
});
expect(selectedRevisionBeforeStale).toMatchObject({
revisionNumber: 2,
draft: model(20),
});
const revisionCountBeforeStale = await count('mechanic_model_revisions');
const auditCountBeforeStale = mocks.recordIsacAudit.mock.calls.length;
const iconCallsBeforeStale = mocks.applyIconMutation.mock.calls.length;
const renderCallsBeforeStale = mocks.applyGameDataRender.mock.calls.length;
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
name: 'Stale Pulse Matrix',
abilityId: 'ability-1',
twoPieceMin: 7,
twoPieceMax: 14,
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'save', draft: model(30) },
}),
).rejects.toMatchObject({
status: 409,
message: expect.stringMatching(/changed after this editor was loaded/),
});
await database.exec('ROLLBACK');
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(ownerBeforeStale);
expect(
await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get('set-1'),
).toEqual(relationshipBeforeStale);
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toEqual(selectedRevisionBeforeStale);
expect(await count('mechanic_model_revisions')).toBe(revisionCountBeforeStale);
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBeforeStale);
expect(mocks.applyIconMutation).toHaveBeenCalledTimes(iconCallsBeforeStale);
expect(mocks.applyGameDataRender).toHaveBeenCalledTimes(renderCallsBeforeStale);
});
it('rejects a stale checked Talent after it was removed without writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const talentBefore = await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1');
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE skill_chip_set_id=?')
).run('set-1');
const setBefore = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const auditCountBefore = mocks.recordIsacAudit.mock.calls.length;
const iconCallsBefore = mocks.applyIconMutation.mock.calls.length;
const renderCallsBefore = mocks.applyGameDataRender.mock.calls.length;
const stale = await actions.save?.({
cookies: {} as never,
request: talentSaveRequest(
String(talentBefore?.id),
expectedOwnerUpdatedAt,
),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(setBefore);
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM skill_chip_talents')
).get(),
).toEqual({ count: 0 });
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBefore);
expect(mocks.applyIconMutation).toHaveBeenCalledTimes(iconCallsBefore);
expect(mocks.applyGameDataRender).toHaveBeenCalledTimes(renderCallsBefore);
});
it('rejects a stale blank Talent after another Talent was added without writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { hasTalent: false, authoredSource: null }),
);
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec(`INSERT INTO skill_chip_talents
(id,skill_chip_set_id,name,description_template,created_at,updated_at)
VALUES('late-talent','set-1','Late Talent','','now','now')`);
const setBefore = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const talentBefore = await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1');
const auditCountBefore = mocks.recordIsacAudit.mock.calls.length;
const stale = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest('', '', expectedOwnerUpdatedAt),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(setBefore);
expect(
await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1'),
).toEqual(talentBefore);
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBefore);
expect(await count('game_data_icons')).toBe(0);
expect(await count('managed_images')).toBe(0);
});
it('rejects a direct save when the expected Talent identity is stale before writes', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE skill_chip_set_id=?')
).run('set-1');
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt,
}),
),
).rejects.toThrow(/Talent changed/);
await database.exec('ROLLBACK');
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM skill_chip_talents')
).get(),
).toEqual({ count: 0 });
});
it('rejects stale Talent removal before changing identity, history, audit, or media', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const talentBefore = await (
await database.prepare(
'SELECT id,skill_chip_set_id,name FROM skill_chip_talents',
)
).get();
const stale = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest(
'f'.repeat(64),
'talent-1',
expectedOwnerUpdatedAt,
),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare(
'SELECT id,skill_chip_set_id,name FROM skill_chip_talents',
)
).get(),
).toEqual(talentBefore);
expect(
await (
await database.prepare(
'SELECT source_fingerprint FROM mechanic_authored_sources WHERE owner_id=?',
)
).get('talent-1'),
).toEqual({ source_fingerprint: savedSource?.fingerprint });
expect(mocks.recordIsacAudit).toHaveBeenCalledOnce();
expect(await count('game_data_icons')).toBe(0);
expect(await count('managed_images')).toBe(0);
});
it('removes a current Talent while preserving authored and revision history', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { selection: { intent: 'save', draft: model() } }),
);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const result = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest(
String(savedSource?.fingerprint),
'talent-1',
expectedOwnerUpdatedAt,
),
} as never);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'edit',
});
expect(await count('skill_chip_talents')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(1);
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('refreshes both abilities and rolls back a stale source or audit failure', async () => {
const first = input(source());
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const staleSourceOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
abilityId: 'ability-2',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleSourceOwnerUpdatedAt,
expectedSourceFingerprint: 'f'.repeat(64),
selection: { intent: 'preserve' },
}),
).rejects.toBeInstanceOf(MechanicSourceFingerprintConflictError);
await database.exec('ROLLBACK');
expect(await count('mechanic_model_revisions')).toBe(0);
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit failed'));
const auditFailureOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
abilityId: 'ability-2',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: auditFailureOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
}),
).rejects.toThrow('audit failed');
await database.exec('ROLLBACK');
expect(mocks.refreshAbilityFocuses).toHaveBeenCalledWith(
database,
'ability-1',
);
expect(mocks.refreshAbilityFocuses).toHaveBeenCalledWith(
database,
'ability-2',
);
expect(
(
await (
await database.prepare(
'SELECT specialization_ability_id FROM skill_chip_sets WHERE id=?',
)
).get('set-1')
)?.specialization_ability_id,
).toBe('ability-1');
});
});
@@ -0,0 +1,545 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import {
applyIconMutation,
gameDataIconMetadata,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
readGameDataRenderMutation,
ManagedImageValidationError,
processManagedImage,
} from '$lib/server/managed-images';
import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
mechanicAuthoredSourceSnapshot,
} from '$lib/server/mechanic-authored-sources';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { MediaMutationJournal } from '$lib/server/media-journal';
export class SkillChipFormValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'SkillChipFormValidationError';
this.status = status;
}
}
export class SkillChipTalentIdentityConflictError extends SkillChipFormValidationError {
constructor(expectedTalentId: string, currentTalentId: string) {
super(
409,
`The Skill Chip Talent changed after this editor was loaded (expected ${expectedTalentId || 'none'}, found ${currentTalentId || 'none'}). Reload the latest definition and try again.`,
);
this.name = 'SkillChipTalentIdentityConflictError';
}
}
function parseExpectedTalentId(value: FormDataEntryValue | null): string {
if (value === null) return '';
if (
typeof value !== 'string' ||
value !== value.trim() ||
value.length > 200 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
)
throw new SkillChipFormValidationError(
400,
'The expected Skill Chip Talent identity is invalid.',
);
return value;
}
/** Parses the canonical Skill Chip save form for direct saves and replay. */
export async function parseSkillChipForm(
database: Database,
form: FormData,
actorId: string,
mutationContext?: SkillChipMutationContext,
): Promise<SkillChipSaveInput> {
const id = String(form.get('id') ?? '').trim();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
throw new SkillChipFormValidationError(
400,
error instanceof Error ? error.message : 'The expected owner version is invalid.',
);
}
const existingSet = id
? await (
await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?')
).get(id)
: null;
if (id && !existingSet)
throw new SkillChipFormValidationError(404, 'Skill Chip set not found.');
if (id && expectedOwnerUpdatedAt !== String(existingSet?.updated_at ?? ''))
throw new SkillChipFormValidationError(
409,
'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.',
);
const expectedTalentId = parseExpectedTalentId(form.get('expectedTalentId'));
const existingTalent = id ? await loadTalent(database, id) : null;
const currentTalentId = String(existingTalent?.id ?? '');
// The child identity is the optimistic concurrency token for this owner.
if (expectedTalentId !== currentTalentId)
throw new SkillChipTalentIdentityConflictError(
expectedTalentId,
currentTalentId,
);
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
throw new SkillChipFormValidationError(400, error.message);
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
throw new SkillChipFormValidationError(400, error.message);
throw error;
}
const proposedEntityId = String(form.get('_proposedEntityId') ?? '').trim();
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const abilityId = String(form.get('abilityId') ?? '').trim();
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '').trim();
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
const talentId = String(existingTalent?.id ?? randomUUID());
let expectedSourceFingerprint: string | null = null;
if (hasTalent || existingTalent)
try {
expectedSourceFingerprint = parseExpectedSourceFingerprint(
form.get('expectedSourceFingerprint'),
);
} catch (error) {
throw new SkillChipFormValidationError(
400,
error instanceof Error
? error.message
: 'The expected authored-source fingerprint is invalid.',
);
}
const invalid = (status: number, message: string): never => {
throw new SkillChipFormValidationError(status, message);
};
if (name.length < 2 || name.length > 80)
invalid(400, 'Skill Chip names must be 2–80 characters.');
if (!abilityId)
invalid(400, 'Assign this Skill Chip to a specialization ability.');
if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin)
invalid(400, 'Configure a valid 2-piece stat range.');
if (existingTalent && !hasTalent && form.get('confirmTalentRemoval') !== 'REMOVE')
invalid(400, 'Confirm removal of the existing 3-piece Talent and mechanic program.');
let authoredSource: MechanicAuthoredSource | null = null;
let selection: MechanicOwnerSelection = { intent: 'clear' };
let activeStats: Array<Record<string, unknown>> = [];
if (hasTalent) {
if (talentName.length < 2 || talentName.length > 80)
invalid(400, 'Talent names must be 2–80 characters.');
activeStats = await (await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')).all();
try {
authoredSource = parseMechanicAuthoredSourcePayload(
form.get('authoredSource'),
{ type: 'skill_chip_talent', id: talentId },
);
selection = parseMechanicSelectionIntent(
form.get('mechanicSelectionIntent'),
form.get('conditionalRule'),
{
variantKeys: ['minimum', 'maximum'],
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: talentName,
ownerType: 'skill_chip_talent',
},
);
const authoredAttributeIds = [
...new Set(authoredSource.variants.flatMap((variant) =>
variant.slots.map((slot) => slot.attributeId).filter((value): value is string => Boolean(value)),
)),
];
if (authoredAttributeIds.some((attributeId) => !activeStats.some((row) => String(row.id) === attributeId)))
throw new Error('One or more authored Attributes are unavailable.');
} catch (error) {
invalid(400, error instanceof Error ? error.message : 'The authored Talent source is invalid.');
}
}
if (!(await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)))
invalid(400, 'Choose an active specialization ability.');
const assignedCount = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)).get(abilityId, id || 'new'))?.count ?? 0);
if (assignedCount >= 3)
invalid(409, 'That ability already has all three active Skill Chips assigned.');
if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new'))
invalid(409, 'That Skill Chip set already exists.');
if (hasTalent && await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, talentId))
invalid(409, 'That Skill Chip talent name is already in use.');
const validStats = await (await database.prepare('SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id=?')).get(twoPieceStatId);
if (Number(validStats?.count) !== 1)
invalid(400, 'One or more selected stats are unavailable.');
return {
id,
proposedEntityId,
name,
abilityId,
twoPieceStatId,
twoPieceMin,
twoPieceMax,
hasTalent,
talentName,
talentId,
expectedTalentId,
expectedOwnerUpdatedAt,
authoredSource,
expectedSourceFingerprint,
selection,
confirmTalentRemoval: form.get('confirmTalentRemoval') === 'REMOVE',
iconMutation,
renderMutation,
actorId,
mutationContext,
resolveAttributeLabel: (attributeId) =>
activeStats.find((row) => String(row.id) === attributeId)?.name as string | undefined,
};
}
const renderAuditMetadata = (
render: Record<string, unknown> | null | undefined,
) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
export async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
export async function loadTalent(database: Database, setId: string) {
return (
(await (
await database.prepare(
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents WHERE skill_chip_set_id=?`,
)
).get(setId)) ?? null
);
}
async function mechanicStateSnapshot(
database: Database,
talentId: string | null,
) {
if (!talentId) return null;
const authored = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
talentId,
);
if (!authored) return null;
const [selectedRevision, projection] = await Promise.all([
loadSelectedMechanicModelRevision(database, authored.id),
loadMechanicPublicProjection(database, authored.id),
]);
return {
authoredSource: mechanicAuthoredSourceSnapshot(authored),
selectedRevision: mechanicModelRevisionSnapshot(selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
selectedRevision,
projection,
),
};
}
export async function snapshot(database: Database, id: string) {
const talent = await loadTalent(database, id);
const mechanic = await mechanicStateSnapshot(
database,
talent ? String(talent.id) : null,
);
return {
record: await auditRecord('skill_chip_sets', id, database),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
mechanic,
};
}
export type SkillChipMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
export type SkillChipSaveInput = {
id: string;
proposedEntityId: string;
name: string;
abilityId: string;
twoPieceStatId: string;
twoPieceMin: number;
twoPieceMax: number;
hasTalent: boolean;
talentName: string;
talentId: string;
expectedTalentId: string;
expectedOwnerUpdatedAt?: string | null;
authoredSource: MechanicAuthoredSource | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
confirmTalentRemoval: boolean;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
actorId: string;
mutationContext?: SkillChipMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class SkillChipNotFoundError extends Error {
constructor() {
super('Skill Chip set not found.');
this.name = 'SkillChipNotFoundError';
}
}
/**
* Persists a Skill Chip owner and all of its dependent state in the caller's
* active transaction. Keeping the transaction outside this function lets Data
* Oversight replay the exact same canonical save path.
*/
export async function saveSkillChipInTransaction(
database: Database,
input: SkillChipSaveInput,
): Promise<{ setId: string; mode: 'add' | 'edit' }> {
const setId = input.id || input.proposedEntityId || randomUUID();
const before = input.id ? await snapshot(database, input.id) : null;
if (input.id && !before?.record) throw new SkillChipNotFoundError();
if (
input.id &&
input.expectedOwnerUpdatedAt !== String(before?.record?.updated_at ?? '')
)
throw new SkillChipFormValidationError(
409,
'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.',
);
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
const existingTalent = input.id ? await loadTalent(database, input.id) : null;
const currentTalentId = String(existingTalent?.id ?? '');
// Recheck inside the write transaction before any owner or media writes.
if (input.expectedTalentId !== currentTalentId)
throw new SkillChipTalentIdentityConflictError(
input.expectedTalentId,
currentTalentId,
);
const talentId = String(existingTalent?.id ?? input.talentId);
if (input.hasTalent && !input.authoredSource)
throw new Error('The authored description is required for a Talent.');
if (
input.authoredSource &&
(input.authoredSource.owner.type !== 'skill_chip_talent' ||
input.authoredSource.owner.id !== talentId)
) {
throw new Error('The authored mechanic owner does not match the Skill Chip Talent.');
}
if (existingTalent && !input.hasTalent && !input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing 3-piece Talent and mechanic program.',
);
if (existingTalent && !input.hasTalent) {
const currentSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
talentId,
);
if (input.expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
currentSource?.fingerprint ?? null,
);
}
// The parser checks this for immediate form feedback, but this query on the
// write transaction is authoritative for canonical callers and retries.
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(input.abilityId, setId)
)?.count ?? 0,
);
if (assignedCount >= 3)
throw new Error('That ability already has all three active Skill Chips assigned.');
const now = new Date().toISOString();
if (input.id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(input.name, input.abilityId, now, input.id);
} else {
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, input.name, input.abilityId, now, now);
}
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(
randomUUID(),
setId,
input.twoPieceStatId,
input.twoPieceMin,
input.twoPieceMax,
);
if (input.hasTalent && input.authoredSource) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,'',?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at`)
).run(talentId, setId, input.talentName, now, now);
await persistMechanicOwnerStateInTransaction(database, {
source: input.authoredSource,
expectedSourceFingerprint: input.expectedSourceFingerprint,
selection: input.selection,
programName: input.talentName,
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.actorId,
resolveAttributeLabel: input.resolveAttributeLabel,
});
} else if (existingTalent) {
// Preserve the authored source, selection, projection, and immutable
// revisions as append-only history. Public readers start from the Talent
// identity row, so the detached mechanic cannot leak after this deletion.
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
input.iconMutation,
input.actorId,
input.journal,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
input.name,
input.renderMutation,
input.actorId,
input.journal,
);
await refreshAbilityFocuses(database, input.abilityId);
if (previousAbilityId && previousAbilityId !== input.abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
const after = await snapshot(database, setId);
await recordIsacAudit({
actorId: input.actorId,
category: 'game_data',
action: input.id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: {
...after,
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { setId, mode: input.id ? 'edit' : 'add' };
}
/** Public for Data Oversight replay; SvelteKit page exports require an underscore prefix. */
export const _saveSkillChipInTransaction = saveSkillChipInTransaction;
@@ -6,7 +6,6 @@ const mocks = vi.hoisted(() => {
events,
talentExists: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
recordIsacAudit: vi.fn(async () => {
events.push('audit');
@@ -16,6 +15,7 @@ const mocks = vi.hoisted(() => {
events.push(sql);
}),
prepare: vi.fn(),
transactionAsync: vi.fn(),
},
};
});
@@ -63,13 +63,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const beforeRecord = {
@@ -77,13 +70,13 @@ const beforeRecord = {
name: 'Pulse Matrix',
specialization_ability_id: 'ability-1',
active: 1,
updated_at: '2026-08-02T11:00:00.000Z',
};
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
const existingTalent = {
id: 'talent-1',
skill_chip_set_id: 'set-1',
name: 'Signal Cascade',
description_template: 'Existing Talent',
};
const twoPiece = {
skill_chip_set_id: 'set-1',
@@ -91,17 +84,6 @@ const twoPiece = {
min_value: 5,
max_value: 10,
};
const talentEffects = [
{
id: 'effect-1',
talent_id: 'talent-1',
stat_attribute_id: 'skill-intensity',
min_value: 1,
max_value: 2,
sort_order: 1,
},
];
const conditional = { id: 'rule-1', source_id: 'talent-1' };
const saveRequest = (confirmRemoval = false) => {
const form = new FormData();
@@ -112,6 +94,8 @@ const saveRequest = (confirmRemoval = false) => {
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
form.set('expectedOwnerUpdatedAt', beforeRecord.updated_at);
form.set('expectedTalentId', 'talent-1');
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
@@ -128,14 +112,31 @@ const runSave = (confirmRemoval = false) =>
describe('Skill Chip Talent removal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.transactionAsync.mockImplementation(
(callback: (transaction: unknown) => Promise<unknown>) => {
const run = vi.fn(async () => {
mocks.events.push('BEGIN IMMEDIATE');
try {
const result = await callback(mocks.database);
mocks.events.push('COMMIT');
return result;
} catch (error) {
mocks.events.push('ROLLBACK');
throw error;
}
});
return Object.assign(run, { immediate: run });
},
);
mocks.events.length = 0;
mocks.talentExists = true;
mocks.auditRecord
.mockResolvedValueOnce(beforeRecord)
.mockResolvedValueOnce(afterRecord);
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('SELECT updated_at FROM skill_chip_sets'))
return { updated_at: beforeRecord.updated_at };
if (sql.includes('FROM specialization_abilities'))
return { id: 'ability-1' };
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
@@ -149,14 +150,8 @@ describe('Skill Chip Talent removal', () => {
if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_talent_effects'))
return talentEffects;
return [];
}),
all: vi.fn(async () => []),
run: vi.fn(async () => {
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM skill_chip_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
@@ -181,7 +176,7 @@ describe('Skill Chip Talent removal', () => {
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes the Talent and mechanic in one audited transaction after confirmation', async () => {
it('removes the Talent while preserving immutable mechanic history', async () => {
const result = await runSave(true);
expect(result).toMatchObject({
@@ -189,13 +184,19 @@ describe('Skill Chip Talent removal', () => {
skillChipMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'BEGIN IMMEDIATE',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.events).not.toContain('delete conditional rule');
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('DELETE FROM mechanic_authored_sources'),
),
).toBe(false);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
@@ -207,8 +208,7 @@ describe('Skill Chip Talent removal', () => {
render: null,
twoPiece,
talent: existingTalent,
talentEffects,
conditional,
mechanic: null,
},
after: {
record: afterRecord,
@@ -216,8 +216,8 @@ describe('Skill Chip Talent removal', () => {
render: null,
twoPiece,
talent: null,
talentEffects: [],
conditional: null,
mechanic: null,
mutationContext: { origin: 'isac' },
},
database: mocks.database,
});
File diff suppressed because it is too large Load Diff
+111 -20
View File
@@ -12,7 +12,9 @@
import GameIcon from '$lib/components/GameIcon.svelte';
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
let { data, form } = $props();
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
@@ -56,11 +58,19 @@
specializationId: string;
name: string;
descriptionTemplate: string;
expectedOwnerUpdatedAt: string;
expectedAttachmentFingerprints: Record<string, string>;
active: number;
};
type RankCombatEffect = {
/** Stable client identity for a newly added attachment before the server allocates its ID. */
editorKey: string;
id: string;
effectId: string;
conditionalRule: ConditionalMechanicDraftV1;
authoredSource: MechanicAuthoredSource;
expectedSourceFingerprint: string | null;
selectionIntent: 'preserve' | 'save' | 'clear';
modelDraft: ConditionalMechanicDraftV1;
};
type RankEditor = {
rank: number;
@@ -74,6 +84,8 @@
focusId: string;
name: string;
descriptionTemplate: string;
expectedOwnerUpdatedAt: string;
expectedAttachmentFingerprints: Record<string, string>;
rowNumber: number | string;
columnNumber: number | string;
maxRank: string;
@@ -93,6 +105,12 @@
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }];
const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }];
const authoredVariantLabels = [{ key: 'base', label: 'BASE' }];
const modelSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '', renderUrl: '' });
const blankFocus = (specializationId = ''): FocusEditor => ({
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
@@ -107,15 +125,25 @@
effects: [], active: 1, iconUrl: '', renderUrl: ''
});
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
id: '', specializationId, name: '', descriptionTemplate: '', expectedOwnerUpdatedAt: '', expectedAttachmentFingerprints: {}, active: 1
});
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
});
const blankRankCombatEffect = (): RankCombatEffect => ({
editorKey: crypto.randomUUID(),
id: '',
effectId: '',
conditionalRule: blankConditionalRule()
authoredSource: {
version: 1,
owner: { type: 'specialization_tree_rank', id: '' },
template: '',
variants: [{ key: 'base', materialized: '', slots: [] }]
},
expectedSourceFingerprint: null,
selectionIntent: 'clear',
modelDraft: blankConditionalRule()
});
const blankRank = (rank: number): RankEditor => ({
rank,
@@ -127,7 +155,7 @@
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
});
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
id: '', focusId, name: '', descriptionTemplate: '', rowNumber,
id: '', focusId, name: '', descriptionTemplate: '', expectedOwnerUpdatedAt: '', expectedAttachmentFingerprints: {}, rowNumber,
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: '', renderUrl: ''
});
const normalizedEffect = (effect: any): StructuredEffect => ({
@@ -278,6 +306,8 @@
specializationId: item.specialization_id,
name: item.name,
descriptionTemplate: item.description_template,
expectedOwnerUpdatedAt: item.updated_at ?? '',
expectedAttachmentFingerprints: { ...(item.expectedAttachmentFingerprints ?? {}) },
active: Number(item.active)
};
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
@@ -288,6 +318,8 @@
focusId: item.focus_id,
name: item.name,
descriptionTemplate: item.description_template,
expectedOwnerUpdatedAt: item.updated_at ?? '',
expectedAttachmentFingerprints: { ...(item.expectedAttachmentFingerprints ?? {}) },
rowNumber: item.row_number,
columnNumber: item.column_number,
maxRank: String(item.max_rank),
@@ -296,8 +328,13 @@
description: rank.description,
effects: (rank.effects ?? []).map(normalizedEffect),
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
editorKey: String(effect.id ?? '') || crypto.randomUUID(),
id: String(effect.id ?? ''),
effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''),
conditionalRule: effect.conditionalRule ?? blankConditionalRule()
authoredSource: effect.authoredSource,
expectedSourceFingerprint: effect.expectedSourceFingerprint ?? null,
selectionIntent: effect.selectionIntent ?? (effect.selectedRevision ? 'preserve' : 'clear'),
modelDraft: effect.modelDraft ?? blankConditionalRule()
})),
costs: { ...(rank.costs ?? {}) }
})),
@@ -321,6 +358,37 @@
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index);
};
const reorderRankCombatEffect = (rank: RankEditor, index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= rank.combatEffects.length) return;
const effects = [...rank.combatEffects];
[effects[index], effects[targetIndex]] = [effects[targetIndex], effects[index]];
rank.combatEffects = effects;
};
const rankCombatEffectEditorKey = (effect: RankCombatEffect) =>
`${nodeEditing?.id || 'new-node'}-${effect.id || effect.editorKey}`;
const setModelIntent = (effect: RankCombatEffect, intent: 'preserve' | 'save' | 'clear') => {
effect.selectionIntent = intent;
if (intent === 'save' && !effect.modelDraft) effect.modelDraft = blankConditionalRule();
};
const selectRankCombatEffect = (effect: RankCombatEffect, effectId: string) => {
effect.effectId = effectId;
const template = String(combatEffectById(effectId)?.description_template ?? '');
const tokens = [...new Set([...template.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]))];
const existing = effect.authoredSource.variants.find((variant) => variant.key === 'base');
const existingSlots = new Map((existing?.slots ?? []).map((slot) => [slot.key, slot]));
const slots = tokens.map((key, order) => ({
...(existingSlots.get(key) ?? { key, value: '', order }),
key,
order
}));
const values = new Map(slots.map((slot) => [slot.key, slot.value]));
effect.authoredSource = {
...effect.authoredSource,
template,
variants: [{ key: 'base', materialized: template.replace(/\{([^{}]+)\}/g, (placeholder, key: string) => values.get(key) || placeholder), slots }]
};
};
const openAbilityConfiguration = () => {
if (!selectedFocus) return;
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
@@ -567,6 +635,8 @@
<div class="editor-title"><div><p class="eyebrow">{combatEffectEditing.id ? 'MODIFY COMBAT EFFECT' : 'NEW COMBAT EFFECT'}</p><h3>{combatEffectEditing.id ? combatEffectEditing.name : 'SHARED EFFECT DEFINITION'}</h3></div><button aria-label="Close combat effect editor" onclick={() => combatEffectEditing = null}>×</button></div>
<PendingForm action="?/saveCombatEffect">
<input type="hidden" name="id" value={combatEffectEditing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={combatEffectEditing.expectedOwnerUpdatedAt}/>
<input type="hidden" name="expectedAttachmentFingerprints" value={JSON.stringify(combatEffectEditing.expectedAttachmentFingerprints)}/>
{#if combatEffectEditing.id}
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
{:else}
@@ -574,9 +644,9 @@
{/if}
<div class="form-grid combat-effect-definition-grid">
<label class="field isac-record-name">NAME<input name="name" bind:value={combatEffectEditing.name} maxlength="100" required placeholder="Relentless"/></label>
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {maxStacks} stacks.'}></textarea></label>
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {max_stacks} stacks.'}></textarea></label>
</div>
<p class="configuration-note">Use {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.</p>
<p class="configuration-note">Use {`{duration}`}, {`{max_stacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.</p>
{#if form?.combatEffectError}<p class="error">{form.combatEffectError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
</PendingForm>
@@ -588,6 +658,8 @@
<div class="editor-title"><div><p class="eyebrow">{nodeEditing.id ? 'MODIFY TREE NODE' : 'NEW TREE NODE'}</p><h3>{nodeEditing.id ? nodeEditing.name : 'UPGRADE DEFINITION'}</h3></div><button aria-label="Close tree node editor" onclick={() => nodeEditing = null}>×</button></div>
<PendingForm action="?/saveNode" enctype="multipart/form-data">
<input type="hidden" name="id" value={nodeEditing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={nodeEditing.expectedOwnerUpdatedAt}/>
<input type="hidden" name="expectedAttachmentFingerprints" value={JSON.stringify(nodeEditing.expectedAttachmentFingerprints)}/>
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
<div class="isac-record-identity specialization-record-identity node-record-identity">
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
@@ -618,21 +690,40 @@
<StructuredEffectEditor bind:effects={rank.effects} attributes={data.attributes} abilities={abilityList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} idPrefix={`node-rank-${rank.rank}`} addLabel={`ADD RANK ${rank.rank} EFFECT`}/>
<div class="rank-combat-effects">
<div class="selection-head"><div><span>CONDITIONAL + STACKING EFFECTS</span><small>BUILDER-CONTROLLABLE COMBAT MODEL</small></div><button type="button" class="secondary" onclick={() => addRankCombatEffect(rank)}>ADD CONDITIONAL EFFECT +</button></div>
{#each rank.combatEffects as combatEffect, combatIndex}
{#each rank.combatEffects as combatEffect, combatIndex (rankCombatEffectEditorKey(combatEffect))}
<section class="rank-combat-effect">
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></header>
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
<MechanicProgramEditor
attributes={data.attributes}
valueColumns={conditionalValueColumns}
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
aiContext={{
sourceKind: 'specialization_tree_rank',
sourceName: combatEffectById(combatEffect.effectId)?.name ?? `${nodeEditing?.name ?? 'Tree node'} · Rank ${rank.rank}`,
sourceDescription: rank?.description ?? ''
}}
bind:rule={combatEffect.conditionalRule}
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><div class="rank-combat-effect-actions"><button type="button" class="secondary" disabled={combatIndex === 0} onclick={() => reorderRankCombatEffect(rank, combatIndex, -1)} aria-label={`Move effect ${combatIndex + 1} up`}>↑ UP</button><button type="button" class="secondary" disabled={combatIndex === rank.combatEffects.length - 1} onclick={() => reorderRankCombatEffect(rank, combatIndex, 1)} aria-label={`Move effect ${combatIndex + 1} down`}>↓ DOWN</button><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></div></header>
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatEffect.id || combatEffect.editorKey}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId} onvaluechange={(value) => selectRankCombatEffect(combatEffect, value)}/></div>
{#key rankCombatEffectEditorKey(combatEffect)}
<AuthoredDescriptionEditor
variantLabels={authoredVariantLabels}
idPrefix={`specialization-rank-${rank.rank}-authored-${nodeEditing.id || 'new-node'}-${combatEffect.id || combatEffect.editorKey}`}
bind:template={combatEffect.authoredSource.template}
bind:variants={combatEffect.authoredSource.variants}
/>
{/key}
<SegmentedChoice
legend="COMBAT MODEL"
name={`specialization-rank-${rank.rank}-model-${combatEffect.id || combatEffect.editorKey}`}
options={modelSelectionOptions}
value={combatEffect.selectionIntent}
onchange={(values) => setModelIntent(combatEffect, values[0] as 'preserve' | 'save' | 'clear')}
/>
{#if combatEffect.selectionIntent === 'save'}
{#key rankCombatEffectEditorKey(combatEffect)}
<MechanicProgramEditor
attributes={data.attributes}
valueColumns={conditionalValueColumns}
idPrefix={`specialization-rank-${rank.rank}-conditional-${nodeEditing.id || 'new-node'}-${combatEffect.id || combatEffect.editorKey}`}
aiContext={{
sourceKind: 'specialization_tree_rank',
sourceName: combatEffectById(combatEffect.effectId)?.name ?? `${nodeEditing?.name ?? 'Tree node'} · Rank ${rank.rank}`,
sourceDescription: rank?.description ?? ''
}}
bind:rule={combatEffect.modelDraft}
/>
{/key}
{/if}
</section>
{/each}
</div>
@@ -64,13 +64,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocuses: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const saveRequest = (compatibleAbilityId: string) => {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+66 -64
View File
@@ -8,15 +8,23 @@
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
import { tick } from 'svelte';
let { data, form } = $props();
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const tierIIDocumentationOptions = [
{ value: 'documented', label: 'DOCUMENTED' },
{ value: 'not_documented', label: 'NOT YET DOCUMENTED' }
];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
let talent = $state<any>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
@@ -38,7 +46,7 @@
const areaLabel = (area: TalentErrorArea) => ({
icon: 'Talent icon',
identity: 'Talent identity',
description: 'Description template',
description: 'Authored description',
mechanic: 'Mechanic program',
outcomes: 'Mechanic outcomes'
}[area]);
@@ -51,25 +59,46 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const blankVariants = (hasTierII = true): MechanicAuthoredVariant[] => [
{ key: 'I', materialized: '', slots: [] },
...(hasTierII ? [{ key: 'II', materialized: '', slots: [] }] : [])
];
const variantLabels = (hasTierII: boolean) => [
{ key: 'I', label: 'TIER I' },
...(hasTierII ? [{ key: 'II', label: 'TIER II' }] : [])
];
const reconcileTierVariants = (hasTierII: boolean) => {
if (!talent) return;
const existing = new Map((talent.authoredVariants ?? []).map((variant: MechanicAuthoredVariant) => [variant.key, variant]));
talent.authoredVariants = variantLabels(hasTierII).map((variant) => existing.get(variant.key) ?? { key: variant.key, materialized: talent.authoredTemplate ?? '', slots: [] });
};
const blankTalent = () => ({
id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1',
authoredTemplate: '', authoredVariants: blankVariants(), expectedOwnerUpdatedAt: '', expectedSourceFingerprint: '',
tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(),
mechanicSelectionIntent: 'clear', modelingOpen: false
});
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II');
const authored = row.authoredSource;
const hasTierII = row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented);
talent = {
...row,
descriptionTemplate: row.descriptionTemplate || one.description,
tierIDescription: one.description,
tierIIDescription: two.description,
tierIIDocumented: row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented),
tierIIDocumentation: row.category === 'Exotic Weapon' && !row.tierIIDocumented ? 'not_documented' : 'documented',
conditionalRule: row.conditionalRule ?? blankRule(),
modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
authoredTemplate: authored?.template ?? '',
authoredVariants: authored?.variants ?? blankVariants(hasTierII),
expectedOwnerUpdatedAt: row.updated_at ?? '',
expectedSourceFingerprint: row.expectedSourceFingerprint ?? '',
tierIIDocumented: hasTierII,
tierIIDocumentation: hasTierII ? 'documented' : 'not_documented',
conditionalRule: row.selectedRevision?.draft ?? blankRule(),
mechanicSelectionIntent: 'preserve',
modelingOpen: Boolean(row.selectedRevision?.draft?.clauses?.length)
};
};
const setTierIIDocumented = (documented: boolean) => {
if (!talent) return;
talent.tierIIDocumented = documented;
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
reconcileTierVariants(documented);
talent.conditionalRule = {
...talent.conditionalRule,
pools: talent.conditionalRule.pools.map((pool: any) => {
@@ -87,11 +116,7 @@
outcomes: clause.outcomes.map((outcome: any) => ({
...outcome,
variants: documented
? {
...outcome.variants,
I: outcome.variants.I,
II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 })
}
? { ...outcome.variants, I: outcome.variants.I, II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 }) }
: { I: outcome.variants.I }
}))
}))
@@ -100,40 +125,13 @@
const setCategory = (category: string) => {
if (!talent) return;
talent.category = category;
if (category !== 'Exotic Weapon' && !talent.tierIIDocumented) setTierIIDocumented(true);
if (category !== 'Exotic Weapon') setTierIIDocumented(true);
};
const valueColumns = () => talent?.category === 'Exotic Weapon' && !talent.tierIIDocumented
? [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }]
: [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }, { key: 'tierII', variantKey: 'II', label: 'TIER II' }];
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description;
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
};
const displayValue = (effect: any, tier: 'I' | 'II') => {
const stat = data.stats.find((item) => item.id === effect.attributeId);
const value = effect.variants?.[tier]?.value ?? '';
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`;
const preview = (tier: 'I' | 'II') => {
if (tier === 'II' && talent.category === 'Exotic Weapon' && !talent.tierIIDocumented) return 'NOT YET DOCUMENTED';
return talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const durationPool = talent.conditionalRule.pools.find((pool: any) =>
pool.expiresAfterSeconds !== undefined || pool.expiresAfterSecondsVariants?.[tier] !== undefined);
const duration = durationPool?.expiresAfterSecondsVariants?.[tier] ?? durationPool?.expiresAfterSeconds;
const stackPools = talent.conditionalRule.pools.filter((pool: any) => pool.stateKind === 'stack');
const maxStacks = stackPools.length ? Math.max(...stackPools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes)
.find((outcome: any) => outcome.descriptionSlot === Number(expression));
return effect ? displayValue(effect, tier) : placeholder;
});
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
@@ -164,16 +162,18 @@
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
<input type="hidden" name="id" value={talent.id}/>
<input type="hidden" name="authoredSource" value={JSON.stringify({version: 1, template: talent.authoredTemplate, variants: talent.authoredVariants})}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={talent.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={talent.expectedSourceFingerprint ?? ''}/>
<input type="hidden" name="tierIIDocumented" value={talent.tierIIDocumented ? '1' : '0'}/>
{#if talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
{/if}
{#if talentErrors.length}
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
<ul>
{#each talentErrors as error}
<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>
{/each}
</ul>
<ul>{#each talentErrors as error}<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>{/each}</ul>
</div>
{/if}
<div class="talent-icon-identity">
@@ -191,26 +191,31 @@
{#if talent.category === 'Exotic Weapon'}
<div class="form-grid">
<SegmentedChoice legend="TIER II DOCUMENTATION" name="tierIIDocumentation" options={tierIIDocumentationOptions} bind:value={talent.tierIIDocumentation} onchange={(values) => setTierIIDocumented(values[0] === 'documented')} required/>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II values will be modeled and saved.' : 'Tier I is required. Tier II will remain absent until its game values are documented.'}</p>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II authored values are saved.' : 'Tier I is required. Tier II remains absent until its game values are documented.'}</p>
</div>
{/if}
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<div id="talent-description" class:validation-area-error={hasErrors(['description'])}>
<AuthoredDescriptionEditor
bind:template={talent.authoredTemplate}
bind:variants={talent.authoredVariants}
variantLabels={variantLabels(talent.tierIIDocumented)}
idPrefix={`talent-${talent.id || 'new'}`}
/>
{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}
</div>
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
<summary>
<span><b>COMBAT MODELING</b><small>{talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY · MODEL LATER' : `${talent.conditionalRule.clauses.length} CONFIGURED ${talent.conditionalRule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}`}</small></span>
<strong>{talent.modelingOpen ? 'COLLAPSE' : 'EXPAND'}</strong>
</summary>
<div class="modeling-content">
<SegmentedChoice legend="COMBAT MODEL SELECTION" name="mechanicSelectionIntent" options={mechanicSelectionOptions} bind:value={talent.mechanicSelectionIntent} onchange={(values) => talent.mechanicSelectionIntent = values[0]} required/>
<p class="console-note">{talent.mechanicSelectionIntent === 'preserve' ? 'Keep the selected immutable revision, if one exists.' : talent.mechanicSelectionIntent === 'clear' ? 'Remove the selected revision. Revision history remains available.' : 'Append and select a new immutable revision from the draft below.'}</p>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={valueColumns()}
valueColumns={variantLabels(talent.tierIIDocumented).map((variant) => ({key: variant.key === 'I' ? 'tierI' : 'tierII', variantKey: variant.key, label: variant.label}))}
idPrefix={`talent-${talent.id || 'new'}`}
aiContext={{
sourceKind: 'talent',
sourceName: talent?.name ?? '',
sourceDescription: talent?.descriptionTemplate ?? ''
}}
aiContext={{sourceKind: 'talent', sourceName: talent.name ?? '', sourceDescription: talent.authoredTemplate ?? ''}}
bind:rule={talent.conditionalRule}
/>
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
@@ -222,9 +227,6 @@
{/if}
{/snippet}
{#if talent && !talent.id}
{@render talentEditor()}
{/if}
{#if talent && !talent.id}{@render talentEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.mechanicStatus}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
@@ -0,0 +1,636 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import {
saveTalentFromFormInTransaction,
saveTalentInTransaction,
} from './talent-save';
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(async () => null),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({
file: null,
remove: false,
})),
}));
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(
new URL('../../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
const source = (secondTier = '8') => ({
version: 1,
template: 'After {1} hits, gain {2}% weapon damage for {duration} seconds.',
variants: [
{
key: 'I',
materialized: 'After 10 hits, gain 15% weapon damage for 5 seconds.',
slots: [
{ key: '1', value: '10', order: 0 },
{ key: '2', value: '15', order: 1, unit: '%', attributeId: 'damage' },
{ key: 'duration', value: '5', order: 2 },
],
},
{
key: 'II',
materialized: `After 8 hits, gain ${secondTier}% weapon damage for 7 seconds.`,
slots: [
{ key: '1', value: '8', order: 0 },
{
key: '2',
value: secondTier,
order: 1,
unit: '%',
attributeId: 'damage',
},
{ key: 'duration', value: '7', order: 2 },
],
},
],
});
const draft = (value = 15) => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'weapon-hit',
label: 'Weapon hit',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'damage',
label: 'Damage',
phase: 'after_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
basis: 'flat',
attributeId: 'damage',
variants: { I: { value }, II: { value: value + 5 } },
},
],
},
],
});
const formFor = ({
id = '',
authored = source(),
expected = '',
expectedOwnerUpdatedAt = '',
intent = 'preserve',
model = draft(),
category = 'Weapon Talent 1',
tierIIDocumented = true,
}: {
id?: string;
authored?: ReturnType<typeof source>;
expected?: string;
expectedOwnerUpdatedAt?: string;
intent?: 'preserve' | 'save' | 'clear';
model?: ReturnType<typeof draft>;
category?: string;
tierIIDocumented?: boolean;
} = {}) => {
const form = new FormData();
form.set('id', id);
form.set('name', 'Escalation Loop');
form.set('category', category);
form.set('tierIIDocumented', tierIIDocumented ? '1' : '0');
form.set('authoredSource', JSON.stringify(authored));
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedSourceFingerprint', expected);
form.set('mechanicSelectionIntent', intent);
if (intent === 'save') form.set('conditionalRule', JSON.stringify(model));
return form;
};
const media = () => ({
iconMutation: { output: null, remove: false },
renderMutation: { file: null, remove: false },
});
const save = async (form: FormData) => {
const operation = database.transactionAsync(async (transaction) =>
saveTalentFromFormInTransaction(transaction as unknown as Database, {
form,
operatorId: 'actor-1',
...media(),
}),
);
return operation.immediate();
};
const count = async (table: string, where = '') =>
Number(
(
await (
await database.prepare(
`SELECT COUNT(*) AS count FROM ${table} ${where}`,
)
).get()
)?.count ?? 0,
);
const sourceFingerprint = async (id = 'talent-1') =>
String(
(
await (
await database.prepare(
'SELECT source_fingerprint FROM mechanic_authored_sources WHERE owner_id=?',
)
).get(id)
)?.source_fingerprint,
);
const ownerUpdatedAt = async (id: string) =>
String(
(
await (
await database.prepare('SELECT updated_at FROM talents WHERE id=?')
).get(id)
)?.updated_at,
);
describe('Talent Phase 2 owner action', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await database.exec(`INSERT INTO users(id,username,role,created_at)
VALUES('actor-1','Agent','admin','now')`);
await database.exec(`INSERT INTO stat_attributes(
id,name,type,category,calculation_type,unit,created_at,updated_at
) VALUES('damage','Weapon Damage','Offensive','Weapon','Additive','%','now','now')`);
});
afterEach(async () => {
await database.close();
});
it('saves complete authored prose without a model and audits source evidence', async () => {
const result = await save(formFor({ id: '' }));
expect(result).toEqual({
ok: true,
talentId: expect.any(String),
talentMode: 'add',
});
expect(await count('mechanic_authored_sources')).toBe(1);
expect(
await (
await database.prepare(
'SELECT owner_id,owner_type,talent_scope,talent_has_tier_ii FROM mechanic_authored_sources',
)
).get(),
).toMatchObject({
owner_id: (result as { talentId: string }).talentId,
owner_type: 'talent',
talent_scope: 'standard',
talent_has_tier_ii: 1,
});
expect(await count('mechanic_public_variants')).toBe(2);
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('talent_description_templates')).toBe(0);
expect(await count('talent_tiers')).toBe(0);
expect(await count('talent_tier_effects')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
const id = (result as { talentId: string }).talentId;
const audit = await (
await database.prepare(
'SELECT actor_id,entity_type,entity_id,action,after_json,created_at FROM audit_log WHERE entity_id=?',
)
).get(id);
expect(audit).toMatchObject({
actor_id: 'actor-1',
entity_type: 'game_data',
entity_id: id,
action: 'talent.created',
created_at: expect.any(String),
});
const after = JSON.parse(String(audit?.after_json));
expect(after).toMatchObject({
authoredSource: { template: source().template },
selectedRevision: null,
readiness: null,
scenarioInputs: [],
graph: null,
publicProjection: { variants: [{ key: 'I' }, { key: 'II' }] },
relationships: {
weaponScope: { scope: 'Standard' },
weaponSlot: { slot: 1 },
ownerWeapon: null,
gearTalents: [],
weaponTalentOptions: [],
},
ownerState: {
authoredSource: { owner: { type: 'talent', scope: 'standard' } },
selectedRevision: null,
},
});
});
it('accepts prose-only Exotic Talents without a Tier II variant', async () => {
const authored = source();
authored.variants = [authored.variants[0]];
const result = await save(
formFor({
authored,
category: 'Exotic Weapon',
tierIIDocumented: false,
intent: 'clear',
}),
);
expect(result).toMatchObject({ ok: true, talentMode: 'add' });
const id = (result as { talentId: string }).talentId;
expect(
await (
await database.prepare(
'SELECT talent_scope,talent_has_tier_ii FROM mechanic_authored_sources WHERE owner_id=?',
)
).get(id),
).toEqual({ talent_scope: 'exotic', talent_has_tier_ii: 0 });
expect(
await (
await database.prepare(
'SELECT variant_key FROM mechanic_authored_variants WHERE source_id=(SELECT id FROM mechanic_authored_sources WHERE owner_id=?)',
)
).all(id),
).toEqual([{ variant_key: 'I' }]);
expect(await count('mechanic_model_revisions')).toBe(0);
});
it('rejects authored Attribute metadata that is not active before writing', async () => {
const authored = source();
authored.variants[0].slots[1].attributeId = 'retired-stat';
const result = await save(formFor({ authored }));
expect(result).toMatchObject({
ok: false,
status: 400,
errors: [
{ area: 'outcomes', message: expect.stringMatching(/unavailable/) },
],
});
expect(await count('talents')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(0);
expect(await count('audit_log')).toBe(0);
});
it('uses the replay callable with a server-owned Talent ID', async () => {
await database.exec('BEGIN IMMEDIATE');
const result = await saveTalentInTransaction(database, {
id: '',
proposedEntityId: 'proposal-talent-1',
name: 'Callable Talent',
category: 'Weapon Talent 2',
authoredSource: {
...source(),
version: 1 as const,
owner: { type: 'talent', id: 'client-supplied', scope: 'exotic' },
},
expectedSourceFingerprint: null,
selection: { intent: 'preserve' },
tierIIDocumented: true,
...media(),
actorId: 'actor-1',
});
await database.exec('COMMIT');
expect(result).toEqual({ talentId: 'proposal-talent-1', mode: 'add' });
expect(result.talentId).not.toBe('client-supplied');
expect(
await (
await database.prepare(
'SELECT owner_id,talent_scope FROM mechanic_authored_sources',
)
).get(),
).toMatchObject({ owner_id: result.talentId, talent_scope: 'standard' });
});
it('appends immutable revisions, skips an identical retry, preserves stale state, and clears without deleting history', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
let fingerprint = await sourceFingerprint(id);
let expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const appended = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'save',
}),
);
expect(appended).toMatchObject({ ok: true, talentMode: 'edit' });
expect(await count('mechanic_model_revisions')).toBe(1);
expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const retry = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'save',
}),
);
expect(retry).toMatchObject({ ok: true });
expect(await count('mechanic_model_revisions')).toBe(1);
expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const changed = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
authored: source('25'),
intent: 'preserve',
}),
);
expect(changed).toMatchObject({ ok: true });
fingerprint = await sourceFingerprint(id);
expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
expect(await count('mechanic_model_revisions')).toBe(1);
expect(
await (
await database.prepare(
'SELECT status,display_source FROM mechanic_public_variants WHERE source_id=(SELECT id FROM mechanic_authored_sources WHERE owner_id=?)',
)
).all(id),
).toMatchObject([
{ status: 'not_modeled', display_source: 'authored' },
{ status: 'not_modeled', display_source: 'authored' },
]);
const cleared = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'clear',
}),
);
expect(cleared).toMatchObject({ ok: true });
expect(await count('mechanic_model_revisions')).toBe(1);
expect(await count('mechanic_model_selections')).toBe(0);
});
it('keeps category relationships valid instead of leaving stale assignments', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const fingerprint = await sourceFingerprint(id);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
await database.exec(`INSERT INTO gear(
id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at
) VALUES('gear-1','Vest','Body Armor',0,1,1,1,1,'now','now')`);
await (
await database.prepare(
'INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)',
)
).run('gear-1', id);
const result = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
category: 'Backpack',
}),
);
expect(result).toMatchObject({
ok: false,
status: 400,
errors: [{ area: 'identity', message: expect.stringMatching(/gear/) }],
});
expect(
await (
await database.prepare('SELECT category FROM talents WHERE id=?')
).get(id),
).toEqual({ category: 'Weapon' });
expect(
await (
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
).toEqual([{ gear_id: 'gear-1', talent_id: id }]);
});
it('rejects a stale fingerprint before writing anything', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const beforeName = await (
await database.prepare('SELECT name FROM talents WHERE id=?')
).get(id);
const auditsBefore = await count('audit_log');
const result = await save(
formFor({
id,
expected: 'f'.repeat(64),
expectedOwnerUpdatedAt,
authored: source('30'),
intent: 'clear',
}),
);
expect(result).toMatchObject({
ok: false,
status: 409,
errors: [{ area: 'description' }],
});
expect(await count('audit_log')).toBe(auditsBefore);
expect(await count('mechanic_model_selections')).toBe(0);
expect(
await (
await database.prepare('SELECT name FROM talents WHERE id=?')
).get(id),
).toEqual(beforeName);
expect(await sourceFingerprint(id)).not.toBe('f'.repeat(64));
});
it('rejects a stale aggregate after an owner scalar mutation without writing', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const fingerprint = await sourceFingerprint(id);
await (
await database.prepare(
'UPDATE talents SET name=?,updated_at=? WHERE id=?',
)
).run('Concurrent Talent Edit', 'owner-scalar-version', id);
const ownerBefore = await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id);
const auditsBefore = await count('audit_log');
const stale = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
authored: source('30'),
intent: 'clear',
}),
);
expect(stale).toMatchObject({
ok: false,
status: 409,
errors: [
{ area: 'identity', message: expect.stringMatching(/changed after/) },
],
});
expect(
await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id),
).toEqual(ownerBefore);
expect(await sourceFingerprint(id)).toBe(fingerprint);
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('audit_log')).toBe(auditsBefore);
});
it('rejects a stale aggregate after a relationship mutation without writing', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const fingerprint = await sourceFingerprint(id);
await (
await database.prepare(
'UPDATE weapon_talent_slots SET slot=? WHERE talent_id=?',
)
).run(2, id);
await (
await database.prepare('UPDATE talents SET updated_at=? WHERE id=?')
).run('owner-relationship-version', id);
const ownerBefore = await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id);
const relationshipBefore = await (
await database.prepare(
'SELECT talent_id,slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(id);
const auditsBefore = await count('audit_log');
const stale = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
category: 'Weapon Talent 1',
authored: source('30'),
intent: 'clear',
}),
);
expect(stale).toMatchObject({
ok: false,
status: 409,
errors: [
{ area: 'identity', message: expect.stringMatching(/changed after/) },
],
});
expect(
await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id),
).toEqual(ownerBefore);
expect(
await (
await database.prepare(
'SELECT talent_id,slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(id),
).toEqual(relationshipBefore);
expect(await sourceFingerprint(id)).toBe(fingerprint);
expect(await count('audit_log')).toBe(auditsBefore);
});
it('rejects a stale aggregate after a revision mutation without writing', async () => {
const created = await save(formFor({ id: '' }));
const id = (created as { talentId: string }).talentId;
const expectedOwnerUpdatedAt = await ownerUpdatedAt(id);
const fingerprint = await sourceFingerprint(id);
const appended = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
intent: 'save',
}),
);
expect(appended).toMatchObject({ ok: true, talentMode: 'edit' });
const ownerBefore = await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id);
const revisionsBefore = await count('mechanic_model_revisions');
const auditsBefore = await count('audit_log');
const stale = await save(
formFor({
id,
expected: fingerprint,
expectedOwnerUpdatedAt,
authored: source('30'),
intent: 'clear',
}),
);
expect(stale).toMatchObject({
ok: false,
status: 409,
errors: [
{ area: 'identity', message: expect.stringMatching(/changed after/) },
],
});
expect(
await (
await database.prepare(
'SELECT name,category,updated_at FROM talents WHERE id=?',
)
).get(id),
).toEqual(ownerBefore);
expect(await count('mechanic_model_revisions')).toBe(revisionsBefore);
expect(await count('audit_log')).toBe(auditsBefore);
expect(await sourceFingerprint(id)).toBe(fingerprint);
});
});
+559
View File
@@ -0,0 +1,559 @@
import { randomUUID } from 'node:crypto';
import type { Database } from '@tursodatabase/database';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconMetadata,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
mechanicAuthoredSourceSnapshot,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
mechanicOwnerStateSnapshot,
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import type { MediaMutationJournal } from '$lib/server/media-journal';
/** Reads and processes the canonical Talent media fields for direct saves/replay. */
export async function readTalentFormMedia(form: FormData) {
const iconMutation = await readIconMutation(form);
const renderMutation = await readGameDataRenderMutation(form);
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
return { iconMutation, renderMutation };
}
type TalentErrorArea =
| 'icon'
| 'identity'
| 'description'
| 'mechanic'
| 'outcomes';
export type TalentValidationError = {
area: TalentErrorArea;
message: string;
};
type TalentCategory =
| 'Weapon Talent 1'
| 'Weapon Talent 2'
| 'Exotic Weapon'
| 'Backpack'
| 'Body Armor';
const talentCategories: readonly TalentCategory[] = [
'Weapon Talent 1',
'Weapon Talent 2',
'Exotic Weapon',
'Backpack',
'Body Armor',
];
type TalentMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
type TalentFormInput = {
form: FormData;
operatorId: string;
mutationContext?: TalentMutationContext;
journal?: MediaMutationJournal;
proposedEntityId?: string;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
type SaveTalentFailure = {
ok: false;
status: number;
errors: TalentValidationError[];
};
type SaveTalentSuccess = {
ok: true;
talentId: string;
talentMode: 'add' | 'edit';
};
export type SaveTalentInTransactionResult =
| SaveTalentFailure
| SaveTalentSuccess;
export type TalentSaveInput = {
id: string;
/** Server-generated draft ID used when replaying a new proposal. */
proposedEntityId?: string;
name: string;
category: string;
authoredSource: MechanicAuthoredSource;
expectedOwnerUpdatedAt?: string | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
tierIIDocumented?: boolean;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
actorId: string;
mutationContext?: TalentMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class TalentNotFoundError extends Error {
constructor() {
super('Talent not found.');
this.name = 'TalentNotFoundError';
}
}
const failure = (
status: number,
errors: TalentValidationError[],
): SaveTalentFailure => ({ ok: false, status, errors });
const renderAuditMetadata = (render: Record<string, unknown> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
async function loadTalentMechanicState(database: Database, talentId: string) {
const authoredSource = await loadMechanicAuthoredSource(database, 'talent', talentId);
if (!authoredSource)
return { authoredSource: null, selectedRevision: null, projection: [] };
const [selectedRevision, projection] = await Promise.all([
loadSelectedMechanicModelRevision(database, authoredSource.id),
loadMechanicPublicProjection(database, authoredSource.id),
]);
return { authoredSource, selectedRevision, projection };
}
export async function talentOwnerAuditSnapshot(database: Database, talentId: string) {
const [record, icon, render, relationships, state] = await Promise.all([
auditRecord('talents', talentId, database),
gameDataIconMetadata(database, 'talent', talentId),
(
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='talent_render' AND source_entity_id=?",
)
).get(talentId),
(async () => ({
weaponScope:
(await (
await database.prepare(
'SELECT scope FROM talent_weapon_scopes WHERE talent_id=?',
)
).get(talentId)) ?? null,
weaponSlot:
(await (
await database.prepare(
'SELECT slot FROM weapon_talent_slots WHERE talent_id=?',
)
).get(talentId)) ?? null,
ownerWeapon:
(await (
await database.prepare(
'SELECT id,name FROM weapons WHERE exotic_talent_id=?',
)
).get(talentId)) ?? null,
gearTalents: await (
await database.prepare(
`SELECT link.gear_id,gear.slot
FROM gear_talents link JOIN gear ON gear.id=link.gear_id
WHERE link.talent_id=? ORDER BY link.gear_id`,
)
).all(talentId),
weaponTalentOptions: await (
await database.prepare(
`SELECT weapon_id,talent_slot
FROM weapon_talent_options WHERE talent_id=?
ORDER BY weapon_id,talent_slot`,
)
).all(talentId),
}))(),
loadTalentMechanicState(database, talentId),
]);
const selectedRevision = mechanicModelRevisionSnapshot(state.selectedRevision);
const publicProjection = state.authoredSource
? mechanicPublicProjectionSnapshot(state.selectedRevision, state.projection)
: null;
return {
record,
relationships,
icon,
render: renderAuditMetadata(render),
authoredSource: mechanicAuthoredSourceSnapshot(state.authoredSource),
selectedRevision,
readiness: state.selectedRevision?.simulationReadiness ?? null,
scenarioInputs: state.selectedRevision?.draft.scenarioInputs ?? [],
graph: state.selectedRevision
? {
pools: state.selectedRevision.draft.pools,
clauses: state.selectedRevision.draft.clauses,
}
: null,
publicProjection,
};
}
/** Persists a Talent in the caller's already-open transaction. */
export async function saveTalentFromFormInTransaction(
database: Database,
input: TalentFormInput,
): Promise<SaveTalentInTransactionResult> {
const id = String(input.form.get('id') ?? '').trim();
const talentMode = id ? 'edit' : 'add';
const existing = id
? ((await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(id)) ?? null)
: null;
if (id && !existing)
return failure(404, [{ area: 'identity', message: 'Talent not found.' }]);
const name = String(input.form.get('name') ?? '').normalize('NFKC').trim();
const category = String(input.form.get('category') ?? '') as TalentCategory;
const isExotic = category === 'Exotic Weapon';
const tierIIDocumented =
!isExotic || String(input.form.get('tierIIDocumented') ?? '') === '1';
const talentId = id || input.proposedEntityId?.trim() || randomUUID();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
input.form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
return failure(400, [{
area: 'identity',
message: error instanceof Error ? error.message : 'The expected owner version is invalid.',
}]);
}
if (id && expectedOwnerUpdatedAt !== String(existing?.updated_at ?? ''))
return failure(409, [{
area: 'identity',
message: 'The Talent changed after this editor was loaded. Reload the latest definition and try again.',
}]);
const owner = {
type: 'talent' as const,
id: talentId,
scope: isExotic ? ('exotic' as const) : ('standard' as const),
...(tierIIDocumented ? { hasTierII: true } : {}),
};
const errors: TalentValidationError[] = [];
if (name.length < 2 || name.length > 80 || !talentCategories.includes(category))
errors.push({ area: 'identity', message: 'Provide a valid talent name and category.' });
let authoredSource;
let expectedSourceFingerprint: string | null;
try {
authoredSource = parseMechanicAuthoredSourcePayload(
input.form.get('authoredSource'),
owner,
);
expectedSourceFingerprint = parseExpectedSourceFingerprint(
input.form.get('expectedSourceFingerprint'),
);
} catch (error) {
return failure(400, [{
area: 'description',
message: error instanceof Error ? error.message : 'The authored description is invalid.',
}]);
}
const activeStats = await (
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
).all();
const activeIds = new Set(activeStats.map((row) => String(row.id)));
const authoredAttributeIds = [
...new Set(
authoredSource.variants.flatMap((variant) =>
variant.slots
.map((slot) => slot.attributeId)
.filter((value): value is string => Boolean(value)),
),
),
];
if (authoredAttributeIds.some((attributeId) => !activeIds.has(attributeId)))
errors.push({
area: 'outcomes',
message: 'One or more authored Attributes are unavailable.',
});
let selection: MechanicOwnerSelection;
try {
selection = parseMechanicSelectionIntent(
input.form.get('mechanicSelectionIntent'),
input.form.get('conditionalRule'),
{
variantKeys: authoredSource.variants.map((variant) => variant.key),
allowedAttributeIds: activeIds,
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: name || 'Talent mechanic',
},
);
} catch (error) {
return failure(400, [{
area: 'mechanic',
message: error instanceof Error ? error.message : 'The mechanic selection is invalid.',
}]);
}
const ownerWeapon = id
? await (await database.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(id)
: null;
if (ownerWeapon && !isExotic)
errors.push({
area: 'identity',
message: `This talent is owned by ${String(ownerWeapon.name)} and must remain an Exotic Weapon talent.`,
});
if (id) {
const [gearLinks, weaponLinks] = await Promise.all([
(
await database.prepare(
`SELECT link.gear_id,gear.slot
FROM gear_talents link JOIN gear ON gear.id=link.gear_id
WHERE link.talent_id=?`,
)
).all(id),
(
await database.prepare(
'SELECT weapon_id,talent_slot FROM weapon_talent_options WHERE talent_id=?',
)
).all(id),
]);
const expectedGearSlots =
category === 'Backpack'
? new Set(['Backpack'])
: category === 'Body Armor'
? new Set(['Body Armor', 'Vest'])
: null;
if (expectedGearSlots === null && gearLinks.length) {
errors.push({
area: 'identity',
message: 'This talent is assigned to gear and must remain a gear talent.',
});
} else if (
expectedGearSlots &&
gearLinks.some((link) => !expectedGearSlots.has(String(link.slot)))
) {
errors.push({
area: 'identity',
message: 'This talent is assigned to a different gear slot.',
});
}
const expectedWeaponSlot =
category === 'Weapon Talent 1'
? 1
: category === 'Weapon Talent 2'
? 2
: null;
if (expectedWeaponSlot === null && weaponLinks.length) {
errors.push({
area: 'identity',
message: 'This talent is assigned to weapons and must remain a standard weapon talent.',
});
} else if (
expectedWeaponSlot !== null &&
weaponLinks.some((link) => Number(link.talent_slot) !== expectedWeaponSlot)
) {
errors.push({
area: 'identity',
message: 'This talent is assigned to a different weapon talent slot.',
});
}
}
if (errors.length) return failure(400, errors);
const currentSource = await loadMechanicAuthoredSource(database, 'talent', talentId);
if (expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
return failure(409, [{
area: 'description',
message: 'The authored mechanic changed after this editor was loaded.',
}]);
const storedCategory =
category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon') || isExotic ? 'Weapon' : category;
if (
await (
await database.prepare(
'SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?',
)
).get(name, storedCategory, id || 'new')
)
return failure(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
const now = new Date().toISOString();
const before = id ? await talentOwnerAuditSnapshot(database, talentId) : null;
if (id) {
await (
await database.prepare(
'UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?',
)
).run(name, storedCategory, now, talentId);
} else {
await (
await database.prepare(
'INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(talentId, name, storedCategory, now, now);
}
if (storedCategory === 'Weapon') {
await (
await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)
).run(talentId, isExotic ? 'Exotic' : 'Standard');
if (isExotic)
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
else
await (
await database.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)
).run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
} else {
await (await database.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
}
const labels = new Map(activeStats.map((stat) => [String(stat.id), String(stat.name)]));
const persisted = await persistMechanicOwnerStateInTransaction(database, {
source: authoredSource,
expectedSourceFingerprint,
selection,
programName: name || 'Talent mechanic',
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.operatorId,
resolveAttributeLabel: input.resolveAttributeLabel ?? ((id) => labels.get(id) ?? null),
});
await applyIconMutation(
database,
'talent',
talentId,
input.iconMutation,
input.operatorId,
input.journal,
);
await applyGameDataRender(
database,
'talent',
talentId,
name,
input.renderMutation,
input.operatorId,
input.journal,
);
const after = await talentOwnerAuditSnapshot(database, talentId);
await recordIsacAudit({
actorId: input.operatorId,
category: 'game_data',
action: id ? 'talent.updated' : 'talent.created',
entityId: talentId,
before,
after: {
...after,
category,
tierIIDocumented,
ownerState: mechanicOwnerStateSnapshot(persisted),
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { ok: true, talentId, talentMode };
}
/**
* Replay-safe canonical callable. The supplied database must be the active
* transaction owned by the caller. Route uploads and permission checks stay
* at the route boundary, while this callable shares the exact save path.
*/
export async function saveTalentInTransaction(
database: Database,
input: TalentSaveInput,
): Promise<{ talentId: string; mode: 'add' | 'edit' }> {
const form = new FormData();
form.set('id', input.id);
form.set('name', input.name);
form.set('category', input.category);
const tierIIDocumented =
input.tierIIDocumented ??
(input.category !== 'Exotic Weapon' ||
(input.authoredSource.owner.type === 'talent' &&
input.authoredSource.owner.hasTierII === true) ||
input.authoredSource.variants.some((variant) => variant.key === 'II'));
form.set('tierIIDocumented', tierIIDocumented ? '1' : '0');
form.set('authoredSource', JSON.stringify({
version: input.authoredSource.version,
template: input.authoredSource.template,
variants: input.authoredSource.variants,
}));
if (input.expectedOwnerUpdatedAt)
form.set('expectedOwnerUpdatedAt', input.expectedOwnerUpdatedAt);
if (input.expectedSourceFingerprint)
form.set('expectedSourceFingerprint', input.expectedSourceFingerprint);
form.set('mechanicSelectionIntent', input.selection.intent);
if (input.selection.intent === 'save')
form.set('conditionalRule', JSON.stringify(input.selection.draft));
const result = await saveTalentFromFormInTransaction(database, {
form,
operatorId: input.actorId,
proposedEntityId: input.proposedEntityId,
iconMutation: input.iconMutation,
renderMutation: input.renderMutation,
mutationContext: input.mutationContext,
journal: input.journal,
resolveAttributeLabel: input.resolveAttributeLabel,
});
if (result.ok) return { talentId: result.talentId, mode: result.talentMode };
if (result.status === 404) throw new TalentNotFoundError();
if (
result.status === 409 &&
input.id &&
result.errors[0]?.area === 'description'
) {
const current = await loadMechanicAuthoredSource(database, 'talent', input.id);
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
current?.fingerprint ?? null,
);
}
throw new Error(result.errors[0]?.message ?? 'The talent could not be saved.');
}
@@ -5,6 +5,7 @@ import {
} from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
getPagination,
@@ -429,6 +430,7 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const journal = new MediaMutationJournal();
await database.exec('BEGIN IMMEDIATE');
try {
const beforeIcon = await gameDataIconMetadata(
@@ -439,6 +441,7 @@ export const actions: Actions = {
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
await database.exec('ROLLBACK');
journal.clear();
return fail(409, {
weaponModSlotIconError:
'This slot icon changed after the editor opened. Review the latest icon and try again.',
@@ -446,6 +449,7 @@ export const actions: Actions = {
}
if (mutation.remove && !beforeIcon) {
await database.exec('ROLLBACK');
journal.clear();
return fail(400, {
weaponModSlotIconError: 'This slot does not have an icon to remove.',
});
@@ -456,6 +460,7 @@ export const actions: Actions = {
definition.id,
mutation,
operator.id,
journal,
);
const afterIcon = await gameDataIconMetadata(
database,
@@ -472,8 +477,10 @@ export const actions: Actions = {
database,
});
await database.exec('COMMIT');
journal.clear();
} catch (error) {
await database.exec('ROLLBACK');
await journal.rollback(error);
throw error;
}
return {
@@ -100,6 +100,7 @@ describe('Weapon Mod shared slot icon action', () => {
definition.id,
{ output: Buffer.from('icon'), remove: false },
'operator-1',
expect.anything(),
);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',