feat: inventory system, adventure scene engine, and PC generator fixes

- 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
This commit is contained in:
2026-07-03 16:00:54 +10:00
parent c223912915
commit 8bccf6f484
7 changed files with 5320 additions and 1 deletions
+113
View File
@@ -96,6 +96,34 @@ CREATE TABLE IF NOT EXISTS enemies (
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,
@@ -352,6 +380,88 @@ function deleteEnemy(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 }) {
@@ -581,6 +691,9 @@ module.exports = {
},
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: {