feat: persist authored mechanic revisions
This commit is contained in:
@@ -926,6 +926,266 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx
|
||||
ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order);
|
||||
-- Phase 2 mechanic persistence. Revision rows are append-only by application
|
||||
-- convention. No database triggers enforce immutability.
|
||||
CREATE TABLE IF NOT EXISTS mechanic_authored_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_type TEXT NOT NULL CHECK(owner_type IN (
|
||||
'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank'
|
||||
)),
|
||||
owner_id TEXT NOT NULL CHECK(length(trim(owner_id))>0),
|
||||
template TEXT NOT NULL CHECK(length(trim(template))>0),
|
||||
authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0),
|
||||
source_fingerprint TEXT NOT NULL CHECK(
|
||||
length(source_fingerprint)=64
|
||||
AND lower(source_fingerprint)=source_fingerprint
|
||||
AND source_fingerprint NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
talent_scope TEXT CHECK(talent_scope IS NULL OR talent_scope IN ('standard','exotic')),
|
||||
talent_has_tier_ii INTEGER CHECK(talent_has_tier_ii IS NULL OR talent_has_tier_ii IN (0,1)),
|
||||
specialization_rank_description TEXT,
|
||||
specialization_effect_id TEXT,
|
||||
specialization_effect_template TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(owner_type,owner_id),
|
||||
CHECK(
|
||||
(owner_type='talent'
|
||||
AND talent_scope IS NOT NULL
|
||||
AND specialization_rank_description IS NULL
|
||||
AND specialization_effect_id IS NULL
|
||||
AND specialization_effect_template IS NULL)
|
||||
OR
|
||||
(owner_type='specialization_tree_rank'
|
||||
AND talent_scope IS NULL
|
||||
AND talent_has_tier_ii IS NULL
|
||||
AND specialization_rank_description IS NOT NULL
|
||||
AND length(trim(specialization_rank_description))>0
|
||||
AND specialization_effect_id IS NOT NULL
|
||||
AND length(trim(specialization_effect_id))>0
|
||||
AND specialization_effect_template IS NOT NULL
|
||||
AND length(trim(specialization_effect_template))>0)
|
||||
OR
|
||||
(owner_type IN ('os_protocol_talent','skill_chip_talent')
|
||||
AND talent_scope IS NULL
|
||||
AND talent_has_tier_ii IS NULL
|
||||
AND specialization_rank_description IS NULL
|
||||
AND specialization_effect_id IS NULL
|
||||
AND specialization_effect_template IS NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_authored_sources_owner_idx
|
||||
ON mechanic_authored_sources(owner_type,owner_id);
|
||||
CREATE TABLE IF NOT EXISTS mechanic_authored_variants (
|
||||
source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
|
||||
variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0),
|
||||
materialized TEXT NOT NULL CHECK(length(materialized)>0),
|
||||
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
|
||||
PRIMARY KEY(source_id,variant_key),
|
||||
UNIQUE(source_id,sort_order)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_authored_variants_source_order_idx
|
||||
ON mechanic_authored_variants(source_id,sort_order);
|
||||
CREATE TABLE IF NOT EXISTS mechanic_authored_slots (
|
||||
source_id TEXT NOT NULL,
|
||||
variant_key TEXT NOT NULL,
|
||||
slot_key TEXT NOT NULL CHECK(length(trim(slot_key))>0),
|
||||
display_value TEXT NOT NULL CHECK(
|
||||
display_value=trim(display_value)
|
||||
AND display_value GLOB '*[0-9]*'
|
||||
AND display_value NOT GLOB '*[^0-9.+-]*'
|
||||
AND substr(display_value,2) NOT GLOB '*[+-]*'
|
||||
AND length(display_value)-length(replace(display_value,'.',''))<=1
|
||||
AND substr(display_value,-1)<>'.'
|
||||
AND display_value NOT IN ('+','-','.','+.','-.')
|
||||
),
|
||||
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
|
||||
unit TEXT,
|
||||
stat_attribute_id TEXT REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(source_id,variant_key,slot_key),
|
||||
UNIQUE(source_id,variant_key,sort_order),
|
||||
FOREIGN KEY(source_id,variant_key)
|
||||
REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_authored_slots_variant_order_idx
|
||||
ON mechanic_authored_slots(source_id,variant_key,sort_order);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_authored_slots_attribute_idx
|
||||
ON mechanic_authored_slots(stat_attribute_id)
|
||||
WHERE stat_attribute_id IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS mechanic_model_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
|
||||
revision_number INTEGER NOT NULL CHECK(revision_number>0),
|
||||
source_fingerprint TEXT NOT NULL CHECK(
|
||||
length(source_fingerprint)=64
|
||||
AND lower(source_fingerprint)=source_fingerprint
|
||||
AND source_fingerprint NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
mechanic_contract_version INTEGER NOT NULL CHECK(mechanic_contract_version>0),
|
||||
program_hash TEXT NOT NULL CHECK(
|
||||
length(program_hash)=64
|
||||
AND lower(program_hash)=program_hash
|
||||
AND program_hash NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
simulation_readiness TEXT NOT NULL CHECK(simulation_readiness IN ('ready','requires_assumptions','captured_only')),
|
||||
scenario_inputs_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(scenario_inputs_json)),
|
||||
source_variants_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(source_variants_json)),
|
||||
program_name TEXT NOT NULL CHECK(length(trim(program_name))>0),
|
||||
origin TEXT NOT NULL CHECK(length(trim(origin))>0),
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(source_id,revision_number),
|
||||
UNIQUE(source_id,id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revisions_source_created_idx
|
||||
ON mechanic_model_revisions(source_id,revision_number DESC,created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revisions_fingerprint_idx
|
||||
ON mechanic_model_revisions(source_id,source_fingerprint);
|
||||
CREATE TABLE IF NOT EXISTS mechanic_model_revision_pools (
|
||||
id TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
|
||||
pool_key TEXT NOT NULL CHECK(length(trim(pool_key))>0),
|
||||
label TEXT NOT NULL CHECK(length(trim(label))>0),
|
||||
state_kind TEXT NOT NULL CHECK(state_kind IN ('flag','count','stack','duration','scalar','target_mark','resource')),
|
||||
state_scope TEXT NOT NULL CHECK(state_scope IN ('agent','target')),
|
||||
initial_value REAL NOT NULL,
|
||||
minimum_value REAL,
|
||||
maximum_value REAL,
|
||||
best_case_value REAL,
|
||||
duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0),
|
||||
duration_variants_json TEXT CHECK(duration_variants_json IS NULL OR json_valid(duration_variants_json)),
|
||||
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
|
||||
UNIQUE(revision_id,id),
|
||||
UNIQUE(revision_id,pool_key),
|
||||
UNIQUE(revision_id,sort_order),
|
||||
CHECK(minimum_value IS NULL OR maximum_value IS NULL OR maximum_value>=minimum_value),
|
||||
CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value),
|
||||
CHECK(best_case_value IS NULL OR maximum_value IS NULL OR best_case_value<=maximum_value),
|
||||
CHECK(NOT (duration_seconds IS NOT NULL AND duration_variants_json IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revision_pools_revision_order_idx
|
||||
ON mechanic_model_revision_pools(revision_id,sort_order);
|
||||
CREATE TABLE IF NOT EXISTS mechanic_model_revision_clauses (
|
||||
id TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
|
||||
clause_key TEXT NOT NULL CHECK(length(trim(clause_key))>0),
|
||||
label TEXT,
|
||||
event_kind TEXT NOT NULL CHECK(event_kind IN (
|
||||
'shot_fired','weapon_hit','skill_deployed','signature_ability_deployed',
|
||||
'skill_hit','skill_multishot_triggered','agent_hit','health_damage_taken',
|
||||
'damage_dealt','enemy_killed','assist','ally_downed','agent_downed',
|
||||
'ally_rescued','grenade_thrown','medical_kit_used','healing_applied',
|
||||
'extra_health_granted','extra_health_changed','movement_snapshot',
|
||||
'proximity_snapshot','firing_started','firing_stopped','reload_started',
|
||||
'reload_completed','weapon_swapped','tick'
|
||||
)),
|
||||
recipe_key TEXT,
|
||||
conditions_json TEXT CHECK(conditions_json IS NULL OR json_valid(conditions_json)),
|
||||
reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}' CHECK(json_valid(reducer_json)),
|
||||
weight_expression_json TEXT CHECK(weight_expression_json IS NULL OR json_valid(weight_expression_json)),
|
||||
mutations_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(mutations_json)),
|
||||
cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0),
|
||||
proc_chance_percent REAL CHECK(proc_chance_percent IS NULL OR (proc_chance_percent>=0 AND proc_chance_percent<=100)),
|
||||
requires_assigned_ability_active INTEGER CHECK(requires_assigned_ability_active IS NULL OR requires_assigned_ability_active IN (0,1)),
|
||||
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
|
||||
UNIQUE(revision_id,id),
|
||||
UNIQUE(revision_id,clause_key),
|
||||
UNIQUE(revision_id,sort_order)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_revision_order_idx
|
||||
ON mechanic_model_revision_clauses(revision_id,sort_order);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_event_idx
|
||||
ON mechanic_model_revision_clauses(event_kind,revision_id,sort_order);
|
||||
CREATE TABLE IF NOT EXISTS mechanic_model_revision_outcomes (
|
||||
id TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
|
||||
clause_id TEXT NOT NULL,
|
||||
state_pool_id TEXT,
|
||||
effect_key TEXT NOT NULL CHECK(length(trim(effect_key))>0),
|
||||
variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0),
|
||||
label TEXT NOT NULL CHECK(length(trim(label))>0),
|
||||
outcome_kind TEXT NOT NULL CHECK(outcome_kind IN ('attribute_modifier','direct_damage','restoration','resource_cost','weapon_reload','marker')),
|
||||
effect_scope TEXT NOT NULL CHECK(effect_scope IN ('self','equipped_weapon','inactive_weapon','target','allies','self_and_allies','assigned_ability','all_skills')),
|
||||
value REAL NOT NULL,
|
||||
value_expression_json TEXT CHECK(value_expression_json IS NULL OR json_valid(value_expression_json)),
|
||||
value_basis TEXT NOT NULL CHECK(value_basis IN ('flat','max_health_percent','current_health_percent','weapon_base_percent')),
|
||||
resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo','signature_ability_charge')),
|
||||
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
|
||||
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
||||
application_phase TEXT NOT NULL CHECK(application_phase IN ('on_event','after_event','on_enter','on_exit','while_active')),
|
||||
combat_timing TEXT CHECK(combat_timing IS NULL OR combat_timing IN ('pre_event','current')),
|
||||
stat_attribute_id TEXT REFERENCES stat_attributes(id),
|
||||
per_stack INTEGER CHECK(per_stack IS NULL OR per_stack IN (0,1)),
|
||||
min_state_value REAL CHECK(min_state_value IS NULL OR min_state_value>=0),
|
||||
max_state_value REAL CHECK(max_state_value IS NULL OR max_state_value>=0),
|
||||
targeting_json TEXT CHECK(targeting_json IS NULL OR json_valid(targeting_json)),
|
||||
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
|
||||
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
|
||||
UNIQUE(revision_id,id),
|
||||
UNIQUE(revision_id,clause_id,variant_key,effect_key),
|
||||
UNIQUE(revision_id,clause_id,variant_key,sort_order),
|
||||
CHECK(max_state_value IS NULL OR min_state_value IS NULL OR max_state_value>=min_state_value),
|
||||
CHECK(
|
||||
(delivery_mode='instant' AND delivery_duration_seconds IS NULL)
|
||||
OR (delivery_mode='continuous' AND outcome_kind='restoration' AND delivery_duration_seconds IS NOT NULL)
|
||||
),
|
||||
CHECK(
|
||||
(outcome_kind='attribute_modifier' AND stat_attribute_id IS NOT NULL AND resource_kind IS NULL)
|
||||
OR (outcome_kind IN ('restoration','resource_cost') AND stat_attribute_id IS NULL AND resource_kind IS NOT NULL)
|
||||
OR (outcome_kind IN ('direct_damage','weapon_reload','marker') AND stat_attribute_id IS NULL AND resource_kind IS NULL)
|
||||
),
|
||||
FOREIGN KEY(revision_id,clause_id)
|
||||
REFERENCES mechanic_model_revision_clauses(revision_id,id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(revision_id,state_pool_id)
|
||||
REFERENCES mechanic_model_revision_pools(revision_id,id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_clause_order_idx
|
||||
ON mechanic_model_revision_outcomes(revision_id,clause_id,variant_key,sort_order);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_pool_idx
|
||||
ON mechanic_model_revision_outcomes(revision_id,state_pool_id)
|
||||
WHERE state_pool_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_attribute_idx
|
||||
ON mechanic_model_revision_outcomes(stat_attribute_id)
|
||||
WHERE stat_attribute_id IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS mechanic_model_selections (
|
||||
source_id TEXT PRIMARY KEY REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
|
||||
revision_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(source_id,revision_id)
|
||||
REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_model_selections_revision_idx
|
||||
ON mechanic_model_selections(revision_id,source_id);
|
||||
CREATE TABLE IF NOT EXISTS mechanic_public_variants (
|
||||
source_id TEXT NOT NULL,
|
||||
variant_key TEXT NOT NULL,
|
||||
selected_revision_id TEXT,
|
||||
current_source_fingerprint TEXT NOT NULL CHECK(
|
||||
length(current_source_fingerprint)=64
|
||||
AND lower(current_source_fingerprint)=current_source_fingerprint
|
||||
AND current_source_fingerprint NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0),
|
||||
mechanic_contract_version INTEGER CHECK(mechanic_contract_version IS NULL OR mechanic_contract_version>0),
|
||||
status TEXT NOT NULL CHECK(status IN ('modeled','not_modeled')),
|
||||
behavior_text TEXT NOT NULL CHECK(length(behavior_text)>0),
|
||||
display_source TEXT NOT NULL CHECK(display_source IN ('model','authored')),
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(source_id,variant_key),
|
||||
CHECK(
|
||||
(display_source='model' AND status='modeled' AND selected_revision_id IS NOT NULL)
|
||||
OR (display_source='authored' AND status='not_modeled')
|
||||
),
|
||||
FOREIGN KEY(source_id,variant_key)
|
||||
REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE,
|
||||
FOREIGN KEY(source_id,selected_revision_id)
|
||||
REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_public_variants_status_idx
|
||||
ON mechanic_public_variants(status,source_id,variant_key);
|
||||
CREATE INDEX IF NOT EXISTS mechanic_public_variants_revision_idx
|
||||
ON mechanic_public_variants(selected_revision_id)
|
||||
WHERE selected_revision_id IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS talent_tier_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
let database: Database;
|
||||
|
||||
const applySchema = async (target: Database) => {
|
||||
const schema = await readFile(new URL('./schema.sql', import.meta.url), 'utf8');
|
||||
for (const statement of schema
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)) {
|
||||
await target.exec(statement);
|
||||
}
|
||||
};
|
||||
|
||||
const insertSource = async (
|
||||
target: Database,
|
||||
id: string,
|
||||
ownerId: string,
|
||||
fingerprint: string,
|
||||
) => {
|
||||
await (
|
||||
await target.prepare(
|
||||
`INSERT INTO mechanic_authored_sources(
|
||||
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
|
||||
talent_scope,talent_has_tier_ii,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
id,
|
||||
'talent',
|
||||
ownerId,
|
||||
'Gain {1}',
|
||||
1,
|
||||
fingerprint,
|
||||
'standard',
|
||||
1,
|
||||
'2026-08-27T00:00:00.000Z',
|
||||
'2026-08-27T00:00:00.000Z',
|
||||
);
|
||||
};
|
||||
|
||||
describe('mechanic persistence schema', () => {
|
||||
beforeEach(async () => {
|
||||
database = await connect(':memory:');
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
await applySchema(database);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it('installs the complete schema with clean foreign keys', async () => {
|
||||
const rows = await (
|
||||
await database.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'mechanic_%' ORDER BY name",
|
||||
)
|
||||
).all();
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual([
|
||||
'mechanic_authored_slots',
|
||||
'mechanic_authored_sources',
|
||||
'mechanic_authored_variants',
|
||||
'mechanic_model_revision_clauses',
|
||||
'mechanic_model_revision_outcomes',
|
||||
'mechanic_model_revision_pools',
|
||||
'mechanic_model_revisions',
|
||||
'mechanic_model_selections',
|
||||
'mechanic_public_variants',
|
||||
]);
|
||||
expect(await (await database.prepare('PRAGMA foreign_key_check')).all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces owner metadata, ordered authored children, and source cascades', async () => {
|
||||
const fingerprint = 'a'.repeat(64);
|
||||
await insertSource(database, 'source-1', 'talent-1', fingerprint);
|
||||
|
||||
await expect(
|
||||
insertSource(database, 'source-2', 'talent-1', fingerprint),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
database.exec(
|
||||
`INSERT INTO mechanic_authored_sources(
|
||||
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
|
||||
specialization_rank_description,specialization_effect_id,specialization_effect_template,
|
||||
created_at,updated_at
|
||||
) VALUES('source-3','talent','talent-3','Gain',1,'${fingerprint}',
|
||||
'rank','effect','template','now','now')`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
|
||||
VALUES('source-1','I','Gain 1',0),('source-1','II','Gain 2',1)`,
|
||||
)
|
||||
).run();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_authored_slots(
|
||||
source_id,variant_key,slot_key,display_value,sort_order
|
||||
) VALUES('source-1','I','1','1',0)`,
|
||||
)
|
||||
).run();
|
||||
|
||||
await expect(
|
||||
database.exec(
|
||||
`INSERT INTO mechanic_authored_slots(
|
||||
source_id,variant_key,slot_key,display_value,sort_order
|
||||
) VALUES('source-1','missing','1','1',0)`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
database.exec(
|
||||
`INSERT INTO mechanic_authored_slots(
|
||||
source_id,variant_key,slot_key,display_value,sort_order
|
||||
) VALUES('source-1','II','1','10% Weapon Damage',0)`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
|
||||
for (const table of [
|
||||
'mechanic_authored_variants',
|
||||
'mechanic_authored_slots',
|
||||
]) {
|
||||
expect(
|
||||
await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
|
||||
).get(),
|
||||
).toMatchObject({ count: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows deliberate duplicate graphs while proving selection ownership', async () => {
|
||||
const sourceFingerprint = 'a'.repeat(64);
|
||||
const programHash = 'b'.repeat(64);
|
||||
await insertSource(database, 'source-1', 'talent-1', sourceFingerprint);
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
|
||||
VALUES('source-1','I','Gain 1',0)`,
|
||||
)
|
||||
).run();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_model_revisions(
|
||||
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
|
||||
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
||||
program_name,origin,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
'revision-1',
|
||||
'source-1',
|
||||
1,
|
||||
sourceFingerprint,
|
||||
1,
|
||||
programHash,
|
||||
'ready',
|
||||
'[]',
|
||||
'["I"]',
|
||||
'Model',
|
||||
'manual',
|
||||
'now',
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_model_revisions(
|
||||
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
|
||||
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
||||
program_name,origin,created_at
|
||||
) SELECT 'revision-2',source_id,2,source_fingerprint,mechanic_contract_version,
|
||||
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
||||
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
|
||||
)
|
||||
).run();
|
||||
|
||||
await expect(
|
||||
database.exec(
|
||||
`INSERT INTO mechanic_model_revisions(
|
||||
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
|
||||
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
||||
program_name,origin,created_at
|
||||
) SELECT 'revision-3',source_id,1,source_fingerprint,mechanic_contract_version,
|
||||
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
|
||||
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
|
||||
VALUES('source-1','revision-2','now')`,
|
||||
)
|
||||
).run();
|
||||
await expect(
|
||||
database.exec(
|
||||
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
|
||||
VALUES('source-1','not-this-source','now')`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_model_revision_pools(
|
||||
id,revision_id,pool_key,label,state_kind,state_scope,initial_value,
|
||||
minimum_value,maximum_value,best_case_value,duration_seconds,
|
||||
duration_variants_json,sort_order
|
||||
) VALUES('pool-1','revision-1','stacks','Stacks','stack','agent',0,0,5,5,NULL,NULL,0)`,
|
||||
)
|
||||
).run();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_model_revision_clauses(
|
||||
id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json,
|
||||
reducer_json,weight_expression_json,mutations_json,cooldown_seconds,
|
||||
proc_chance_percent,requires_assigned_ability_active,sort_order
|
||||
) VALUES('clause-1','revision-1','on-hit','On hit','weapon_hit',NULL,NULL,
|
||||
'{"kind":"none"}',NULL,'[]',NULL,25,NULL,0)`,
|
||||
)
|
||||
).run();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_model_revision_outcomes(
|
||||
id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label,
|
||||
outcome_kind,effect_scope,value,value_expression_json,value_basis,
|
||||
resource_kind,delivery_mode,delivery_duration_seconds,application_phase,
|
||||
combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value,
|
||||
targeting_json,probability_percent,sort_order
|
||||
) VALUES('outcome-1','revision-1','clause-1','pool-1','mark','I','Mark',
|
||||
'marker','self',1,NULL,'flat',NULL,'instant',NULL,'while_active','current',
|
||||
NULL,0,NULL,NULL,NULL,NULL,0)`,
|
||||
)
|
||||
).run();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mechanic_public_variants(
|
||||
source_id,variant_key,selected_revision_id,current_source_fingerprint,
|
||||
authored_contract_version,mechanic_contract_version,status,behavior_text,
|
||||
display_source,updated_at
|
||||
) VALUES('source-1','I','revision-1',?,1,1,'modeled','Modeled behavior','model','now')`,
|
||||
)
|
||||
).run(sourceFingerprint);
|
||||
|
||||
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
|
||||
for (const table of [
|
||||
'mechanic_authored_variants',
|
||||
'mechanic_authored_slots',
|
||||
'mechanic_model_revisions',
|
||||
'mechanic_model_revision_pools',
|
||||
'mechanic_model_revision_clauses',
|
||||
'mechanic_model_revision_outcomes',
|
||||
'mechanic_model_selections',
|
||||
'mechanic_public_variants',
|
||||
]) {
|
||||
expect(
|
||||
await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
|
||||
).get(),
|
||||
).toMatchObject({ count: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user