- Inventory: items linked to PCs with name, qty, ENC, category; effective FP = STR+CON minus total ENC carried; CRUD API + inline UI on Characters tab - Adventure scene engine: new tab with procedural CYOA choices (Fight, Sneak, Talk, Investigate, Flee) resolved via skill checks; scenes generated from any rollable table with automatic L2 cascade; scene state persisted in DB - Fix total HP formula: was CON+SIZ, now correctly ceil((CON+SIZ)/2) per RQ3 - Fix hit location HPs: were computed from wrong total HP, now correct - Add fatigue points (STR+CON) to derived stats - Add all seven skill category modifiers computed from characteristics (primary/secondary/negative influences per rulebook), shown in generator UI - Add base skill computation (computeBaseSkills) for use in scene engine - Add RQ3 Players Book to docs/ as reference
718 lines
26 KiB
JavaScript
718 lines
26 KiB
JavaScript
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'))
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
// ---------- 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();
|
|
}
|
|
|
|
// ---------- player characters ----------
|
|
|
|
function listPlayerCharacters() {
|
|
const rows = db.prepare('SELECT * FROM player_characters ORDER BY id DESC').all();
|
|
return rows.map((r) => ({ ...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 { ...row, stat_block: getStatBlock(row.stat_block_id) };
|
|
}
|
|
|
|
function createPlayerCharacter({ name, generation_method, stat_block }) {
|
|
const statBlockId = createStatBlock(stat_block || {}).id;
|
|
const info = db.prepare(`
|
|
INSERT INTO player_characters (name, generation_method, stat_block_id)
|
|
VALUES (?, ?, ?)
|
|
`).run(name, generation_method ?? 'random', statBlockId);
|
|
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;
|
|
}
|
|
|
|
// ---------- 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 },
|
|
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter },
|
|
inventory: { list: listInventory, add: addInventoryItem, update: updateInventoryItem, delete: deleteInventoryItem, totalEnc: inventoryTotalEnc },
|
|
adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState },
|
|
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,
|
|
};
|