feat: persist authored mechanic revisions
This commit is contained in:
@@ -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