Files
story_builder/db.js
T

766 lines
28 KiB
JavaScript
Raw Normal View History

2026-06-30 09:10:05 +10:00
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const DATA_DIR = path.join(__dirname, 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
const db = new Database(path.join(DATA_DIR, 'story-tool.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS stat_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
str INTEGER NOT NULL DEFAULT 0,
con INTEGER NOT NULL DEFAULT 0,
siz INTEGER NOT NULL DEFAULT 0,
int INTEGER NOT NULL DEFAULT 0,
pow INTEGER NOT NULL DEFAULT 0,
dex INTEGER NOT NULL DEFAULT 0,
app INTEGER NOT NULL DEFAULT 0,
max_hp INTEGER NOT NULL DEFAULT 0,
current_hp INTEGER NOT NULL DEFAULT 0,
move INTEGER NOT NULL DEFAULT 8,
magic_points_max INTEGER NOT NULL DEFAULT 0,
magic_points_current INTEGER NOT NULL DEFAULT 0,
culture TEXT
);
CREATE TABLE IF NOT EXISTS hit_locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stat_block_id INTEGER NOT NULL REFERENCES stat_blocks(id) ON DELETE CASCADE,
location_name TEXT NOT NULL,
max_hp INTEGER NOT NULL,
current_hp INTEGER NOT NULL,
armor_ap INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_hit_locations_stat_block ON hit_locations(stat_block_id);
CREATE TABLE IF NOT EXISTS combatant_weapons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stat_block_id INTEGER NOT NULL REFERENCES stat_blocks(id) ON DELETE CASCADE,
weapon_name TEXT NOT NULL,
category TEXT,
skill_percent INTEGER NOT NULL DEFAULT 0,
mode TEXT,
experience_checked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_combatant_weapons_stat_block ON combatant_weapons(stat_block_id);
CREATE TABLE IF NOT EXISTS spell_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
custom_name TEXT NOT NULL UNIQUE,
mechanic_id TEXT NOT NULL,
default_mp_cost INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS combatant_spells (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stat_block_id INTEGER NOT NULL REFERENCES stat_blocks(id) ON DELETE CASCADE,
spell_mapping_id INTEGER NOT NULL REFERENCES spell_mappings(id),
mp_cost_override INTEGER
);
CREATE INDEX IF NOT EXISTS idx_combatant_spells_stat_block ON combatant_spells(stat_block_id);
CREATE TABLE IF NOT EXISTS npcs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
npc_type TEXT NOT NULL DEFAULT 'full',
first_name TEXT,
last_name TEXT,
brief_description TEXT,
wants_needs TEXT,
secret_obstacle TEXT,
also_carrying TEXT,
race TEXT,
pronouns TEXT,
age TEXT,
intelligence TEXT,
hair TEXT,
build TEXT,
status TEXT NOT NULL DEFAULT 'active',
stat_block_id INTEGER REFERENCES stat_blocks(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS enemies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT,
status TEXT NOT NULL DEFAULT 'active',
notes TEXT,
stat_block_id INTEGER NOT NULL REFERENCES stat_blocks(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES player_characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
enc REAL NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'equipment',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_inventory_character ON inventory_items(character_id);
CREATE TABLE IF NOT EXISTS adventure_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
character_id INTEGER REFERENCES player_characters(id),
scene_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS player_characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
generation_method TEXT NOT NULL DEFAULT 'random',
stat_block_id INTEGER NOT NULL REFERENCES stat_blocks(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
2026-06-30 09:10:05 +10:00
CREATE TABLE IF NOT EXISTS log_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
summary TEXT NOT NULL,
details_json TEXT,
session_date TEXT NOT NULL DEFAULT (date('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_log_entries_session_date ON log_entries(session_date);
CREATE TABLE IF NOT EXISTS combat_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tree_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_file TEXT NOT NULL,
parent_id INTEGER REFERENCES tree_nodes(id) ON DELETE CASCADE,
heading_text TEXT NOT NULL,
heading_level INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tree_nodes_parent ON tree_nodes(parent_id);
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tree_node_id INTEGER NOT NULL UNIQUE REFERENCES tree_nodes(id) ON DELETE CASCADE,
dice_notation TEXT,
table_type TEXT NOT NULL DEFAULT 'single',
npc_role TEXT
);
CREATE TABLE IF NOT EXISTS table_columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id) ON DELETE CASCADE,
column_name TEXT NOT NULL,
column_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_table_columns_table ON table_columns(table_id);
CREATE TABLE IF NOT EXISTS table_rows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id) ON DELETE CASCADE,
roll_min INTEGER NOT NULL,
roll_max INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
cells_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_table_rows_table ON table_rows(table_id);
CREATE INDEX IF NOT EXISTS idx_table_rows_range ON table_rows(table_id, roll_min, roll_max);
CREATE TABLE IF NOT EXISTS table_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id) ON DELETE CASCADE,
row_id INTEGER NOT NULL REFERENCES table_rows(id) ON DELETE CASCADE,
target_table_id INTEGER NOT NULL REFERENCES tables(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_table_links_row ON table_links(row_id);
`);
// ---------- stat blocks (shared by npcs and enemies) ----------
function createStatBlock(data) {
const stmt = db.prepare(`
INSERT INTO stat_blocks (str, con, siz, int, pow, dex, app, max_hp, current_hp, move, magic_points_max, magic_points_current, culture)
VALUES (@str, @con, @siz, @int, @pow, @dex, @app, @max_hp, @current_hp, @move, @magic_points_max, @magic_points_current, @culture)
`);
const info = stmt.run({
str: 0, con: 0, siz: 0, int: 0, pow: 0, dex: 0, app: 0,
max_hp: 0, current_hp: 0, move: 8, magic_points_max: 0, magic_points_current: 0, culture: null,
...data,
});
return getStatBlock(info.lastInsertRowid);
}
function getStatBlock(id) {
const block = db.prepare('SELECT * FROM stat_blocks WHERE id = ?').get(id);
if (!block) return null;
block.hit_locations = db.prepare('SELECT * FROM hit_locations WHERE stat_block_id = ?').all(id);
block.weapons = db.prepare('SELECT * FROM combatant_weapons WHERE stat_block_id = ?').all(id);
block.spells = db.prepare(`
SELECT cs.id, cs.mp_cost_override, sm.custom_name, sm.mechanic_id, sm.default_mp_cost
FROM combatant_spells cs JOIN spell_mappings sm ON sm.id = cs.spell_mapping_id
WHERE cs.stat_block_id = ?
`).all(id);
return block;
}
function updateStatBlock(id, data) {
const current = db.prepare('SELECT * FROM stat_blocks WHERE id = ?').get(id);
if (!current) return null;
const merged = { ...current, ...data, id };
db.prepare(`
UPDATE stat_blocks SET str=@str, con=@con, siz=@siz, int=@int, pow=@pow, dex=@dex, app=@app,
max_hp=@max_hp, current_hp=@current_hp, move=@move,
magic_points_max=@magic_points_max, magic_points_current=@magic_points_current, culture=@culture
WHERE id=@id
`).run(merged);
return getStatBlock(id);
}
function deleteStatBlock(id) {
db.prepare('DELETE FROM stat_blocks WHERE id = ?').run(id);
}
function setHitLocations(statBlockId, locations) {
db.prepare('DELETE FROM hit_locations WHERE stat_block_id = ?').run(statBlockId);
const stmt = db.prepare(`
INSERT INTO hit_locations (stat_block_id, location_name, max_hp, current_hp, armor_ap, disabled)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const loc of locations) {
stmt.run(statBlockId, loc.location_name, loc.max_hp, loc.current_hp ?? loc.max_hp, loc.armor_ap ?? 0, loc.disabled ? 1 : 0);
}
}
function setWeapons(statBlockId, weapons) {
db.prepare('DELETE FROM combatant_weapons WHERE stat_block_id = ?').run(statBlockId);
const stmt = db.prepare(`
INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, parry_percent, mode, experience_checked)
VALUES (?, ?, ?, ?, ?, ?, ?)
2026-06-30 09:10:05 +10:00
`);
for (const w of weapons) {
stmt.run(statBlockId, w.weapon_name, w.category ?? null, w.skill_percent ?? 0, w.parry_percent ?? 0, w.mode ?? null, w.experience_checked ? 1 : 0);
2026-06-30 09:10:05 +10:00
}
}
function markWeaponExperienceChecked(weaponId, checked = true) {
db.prepare('UPDATE combatant_weapons SET experience_checked = ? WHERE id = ?').run(checked ? 1 : 0, weaponId);
}
function updateWeaponSkillPercent(weaponId, skillPercent) {
db.prepare('UPDATE combatant_weapons SET skill_percent = ?, experience_checked = 0 WHERE id = ?').run(skillPercent, weaponId);
}
function setSpells(statBlockId, spells) {
db.prepare('DELETE FROM combatant_spells WHERE stat_block_id = ?').run(statBlockId);
const stmt = db.prepare(`
INSERT INTO combatant_spells (stat_block_id, spell_mapping_id, mp_cost_override)
VALUES (?, ?, ?)
`);
for (const s of spells) {
stmt.run(statBlockId, s.spell_mapping_id, s.mp_cost_override ?? null);
}
}
// ---------- npcs ----------
const NPC_FIELDS = [
'npc_type', 'first_name', 'last_name', 'brief_description', 'wants_needs',
'secret_obstacle', 'also_carrying', 'race', 'pronouns', 'age', 'intelligence', 'hair', 'build', 'status',
];
function parseTraits(row) {
return { ...row, traits: row.traits_json ? JSON.parse(row.traits_json) : [] };
}
2026-06-30 09:10:05 +10:00
function listNpcs() {
const npcs = db.prepare('SELECT * FROM npcs ORDER BY id DESC').all();
return npcs.map((n) => ({ ...parseTraits(n), stat_block: n.stat_block_id ? getStatBlock(n.stat_block_id) : null }));
2026-06-30 09:10:05 +10:00
}
function getNpc(id) {
const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id);
if (!npc) return null;
return { ...parseTraits(npc), stat_block: npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null };
}
function updateNpcTraits(id, traits) {
db.prepare(`UPDATE npcs SET traits_json=?, updated_at=datetime('now') WHERE id=?`)
.run(JSON.stringify(traits), id);
return getNpc(id);
2026-06-30 09:10:05 +10:00
}
function createNpc(data) {
const row = {};
for (const f of NPC_FIELDS) row[f] = data[f] ?? (f === 'npc_type' ? 'full' : f === 'status' ? 'active' : null);
const statBlockId = data.stat_block ? createStatBlock(data.stat_block).id : null;
const info = db.prepare(`
INSERT INTO npcs (${NPC_FIELDS.join(', ')}, stat_block_id)
VALUES (${NPC_FIELDS.map((f) => '@' + f).join(', ')}, @stat_block_id)
`).run({ ...row, stat_block_id: statBlockId });
return getNpc(info.lastInsertRowid);
}
function updateNpc(id, data) {
const existing = getNpc(id);
if (!existing) return null;
const row = {};
for (const f of NPC_FIELDS) row[f] = data[f] !== undefined ? data[f] : existing[f];
db.prepare(`
UPDATE npcs SET ${NPC_FIELDS.map((f) => `${f}=@${f}`).join(', ')}, updated_at = datetime('now')
WHERE id = @id
`).run({ ...row, id });
return getNpc(id);
}
function deleteNpc(id) {
const existing = getNpc(id);
if (!existing) return false;
db.prepare('DELETE FROM npcs WHERE id = ?').run(id);
if (existing.stat_block_id) deleteStatBlock(existing.stat_block_id);
return true;
}
function linkNpcStatBlock(npcId, statBlockId) {
db.prepare(`UPDATE npcs SET stat_block_id = ?, updated_at = datetime('now') WHERE id = ?`).run(statBlockId, npcId);
return getNpc(npcId);
}
// ---------- enemies ----------
function listEnemies() {
const enemies = db.prepare('SELECT * FROM enemies ORDER BY id DESC').all();
return enemies.map((e) => ({ ...e, stat_block: getStatBlock(e.stat_block_id) }));
}
function getEnemy(id) {
const enemy = db.prepare('SELECT * FROM enemies WHERE id = ?').get(id);
if (!enemy) return null;
enemy.stat_block = getStatBlock(enemy.stat_block_id);
return enemy;
}
function createEnemy(data) {
const statBlockId = createStatBlock(data.stat_block || {}).id;
const info = db.prepare(`
INSERT INTO enemies (name, category, status, notes, stat_block_id)
VALUES (@name, @category, @status, @notes, @stat_block_id)
`).run({
name: data.name,
category: data.category ?? null,
status: data.status ?? 'active',
notes: data.notes ?? null,
stat_block_id: statBlockId,
});
return getEnemy(info.lastInsertRowid);
}
function updateEnemy(id, data) {
const existing = getEnemy(id);
if (!existing) return null;
db.prepare(`
UPDATE enemies SET name=@name, category=@category, status=@status, notes=@notes, updated_at = datetime('now')
WHERE id=@id
`).run({
id,
name: data.name !== undefined ? data.name : existing.name,
category: data.category !== undefined ? data.category : existing.category,
status: data.status !== undefined ? data.status : existing.status,
notes: data.notes !== undefined ? data.notes : existing.notes,
});
return getEnemy(id);
}
function deleteEnemy(id) {
const existing = getEnemy(id);
if (!existing) return false;
db.prepare('DELETE FROM enemies WHERE id = ?').run(id);
deleteStatBlock(existing.stat_block_id);
return true;
}
// ---------- inventory ----------
function listInventory(characterId) {
return db.prepare('SELECT * FROM inventory_items WHERE character_id = ? ORDER BY category, name').all(characterId);
}
function addInventoryItem({ character_id, name, quantity = 1, enc = 0, category = 'equipment', notes = null }) {
const info = db.prepare(`
INSERT INTO inventory_items (character_id, name, quantity, enc, category, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(character_id, name, quantity, enc, category, notes);
return db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(info.lastInsertRowid);
}
function updateInventoryItem(id, { quantity, enc, notes }) {
const item = db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(id);
if (!item) return null;
db.prepare(`UPDATE inventory_items SET quantity=?, enc=?, notes=? WHERE id=?`).run(
quantity ?? item.quantity, enc ?? item.enc, notes !== undefined ? notes : item.notes, id
);
return db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(id);
}
function deleteInventoryItem(id) {
return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id).changes > 0;
}
function inventoryTotalEnc(characterId) {
const row = db.prepare('SELECT SUM(quantity * enc) AS total FROM inventory_items WHERE character_id = ?').get(characterId);
return row ? (row.total || 0) : 0;
}
// ---------- adventure state ----------
function getAdventureState() {
const row = db.prepare('SELECT * FROM adventure_state WHERE id = 1').get();
if (!row) return null;
return { characterId: row.character_id, scene: JSON.parse(row.scene_json) };
}
function setAdventureState(characterId, scene) {
db.prepare(`
INSERT INTO adventure_state (id, character_id, scene_json, updated_at) VALUES (1, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET character_id=?, scene_json=?, updated_at=datetime('now')
`).run(characterId, JSON.stringify(scene), characterId, JSON.stringify(scene));
return getAdventureState();
}
function clearAdventureState() {
db.prepare('DELETE FROM adventure_state WHERE id = 1').run();
}
for (const sql of [
'ALTER TABLE player_characters ADD COLUMN age INTEGER NOT NULL DEFAULT 21',
'ALTER TABLE player_characters ADD COLUMN culture TEXT',
'ALTER TABLE player_characters ADD COLUMN occupation TEXT',
'ALTER TABLE player_characters ADD COLUMN current_location TEXT',
'ALTER TABLE player_characters ADD COLUMN destination TEXT',
'ALTER TABLE combatant_weapons ADD COLUMN parry_percent INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE player_characters ADD COLUMN traits_json TEXT',
'ALTER TABLE npcs ADD COLUMN traits_json TEXT',
]) {
try { db.exec(sql); } catch (_) {}
}
// ---------- player characters ----------
function listPlayerCharacters() {
const rows = db.prepare('SELECT * FROM player_characters ORDER BY id DESC').all();
return rows.map((r) => ({ ...parseTraits(r), stat_block: getStatBlock(r.stat_block_id) }));
}
function getPlayerCharacter(id) {
const row = db.prepare('SELECT * FROM player_characters WHERE id = ?').get(id);
if (!row) return null;
return { ...parseTraits(row), stat_block: getStatBlock(row.stat_block_id) };
}
function createPlayerCharacter({ name, generation_method, stat_block, age, culture, occupation }) {
const statBlockId = createStatBlock(stat_block || {}).id;
const info = db.prepare(`
INSERT INTO player_characters (name, generation_method, stat_block_id, age, culture, occupation)
VALUES (?, ?, ?, ?, ?, ?)
`).run(name, generation_method ?? 'random', statBlockId, age ?? 21, culture ?? null, occupation ?? null);
return getPlayerCharacter(info.lastInsertRowid);
}
function deletePlayerCharacter(id) {
const existing = getPlayerCharacter(id);
if (!existing) return false;
db.prepare('DELETE FROM player_characters WHERE id = ?').run(id);
deleteStatBlock(existing.stat_block_id);
return true;
}
function updatePlayerCharacterTraits(id, traits) {
db.prepare(`UPDATE player_characters SET traits_json=?, updated_at=datetime('now') WHERE id=?`)
.run(JSON.stringify(traits), id);
return getPlayerCharacter(id);
}
function updatePlayerCharacterLocation(id, { current_location, destination }) {
db.prepare(`
UPDATE player_characters SET current_location=?, destination=?, updated_at=datetime('now') WHERE id=?
`).run(current_location ?? null, destination ?? null, id);
return getPlayerCharacter(id);
}
2026-06-30 09:10:05 +10:00
// ---------- log ----------
function appendLogEntry({ type, summary, details }) {
const info = db.prepare(`
INSERT INTO log_entries (type, summary, details_json) VALUES (?, ?, ?)
`).run(type, summary, details ? JSON.stringify(details) : null);
return getLogEntry(info.lastInsertRowid);
}
function getLogEntry(id) {
const row = db.prepare('SELECT * FROM log_entries WHERE id = ?').get(id);
if (!row) return null;
return { ...row, details: row.details_json ? JSON.parse(row.details_json) : null };
}
function searchLog(search) {
const rows = search
? db.prepare(`
SELECT * FROM log_entries
WHERE summary LIKE @q OR details_json LIKE @q OR type LIKE @q
ORDER BY id ASC
`).all({ q: `%${search}%` })
: db.prepare('SELECT * FROM log_entries ORDER BY id ASC').all();
return rows.map((row) => ({ ...row, details: row.details_json ? JSON.parse(row.details_json) : null }));
}
// ---------- combat state ----------
function getCombatState() {
const row = db.prepare('SELECT * FROM combat_state WHERE id = 1').get();
if (!row) return null;
return { ...row, state: JSON.parse(row.state_json) };
}
function setCombatState(state) {
db.prepare(`
INSERT INTO combat_state (id, state_json, updated_at) VALUES (1, @state_json, datetime('now'))
ON CONFLICT(id) DO UPDATE SET state_json = @state_json, updated_at = datetime('now')
`).run({ state_json: JSON.stringify(state) });
return getCombatState();
}
function clearCombatState() {
db.prepare('DELETE FROM combat_state WHERE id = 1').run();
}
// ---------- tables (imported markdown content) ----------
function getTableTree() {
const nodes = db.prepare('SELECT * FROM tree_nodes ORDER BY sort_order ASC, id ASC').all();
const tableMeta = new Map(db.prepare('SELECT * FROM tables').all().map((t) => [t.tree_node_id, t]));
const byId = new Map(nodes.map((n) => [n.id, { ...n, table: tableMeta.get(n.id) || null, children: [] }]));
const roots = [];
for (const node of byId.values()) {
if (node.parent_id && byId.has(node.parent_id)) {
byId.get(node.parent_id).children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function getTableById(tableId) {
const table = db.prepare('SELECT * FROM tables WHERE id = ?').get(tableId);
if (!table) return null;
const node = db.prepare('SELECT * FROM tree_nodes WHERE id = ?').get(table.tree_node_id);
const columns = db.prepare('SELECT * FROM table_columns WHERE table_id = ? ORDER BY column_order ASC').all(tableId);
const rows = db.prepare('SELECT * FROM table_rows WHERE table_id = ? ORDER BY sort_order ASC').all(tableId)
.map((r) => ({ ...r, cells: JSON.parse(r.cells_json) }));
return { ...table, name: node.heading_text, source_file: node.source_file, columns, rows };
}
function rollOnTable(tableId) {
const table = getTableById(tableId);
if (!table) return null;
const row = table.rows[Math.floor(Math.random() * table.rows.length)];
const links = db.prepare('SELECT * FROM table_links WHERE row_id = ?').all(row.id);
return { table, row, links };
}
function findTableByNpcRole(npcRole) {
const t = db.prepare('SELECT * FROM tables WHERE npc_role = ?').get(npcRole);
return t ? getTableById(t.id) : null;
}
function findTableByColumnHeaders(requiredColumnNames) {
const wanted = requiredColumnNames.map((c) => c.toLowerCase());
const candidates = db.prepare(`
SELECT table_id, GROUP_CONCAT(LOWER(column_name), '|') AS cols
FROM table_columns GROUP BY table_id
`).all();
for (const c of candidates) {
const cols = c.cols.split('|');
if (wanted.every((w) => cols.includes(w))) return getTableById(c.table_id);
}
return null;
}
// ---------- markdown import (build-time conversion) ----------
function clearImportedTables() {
db.prepare('DELETE FROM tree_nodes').run();
}
function insertTreeNode({ source_file, parent_id, heading_text, heading_level, sort_order }) {
const info = db.prepare(`
INSERT INTO tree_nodes (source_file, parent_id, heading_text, heading_level, sort_order)
VALUES (?, ?, ?, ?, ?)
`).run(source_file, parent_id ?? null, heading_text, heading_level, sort_order ?? 0);
return info.lastInsertRowid;
}
function insertTable({ tree_node_id, dice_notation, table_type, npc_role }) {
const info = db.prepare(`
INSERT INTO tables (tree_node_id, dice_notation, table_type, npc_role)
VALUES (?, ?, ?, ?)
`).run(tree_node_id, dice_notation ?? null, table_type, npc_role ?? null);
return info.lastInsertRowid;
}
function insertColumns(tableId, columnNames) {
const stmt = db.prepare('INSERT INTO table_columns (table_id, column_name, column_order) VALUES (?, ?, ?)');
columnNames.forEach((name, idx) => stmt.run(tableId, name, idx));
}
function insertRows(tableId, rows) {
const stmt = db.prepare(`
INSERT INTO table_rows (table_id, roll_min, roll_max, sort_order, cells_json)
VALUES (?, ?, ?, ?, ?)
`);
const ids = [];
rows.forEach((row, idx) => {
const info = stmt.run(tableId, row.roll_min, row.roll_max, idx, JSON.stringify(row.cells));
ids.push(info.lastInsertRowid);
});
return ids;
}
function insertLink({ table_id, row_id, target_table_id }) {
db.prepare(`
INSERT INTO table_links (table_id, row_id, target_table_id) VALUES (?, ?, ?)
`).run(table_id, row_id, target_table_id);
}
function findTableByHeading(sourceFile, headingText) {
const node = db.prepare('SELECT * FROM tree_nodes WHERE source_file = ? AND heading_text = ?').get(sourceFile, headingText);
if (!node) return null;
return db.prepare('SELECT * FROM tables WHERE tree_node_id = ?').get(node.id) || null;
}
function getRowsForTable(tableId) {
return db.prepare('SELECT * FROM table_rows WHERE table_id = ? ORDER BY sort_order ASC').all(tableId)
.map((r) => ({ ...r, cells: JSON.parse(r.cells_json) }));
}
// ---------- spell mappings ----------
function listSpellMappings() {
return db.prepare('SELECT * FROM spell_mappings ORDER BY custom_name ASC').all();
}
function upsertSpellMapping({ custom_name, mechanic_id, default_mp_cost }) {
db.prepare(`
INSERT INTO spell_mappings (custom_name, mechanic_id, default_mp_cost) VALUES (@custom_name, @mechanic_id, @default_mp_cost)
ON CONFLICT(custom_name) DO UPDATE SET mechanic_id = @mechanic_id, default_mp_cost = @default_mp_cost
`).run({ custom_name, mechanic_id, default_mp_cost: default_mp_cost ?? 1 });
return db.prepare('SELECT * FROM spell_mappings WHERE custom_name = ?').get(custom_name);
}
// ---------- export / import ----------
function exportAll() {
return {
npcs: listNpcs(),
enemies: listEnemies(),
log_entries: searchLog(),
combat_state: getCombatState(),
spell_mappings: listSpellMappings(),
exported_at: new Date().toISOString(),
};
}
function clearAll() {
db.transaction(() => {
db.prepare('DELETE FROM adventure_state').run(); // references player_characters
db.prepare('DELETE FROM player_characters').run(); // cascades inventory_items
db.prepare('DELETE FROM npcs').run();
db.prepare('DELETE FROM enemies').run();
db.prepare('DELETE FROM stat_blocks').run(); // cascades hit_locations, weapons, spells
db.prepare('DELETE FROM log_entries').run();
db.prepare('DELETE FROM combat_state').run();
})();
}
2026-06-30 09:10:05 +10:00
function importAll(dump) {
const tx = db.transaction(() => {
db.prepare('DELETE FROM npcs').run();
db.prepare('DELETE FROM enemies').run();
db.prepare('DELETE FROM stat_blocks').run();
db.prepare('DELETE FROM log_entries').run();
db.prepare('DELETE FROM combat_state').run();
db.prepare('DELETE FROM spell_mappings').run();
for (const sm of dump.spell_mappings || []) {
upsertSpellMapping(sm);
}
for (const npc of dump.npcs || []) {
const { stat_block, id, created_at, updated_at, ...rest } = npc;
createNpc({ ...rest, stat_block: stat_block || undefined });
}
for (const enemy of dump.enemies || []) {
const { stat_block, id, created_at, updated_at, ...rest } = enemy;
createEnemy({ ...rest, stat_block: stat_block || {} });
}
for (const entry of dump.log_entries || []) {
appendLogEntry({ type: entry.type, summary: entry.summary, details: entry.details });
}
if (dump.combat_state && dump.combat_state.state) {
setCombatState(dump.combat_state.state);
}
});
tx();
return exportAll();
}
module.exports = {
db,
statBlocks: {
create: createStatBlock,
get: getStatBlock,
update: updateStatBlock,
delete: deleteStatBlock,
setHitLocations,
setWeapons,
setSpells,
markWeaponExperienceChecked,
updateWeaponSkillPercent,
},
npcs: { list: listNpcs, get: getNpc, create: createNpc, update: updateNpc, delete: deleteNpc, linkStatBlock: linkNpcStatBlock, updateTraits: updateNpcTraits },
2026-06-30 09:10:05 +10:00
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter, updateLocation: updatePlayerCharacterLocation, updateTraits: updatePlayerCharacterTraits },
inventory: { list: listInventory, add: addInventoryItem, update: updateInventoryItem, delete: deleteInventoryItem, totalEnc: inventoryTotalEnc },
adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState },
2026-06-30 09:10:05 +10:00
log: { append: appendLogEntry, get: getLogEntry, search: searchLog },
combat: { get: getCombatState, set: setCombatState, clear: clearCombatState },
tables: {
getTree: getTableTree,
getById: getTableById,
roll: rollOnTable,
findByNpcRole: findTableByNpcRole,
findByColumnHeaders: findTableByColumnHeaders,
clearImported: clearImportedTables,
insertTreeNode,
insertTable,
insertColumns,
insertRows,
insertLink,
findTableByHeading,
getRowsForTable,
},
spellMappings: { list: listSpellMappings, upsert: upsertSpellMapping },
clearAll,
2026-06-30 09:10:05 +10:00
exportAll,
importAll,
};