First Commit
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
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 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, mode, experience_checked)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const w of weapons) {
|
||||
stmt.run(statBlockId, w.weapon_name, w.category ?? null, w.skill_percent ?? 0, w.mode ?? null, w.experience_checked ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
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 listNpcs() {
|
||||
const npcs = db.prepare('SELECT * FROM npcs ORDER BY id DESC').all();
|
||||
return npcs.map((n) => ({ ...n, stat_block: n.stat_block_id ? getStatBlock(n.stat_block_id) : null }));
|
||||
}
|
||||
|
||||
function getNpc(id) {
|
||||
const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id);
|
||||
if (!npc) return null;
|
||||
npc.stat_block = npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null;
|
||||
return npc;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------- 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 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 },
|
||||
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
|
||||
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 },
|
||||
exportAll,
|
||||
importAll,
|
||||
};
|
||||
Reference in New Issue
Block a user