First Commit

This commit is contained in:
2026-06-30 09:10:05 +10:00
commit 6e0d3d0e1e
14 changed files with 10546 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# RQ3 Story Tool
A solo web app for narrative-driven play using RuneQuest 3rd Edition rules with a custom fiction layer.
## Setup
```bash
npm install
```
The first time you run the app, the SQLite database is created automatically at `data/story-tool.db` from `db.js`'s schema.
### Importing the table files
`tables/*.md` are converted into the database once, then the database is authoritative (the `.md` files can be deleted afterward):
```bash
npm run import-tables
```
Re-running this command wipes and rebuilds the imported table tree, NPC-field detection, and the Norse Layer 1→2 table links - safe to re-run any time the `.md` files change.
## Run
```bash
npm start
```
Then open `http://localhost:3000`.
## Project layout
- `db.js` - SQLite schema and all query helpers (npcs, enemies, log, combat state, imported tables, spell mappings).
- `rq3.js` - the RuneQuest 3rd Edition rules engine. Pure functions only, no DB/HTTP dependencies.
- `server.js` - Express routes. All state changes go through `/api/*` and are logged.
- `lib/markdownTableParser.js` - parses the heading/table structure out of `tables/*.md`.
- `scripts/import-tables.js` - one-time markdown → SQLite conversion (see above).
- `public/` - the frontend (`index.html`, `app.js`, `style.css`). Plain JS, no framework, no rules logic - everything goes through the API.
## Notes
- The fiction layer (NPC names, encounter flavor text, custom spell names) lives entirely in the database (originally seeded from `tables/*.md`) and never touches the RQ3 rules engine in `rq3.js`.
- Spell name → mechanic mappings are managed via `/api/spell-mappings` rather than markdown, since the table content has already moved into the database.
- Closing the tab while there are unexported changes triggers an export reminder; data is also safely persisted in SQLite regardless.
+604
View File
@@ -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,
};
+163
View File
@@ -0,0 +1,163 @@
const fs = require('fs');
function isTableRowLine(line) {
const t = line.trim();
return t.length > 0 && t.includes('|');
}
function isSeparatorLine(line) {
const t = line.trim();
if (!t.includes('-')) return false;
const cells = stripOuterPipes(t).split('|');
return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c.trim()));
}
function stripOuterPipes(t) {
let s = t;
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s;
}
function splitRow(line) {
return stripOuterPipes(line.trim()).split('|').map((c) => c.trim());
}
function parseRollCell(raw) {
if (raw == null) return null;
const t = raw.trim().replace(/\*\*/g, '').trim();
if (t === '') return null;
let m = /^(\d+)\s*[-–—]\s*$/.exec(t);
if (m) return { min: parseInt(m[1], 10), max: 9999 };
m = /^(\d+)\s*\+\s*$/.exec(t);
if (m) return { min: parseInt(m[1], 10), max: 9999 };
m = /^(\d+)\s*or\s*(less|lower|fewer|under)$/i.exec(t);
if (m) return { min: 0, max: parseInt(m[1], 10) };
m = /^(\d+)\s*or\s*(more|higher|greater|above)$/i.exec(t);
if (m) return { min: parseInt(m[1], 10), max: 9999 };
m = /^(\d+)\s*[-–—]\s*(\d+)$/.exec(t);
if (m) return { min: parseInt(m[1], 10), max: parseInt(m[2], 10) };
m = /^(\d+)$/.exec(t);
if (m) {
const v = parseInt(m[1], 10);
return { min: v, max: v };
}
return null;
}
function inferDiceFromMax(maxRoll) {
const standard = [4, 6, 8, 10, 12, 20, 100];
const found = standard.find((d) => maxRoll <= d);
return `d${found || maxRoll}`;
}
function buildTableObject(columns, rowsRaw, headingText) {
if (columns.length === 1) {
const rows = rowsRaw.map((cells, idx) => ({
roll_min: idx + 1,
roll_max: idx + 1,
cells,
}));
return {
columns,
rows,
diceNotation: rows.length ? `d${rows.length}` : null,
tableType: 'list',
};
}
const headingDiceMatch = /\((\d*d\d+)\)/i.exec(headingText || '');
const diceFromHeading = headingDiceMatch ? headingDiceMatch[1].toLowerCase() : null;
const firstColHeader = columns[0];
const diceFromColumn = /^\d*d\d+$/i.test(firstColHeader.replace(/\s/g, ''))
? firstColHeader.toLowerCase()
: null;
const firstColRanges = rowsRaw.map((cells) => parseRollCell(cells[0]));
const allFirstColParsed = firstColRanges.length > 0 && firstColRanges.every((r) => r !== null);
if (!allFirstColParsed) {
// First column isn't actually a roll column (or some cells use unrecognized
// phrasing) - keep it as real data rather than silently discarding it.
if (firstColRanges.some((r) => r !== null)) {
console.warn(`"${headingText}": first column has a mix of roll-like and non-roll-like cells; keeping it as a data column.`);
}
const rows = rowsRaw.map((cells, idx) => ({ roll_min: idx + 1, roll_max: idx + 1, cells }));
return {
columns,
rows,
diceNotation: rows.length ? `d${rows.length}` : null,
tableType: 'list',
};
}
let maxRoll = 0;
const parsedRows = rowsRaw.map((cells, idx) => {
const range = firstColRanges[idx];
if (range.max < 9999) maxRoll = Math.max(maxRoll, range.max);
return { roll_min: range.min, roll_max: range.max, cells: cells.slice(1) };
});
const resultColumns = columns.slice(1);
const tableType = resultColumns.length > 1 ? 'multi' : 'single';
let diceNotation = diceFromHeading || diceFromColumn || null;
if (!diceNotation && maxRoll > 0) {
diceNotation = inferDiceFromMax(maxRoll);
}
return { columns: resultColumns, rows: parsedRows, diceNotation, tableType };
}
function extractFirstTable(bodyLines, headingText) {
for (let i = 0; i < bodyLines.length - 1; i++) {
if (isTableRowLine(bodyLines[i]) && isSeparatorLine(bodyLines[i + 1])) {
const columns = splitRow(bodyLines[i]);
const rowsRaw = [];
let j = i + 2;
while (j < bodyLines.length && isTableRowLine(bodyLines[j]) && !isSeparatorLine(bodyLines[j])) {
rowsRaw.push(splitRow(bodyLines[j]));
j++;
}
return buildTableObject(columns, rowsRaw, headingText);
}
}
return null;
}
function parseMarkdownFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
const root = { level: 0, headingText: null, table: null, children: [] };
const stack = [root];
let bodyLines = [];
let currentNode = null;
function flushBody() {
if (!currentNode) return;
const table = extractFirstTable(bodyLines, currentNode.headingText);
if (table) currentNode.table = table;
}
for (const line of lines) {
const m = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
if (m) {
flushBody();
bodyLines = [];
const level = m[1].length;
const headingText = m[2].trim();
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
const parent = stack[stack.length - 1];
const node = { level, headingText, table: null, children: [] };
parent.children.push(node);
stack.push(node);
currentNode = node;
} else {
bodyLines.push(line);
}
}
flushBody();
return root.children;
}
module.exports = { parseMarkdownFile, parseRollCell };
+1265
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "rq3-story-tool",
"version": "0.1.0",
"description": "Solo web app for narrative-driven play using RuneQuest 3rd Edition rules with a custom fiction layer",
"main": "server.js",
"scripts": {
"start": "node server.js",
"import-tables": "node scripts/import-tables.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"marked": "^13.0.0"
}
}
+803
View File
@@ -0,0 +1,803 @@
// Frontend logic only. No RQ3 rules calculations happen here - everything goes through /api/*.
const SPELL_MECHANIC_IDS = ['bladesharp', 'protection', 'heal', 'disruption', 'demoralize', 'coordination'];
const state = {
view: 'tables',
tablesTree: [],
expandedNodes: new Set(),
selectedTableId: null,
selectedTable: null,
lastRoll: null,
manualSelectOpen: false,
npcs: [],
selectedNpcId: null,
enemies: [],
selectedEnemyId: null,
combat: null,
reactionType: 'none',
spellMappings: [],
logEntries: [],
dirty: false,
attackModifiers: [],
armorTable: [],
selectedModifierIds: new Set(),
};
// ---------- low-level helpers ----------
function qs(sel, root = document) { return root.querySelector(sel); }
function el(html) {
const t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function md(text) {
if (!text) return '';
// parseInline (not parse) - these are short flavor-text fragments rendered inside our
// own wrapper elements; parse()'s block-level <p> wrapping causes nested-<p> auto-close
// bugs when our wrapper is also a <p>.
return window.marked ? window.marked.parseInline(text) : escapeHtml(text);
}
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 204) return null;
let data = null;
try { data = await res.json(); } catch (e) { /* no body */ }
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
state.dirty = true;
return data;
}
function tierTag(tier) {
return `<span class="tag tier-${tier}">${tier}</span>`;
}
// ---------- load ----------
async function loadTablesTree() { state.tablesTree = await api('GET', '/api/tables/tree'); }
async function loadNpcs() { state.npcs = await api('GET', '/api/npcs'); }
async function loadEnemies() { state.enemies = await api('GET', '/api/enemies'); }
async function loadCombat() { state.combat = await api('GET', '/api/combat'); }
async function loadSpellMappings() { state.spellMappings = await api('GET', '/api/spell-mappings'); }
async function loadLog(search = '') {
state.logEntries = await api('GET', `/api/log${search ? `?search=${encodeURIComponent(search)}` : ''}`);
}
async function loadAttackModifiers() { state.attackModifiers = await api('GET', '/api/rules/attack-modifiers'); }
async function loadArmorTable() { state.armorTable = await api('GET', '/api/rules/armor-table'); }
// ---------- top-level render ----------
function setView(view) {
state.view = view;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));
renderSidebar();
renderMain();
}
function renderSidebar() {
const root = qs('#sidebar-content');
root.innerHTML = '';
if (state.view === 'tables') root.appendChild(renderTablesSidebar());
if (state.view === 'npcs') root.appendChild(renderNpcsSidebar());
if (state.view === 'enemies') root.appendChild(renderEnemiesSidebar());
if (state.view === 'combat') root.appendChild(renderCombatSidebar());
}
function renderMain() {
const root = qs('#main-panel');
root.innerHTML = '';
if (state.view === 'tables') root.appendChild(renderTablesMain());
if (state.view === 'npcs') root.appendChild(renderNpcsMain());
if (state.view === 'enemies') root.appendChild(renderEnemiesMain());
if (state.view === 'combat') root.appendChild(renderCombatMain());
}
// ======================================================================
// TABLES
// ======================================================================
function renderTreeNode(node) {
const hasChildren = node.children && node.children.length > 0;
const expanded = state.expandedNodes.has(node.id);
const wrap = el(`<div class="tree-node"></div>`);
const heading = el(`
<div class="tree-heading ${node.table && state.selectedTableId === node.table.id ? 'selected' : ''}" data-node-id="${node.id}">
<span class="tree-toggle">${hasChildren ? (expanded ? '▾' : '▸') : ''}</span>
<span>${escapeHtml(node.heading_text)}</span>
${node.table ? `<span class="tree-table-icon">[${node.table.dice_notation || '?'}]</span>` : ''}
</div>
`);
heading.addEventListener('click', () => {
if (node.table) selectTable(node.table.id);
if (hasChildren) {
if (expanded) state.expandedNodes.delete(node.id); else state.expandedNodes.add(node.id);
renderSidebar();
}
});
wrap.appendChild(heading);
if (hasChildren && expanded) {
const childWrap = el(`<div class="tree-children"></div>`);
node.children.forEach((c) => childWrap.appendChild(renderTreeNode(c)));
wrap.appendChild(childWrap);
}
return wrap;
}
function renderTablesSidebar() {
const wrap = el(`<div></div>`);
if (!state.tablesTree.length) {
wrap.appendChild(el(`<p class="empty-state">No tables imported.</p>`));
return wrap;
}
state.tablesTree.forEach((root) => wrap.appendChild(renderTreeNode(root)));
return wrap;
}
async function selectTable(tableId) {
state.selectedTableId = tableId;
state.selectedTable = await api('GET', `/api/tables/${tableId}`);
state.lastRoll = null;
state.manualSelectOpen = false;
renderSidebar();
renderMain();
}
async function rollSelectedTable() {
if (!state.selectedTableId) return;
const result = await api('POST', `/api/tables/${state.selectedTableId}/roll`);
state.lastRoll = result;
state.manualSelectOpen = false;
await loadLog();
renderMain();
renderLog();
}
async function manualSelectRow(rowId) {
const table = state.selectedTable;
const row = table.rows.find((r) => r.id === rowId);
if (!row) return;
await api('POST', '/api/log', {
type: 'roll',
summary: `Manually selected on "${table.name}": ${row.cells.join(' / ')}`,
details: { table_id: table.id, row },
});
state.lastRoll = { table, row, links: [] };
state.manualSelectOpen = false;
await loadLog();
renderMain();
renderLog();
}
function renderRollResult(result) {
const box = el(`<div class="roll-result"></div>`);
box.appendChild(el(`<div class="roll-table-name">${escapeHtml(result.table.name)}</div>`));
const cellsWrap = el(`<div class="roll-cells"></div>`);
result.row.cells.forEach((cell, idx) => {
const colName = result.table.columns[idx] ? result.table.columns[idx].column_name : '';
cellsWrap.appendChild(el(`<p>${colName ? `<strong>${escapeHtml(colName)}:</strong> ` : ''}${md(cell)}</p>`));
});
box.appendChild(cellsWrap);
if (result.links && result.links.length) {
const linkWrap = el(`<div class="button-row"></div>`);
result.links.forEach((link) => {
const btn = el(`<button class="button">Roll Linked Table</button>`);
btn.addEventListener('click', () => selectTable(link.target_table_id).then(rollSelectedTable));
linkWrap.appendChild(btn);
});
box.appendChild(linkWrap);
}
return box;
}
function renderTablesMain() {
const wrap = el(`<div></div>`);
if (!state.selectedTable) {
wrap.appendChild(el(`<p class="empty-state">Select a table from the tree to roll on it.</p>`));
return wrap;
}
const table = state.selectedTable;
wrap.appendChild(el(`<h2>${escapeHtml(table.name)}</h2>`));
const actions = el(`<div class="button-row"></div>`);
const rollBtn = el(`<button class="button primary">Roll</button>`);
rollBtn.addEventListener('click', rollSelectedTable);
const rerollBtn = el(`<button class="button">Re-roll</button>`);
rerollBtn.addEventListener('click', rollSelectedTable);
const manualBtn = el(`<button class="button">Select Manually</button>`);
manualBtn.addEventListener('click', () => { state.manualSelectOpen = !state.manualSelectOpen; renderMain(); });
actions.append(rollBtn, rerollBtn, manualBtn);
wrap.appendChild(actions);
if (state.lastRoll) wrap.appendChild(renderRollResult(state.lastRoll));
if (state.manualSelectOpen) {
const list = el(`<div class="card"><h3>All entries</h3></div>`);
table.rows.forEach((row) => {
const item = el(`<div class="weapon-row" style="cursor:pointer"><span>${escapeHtml(row.roll_min === row.roll_max ? String(row.roll_min) : `${row.roll_min}-${row.roll_max}`)}</span><span>${md(row.cells.join(' / '))}</span></div>`);
item.addEventListener('click', () => manualSelectRow(row.id));
list.appendChild(item);
});
wrap.appendChild(list);
}
return wrap;
}
// ======================================================================
// NPCS
// ======================================================================
function renderNpcsSidebar() {
const wrap = el(`<div></div>`);
const actions = el(`<div class="button-row"></div>`);
const fullBtn = el(`<button class="button primary">+ Full NPC</button>`);
fullBtn.addEventListener('click', () => generateNpc('full'));
const fillerBtn = el(`<button class="button">+ Filler NPC</button>`);
fillerBtn.addEventListener('click', () => generateNpc('filler'));
actions.append(fullBtn, fillerBtn);
wrap.appendChild(actions);
const list = el(`<ul class="entity-list"></ul>`);
state.npcs.forEach((npc) => {
const name = `${npc.first_name || '(unnamed)'} ${npc.last_name || ''}`.trim();
const li = el(`<li class="${state.selectedNpcId === npc.id ? 'selected' : ''} status-${npc.status}"><span>${escapeHtml(name)}</span><span class="tag">${npc.status}</span></li>`);
li.addEventListener('click', () => { state.selectedNpcId = npc.id; renderSidebar(); renderMain(); });
list.appendChild(li);
});
wrap.appendChild(list);
return wrap;
}
async function generateNpc(npcType) {
const npc = await api('POST', '/api/npcs/generate', { npc_type: npcType });
await loadNpcs();
state.selectedNpcId = npc.id;
await loadLog();
renderSidebar();
renderMain();
renderLog();
}
async function rerollNpcField(field) {
await api('POST', `/api/npcs/${state.selectedNpcId}/reroll-field`, { field });
await loadNpcs();
await loadLog();
renderSidebar();
renderMain();
renderLog();
}
async function updateNpcField(field, value) {
await api('PUT', `/api/npcs/${state.selectedNpcId}`, { [field]: value });
await loadNpcs();
renderSidebar();
}
async function setNpcStatus(status) {
await api('PUT', `/api/npcs/${state.selectedNpcId}`, { status });
await loadNpcs();
renderSidebar();
renderMain();
}
async function deleteSelectedNpc() {
if (!confirm('Delete this NPC? This cannot be undone.')) return;
await api('DELETE', `/api/npcs/${state.selectedNpcId}`);
state.selectedNpcId = null;
await loadNpcs();
await loadLog();
renderSidebar();
renderMain();
renderLog();
}
async function attachNpcStatBlock() {
await api('POST', `/api/npcs/${state.selectedNpcId}/generate-stat-block`);
await loadNpcs();
renderMain();
}
const NPC_CORE_FIELDS = [
['first_name', 'First Name'], ['last_name', 'Last Name'], ['brief_description', 'Brief Description'],
['wants_needs', 'Wants and Needs'], ['secret_obstacle', 'Secret or Obstacle'], ['also_carrying', 'Also Carrying'],
];
const NPC_ATTR_FIELDS = [
['race', 'Race'], ['pronouns', 'Pronouns'], ['age', 'Age'], ['intelligence', 'Intelligence'], ['hair', 'Hair'], ['build', 'Build'],
];
function renderNpcField(npc, key, label, rerollable) {
const row = el(`<div class="field-row"><label>${label}</label></div>`);
const valueWrap = el(`<div class="field-value"></div>`);
const span = el(`<span>${md(npc[key]) || '<em>—</em>'}</span>`);
span.contentEditable = 'true';
span.addEventListener('blur', () => {
const text = span.innerText.trim();
if (text !== (npc[key] || '')) updateNpcField(key, text);
});
valueWrap.appendChild(span);
if (rerollable) {
const btn = el(`<button class="button" title="Re-roll">🎲</button>`);
btn.addEventListener('click', () => rerollNpcField(key));
valueWrap.appendChild(btn);
}
row.appendChild(valueWrap);
return row;
}
function renderNpcsMain() {
const wrap = el(`<div></div>`);
const npc = state.npcs.find((n) => n.id === state.selectedNpcId);
if (!npc) {
wrap.appendChild(el(`<p class="empty-state">Generate or select an NPC.</p>`));
return wrap;
}
const card = el(`<div class="card"></div>`);
card.appendChild(el(`<h2>${escapeHtml(`${npc.first_name || ''} ${npc.last_name || ''}`.trim() || '(unnamed)')} <span class="tag">${npc.npc_type}</span></h2>`));
const statusRow = el(`<div class="button-row"></div>`);
['active', 'dead', 'inactive'].forEach((s) => {
const b = el(`<button class="button ${npc.status === s ? 'primary' : ''}">${s}</button>`);
b.addEventListener('click', () => setNpcStatus(s));
statusRow.appendChild(b);
});
const delBtn = el(`<button class="button danger">Delete</button>`);
delBtn.addEventListener('click', deleteSelectedNpc);
statusRow.appendChild(delBtn);
card.appendChild(statusRow);
NPC_CORE_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true)));
if (npc.npc_type === 'full') {
NPC_ATTR_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true)));
}
wrap.appendChild(card);
const sbCard = el(`<div class="card"><h3>Stat Block</h3></div>`);
if (!npc.stat_block) {
const attachBtn = el(`<button class="button">Attach Random Stat Block</button>`);
attachBtn.addEventListener('click', attachNpcStatBlock);
sbCard.appendChild(attachBtn);
} else {
sbCard.appendChild(renderStatBlockReadout(npc.stat_block));
}
wrap.appendChild(sbCard);
return wrap;
}
// ======================================================================
// shared stat block readout (used by NPC + enemy detail views)
// ======================================================================
function renderStatBlockReadout(sb) {
const wrap = el(`<div></div>`);
const grid = el(`<div class="char-grid"></div>`);
['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((c) => {
grid.appendChild(el(`<div><div class="char-label">${c}</div><div class="char-value">${sb[c]}</div></div>`));
});
wrap.appendChild(grid);
wrap.appendChild(el(`<p>HP ${sb.current_hp}/${sb.max_hp} &nbsp; MP ${sb.magic_points_current}/${sb.magic_points_max} &nbsp; Move ${sb.move}${sb.culture ? ` &nbsp; <span class="tag">${escapeHtml(sb.culture)}</span>` : ''}</p>`));
const table = el(`<table class="hit-loc-table"><thead><tr><th>Location</th><th>HP</th><th>AP</th></tr></thead></table>`);
const tbody = el(`<tbody></tbody>`);
(sb.hit_locations || []).forEach((loc) => {
tbody.appendChild(el(`<tr class="${loc.disabled ? 'disabled' : ''}"><td>${loc.location_name}</td><td>${loc.current_hp}/${loc.max_hp}</td><td>${loc.armor_ap}</td></tr>`));
});
table.appendChild(tbody);
wrap.appendChild(table);
if ((sb.weapons || []).length) {
sb.weapons.forEach((w) => {
wrap.appendChild(el(`<div class="weapon-row"><span>${escapeHtml(w.weapon_name)}</span><span class="tag">${w.skill_percent}%</span>${w.mode ? `<span class="tag">${w.mode}</span>` : ''}</div>`));
});
}
if ((sb.spells || []).length) {
sb.spells.forEach((s) => {
wrap.appendChild(el(`<div class="spell-row"><span>${escapeHtml(s.custom_name)}</span><span class="tag">${s.mechanic_id}</span></div>`));
});
}
return wrap;
}
// ======================================================================
// ENEMIES
// ======================================================================
function renderEnemiesSidebar() {
const wrap = el(`<div></div>`);
const genBtn = el(`<button class="button primary">+ Generate Enemy</button>`);
genBtn.addEventListener('click', async () => {
const name = prompt('Enemy name?', 'New Enemy');
if (name === null) return;
const enemy = await api('POST', '/api/enemies/generate', { name, category: 'Humanoid' });
await loadEnemies();
state.selectedEnemyId = enemy.id;
await loadLog();
renderSidebar(); renderMain(); renderLog();
});
wrap.appendChild(genBtn);
const list = el(`<ul class="entity-list"></ul>`);
state.enemies.forEach((enemy) => {
const li = el(`<li class="${state.selectedEnemyId === enemy.id ? 'selected' : ''} status-${enemy.status}"><span>${escapeHtml(enemy.name)}</span><span class="tag">${enemy.stat_block.current_hp}/${enemy.stat_block.max_hp}</span></li>`);
li.addEventListener('click', () => { state.selectedEnemyId = enemy.id; renderSidebar(); renderMain(); });
list.appendChild(li);
});
wrap.appendChild(list);
return wrap;
}
async function updateEnemyStatBlock(patch) {
await api('PUT', `/api/enemies/${state.selectedEnemyId}`, { stat_block: patch });
await loadEnemies();
renderSidebar();
renderMain();
}
async function deleteSelectedEnemy() {
if (!confirm('Delete this enemy? This cannot be undone.')) return;
await api('DELETE', `/api/enemies/${state.selectedEnemyId}`);
state.selectedEnemyId = null;
await loadEnemies();
await loadLog();
renderSidebar(); renderMain(); renderLog();
}
function renderEnemiesMain() {
const wrap = el(`<div></div>`);
const enemy = state.enemies.find((e) => e.id === state.selectedEnemyId);
if (!enemy) {
wrap.appendChild(el(`<p class="empty-state">Generate or select an enemy.</p>`));
return wrap;
}
const card = el(`<div class="card"></div>`);
card.appendChild(el(`<h2>${escapeHtml(enemy.name)} <span class="tag">${enemy.category || ''}</span></h2>`));
const delBtn = el(`<button class="button danger">Delete</button>`);
delBtn.addEventListener('click', deleteSelectedEnemy);
card.appendChild(delBtn);
card.appendChild(renderStatBlockReadout(enemy.stat_block));
const weaponForm = el(`<div class="card"><h3>Add Weapon</h3></div>`);
const wName = el(`<input placeholder="Weapon name (e.g. Broadsword)">`);
const wCat = el(`<input placeholder="Category (e.g. Sword, 1H)">`);
const wSkill = el(`<input type="number" placeholder="Skill %" value="50">`);
const wAdd = el(`<button class="button">Add</button>`);
const wBonusBtn = el(`<button class="button">Check Cultural Bonus</button>`);
const wBonusResult = el(`<span class="tag"></span>`);
wBonusBtn.addEventListener('click', async () => {
if (!enemy.stat_block.culture || !wCat.value.trim()) {
wBonusResult.textContent = 'need culture + category';
return;
}
const bonus = await api('GET', `/api/rules/cultural-bonus?culture=${encodeURIComponent(enemy.stat_block.culture)}&category=${encodeURIComponent(wCat.value.trim())}&weapon=${encodeURIComponent(wName.value.trim())}`);
wBonusResult.textContent = `+${bonus.attack} atk / +${bonus.parry} parry (${enemy.stat_block.culture})`;
});
wAdd.addEventListener('click', async () => {
if (!wName.value.trim()) return;
const weapons = enemy.stat_block.weapons.map((w) => ({ weapon_name: w.weapon_name, category: w.category, skill_percent: w.skill_percent, mode: w.mode }));
weapons.push({ weapon_name: wName.value.trim(), category: wCat.value.trim() || null, skill_percent: Number(wSkill.value) || 0 });
await updateEnemyStatBlock({ weapons });
});
weaponForm.append(wName, wCat, wSkill, wAdd, wBonusBtn, wBonusResult);
wrap.append(card, weaponForm);
const armorForm = el(`<div class="card"><h3>Apply Armor Type</h3></div>`);
const armorSelect = el(`<select>${state.armorTable.map((a) => `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)} (AP ${a.ap})</option>`).join('')}</select>`);
const armorApply = el(`<button class="button">Apply to All Locations</button>`);
armorApply.addEventListener('click', async () => {
const armor = state.armorTable.find((a) => a.name === armorSelect.value);
if (!armor) return;
const hit_locations = enemy.stat_block.hit_locations.map((loc) => ({ ...loc, armor_ap: armor.ap }));
await updateEnemyStatBlock({ hit_locations });
});
armorForm.append(armorSelect, armorApply);
if (state.armorTable.length) wrap.appendChild(armorForm);
const spellForm = el(`<div class="card"><h3>Add Known Spell</h3></div>`);
const spellSelect = el(`<select></select>`);
state.spellMappings.forEach((sm) => spellSelect.appendChild(el(`<option value="${sm.id}">${escapeHtml(sm.custom_name)} (${sm.mechanic_id})</option>`)));
const spellAdd = el(`<button class="button">Add</button>`);
spellAdd.addEventListener('click', async () => {
if (!state.spellMappings.length) return;
const spells = enemy.stat_block.spells.map((s) => ({ spell_mapping_id: s.id }));
spells.push({ spell_mapping_id: Number(spellSelect.value) });
await updateEnemyStatBlock({ spells });
});
spellForm.append(spellSelect, spellAdd);
if (state.spellMappings.length) wrap.appendChild(spellForm);
return wrap;
}
// ======================================================================
// COMBAT
// ======================================================================
function combatants() { return (state.combat && state.combat.state.combatants) || []; }
function renderCombatSidebar() {
const wrap = el(`<div></div>`);
const addWrap = el(`<div class="field-row"><label>Add Combatant</label></div>`);
const select = el(`<select></select>`);
select.appendChild(el(`<option value="">Choose...</option>`));
state.npcs.filter((n) => n.stat_block).forEach((n) => select.appendChild(el(`<option value="npc:${n.id}">${escapeHtml(`${n.first_name || ''} ${n.last_name || ''}`.trim())}</option>`)));
state.enemies.forEach((e) => select.appendChild(el(`<option value="enemy:${e.id}">${escapeHtml(e.name)}</option>`)));
const addBtn = el(`<button class="button">Add</button>`);
addBtn.addEventListener('click', async () => {
if (!select.value) return;
const [type, id] = select.value.split(':');
await api('POST', '/api/combat/add-combatant', { type, id: Number(id) });
await loadCombat();
await loadLog();
renderSidebar(); renderMain(); renderLog();
});
addWrap.append(select, addBtn);
wrap.appendChild(addWrap);
const list = el(`<ul class="entity-list"></ul>`);
combatants().forEach((c) => {
list.appendChild(el(`<li><span>${escapeHtml(c.name)} (SR ${c.strikeRank})</span><span class="tag">${c.currentHp}/${c.maxHp} ${c.status}</span></li>`));
});
wrap.appendChild(list);
if (combatants().length) {
const endBtn = el(`<button class="button danger">End Combat</button>`);
endBtn.addEventListener('click', async () => {
if (!confirm('End combat?')) return;
await api('DELETE', '/api/combat');
await loadCombat();
await loadLog();
renderSidebar(); renderMain(); renderLog();
});
wrap.appendChild(endBtn);
}
return wrap;
}
function combatantOptions(selectedId) {
return combatants().map((c) => `<option value="${c.id}" ${c.id === selectedId ? 'selected' : ''}>${escapeHtml(c.name)}</option>`).join('');
}
function renderCombatMain() {
const wrap = el(`<div></div>`);
if (combatants().length < 1) {
wrap.appendChild(el(`<p class="empty-state">Add combatants from the sidebar to begin.</p>`));
return wrap;
}
wrap.appendChild(el(`<h2>Attack</h2>`));
const form = el(`<div class="card"></div>`);
const attackerSel = el(`<select>${combatantOptions()}</select>`);
const defenderSel = el(`<select>${combatantOptions()}</select>`);
const weaponSel = el(`<select></select>`);
const modeSel = el(`<select><option value="">(mode if dual)</option><option value="impale">impale</option><option value="slash">slash</option></select>`);
const kindSel = el(`<select><option value="melee">melee</option><option value="missile">missile</option></select>`);
const thrownChk = el(`<span class="checkbox-field"><input type="checkbox"> thrown</span>`);
function refreshWeapons() {
const attacker = combatants().find((c) => c.id === attackerSel.value) || combatants()[0];
weaponSel.innerHTML = '';
(attacker ? attacker.weapons : []).forEach((w) => weaponSel.appendChild(el(`<option>${escapeHtml(w.weapon_name)}</option>`)));
}
attackerSel.addEventListener('change', refreshWeapons);
refreshWeapons();
const reactionType = el(`<select><option value="">no reaction</option><option value="parry">parry</option><option value="dodge">dodge</option></select>`);
const reactionSkill = el(`<input type="number" placeholder="Reaction skill %">`);
const reactionWeapon = el(`<input placeholder="Parrying weapon name (for parry)">`);
const modifiersWrap = el(`<div class="field-row"><label>Situational Modifiers</label></div>`);
const modifierChecks = state.attackModifiers.map((mod) => el(`
<div class="checkbox-field-row">
<span class="checkbox-field"><input type="checkbox" data-mod-id="${mod.id}"> ${escapeHtml(mod.description)} (${mod.modifier > 0 ? '+' : ''}${mod.modifier}${mod.perSiz ? ` per ${mod.perSiz} SIZ` : ''})</span>
</div>
`));
modifierChecks.forEach((f) => modifiersWrap.appendChild(f));
const resolveBtn = el(`<button class="button primary">Resolve Attack</button>`);
const resultBox = el(`<div></div>`);
resolveBtn.addEventListener('click', async () => {
const modifierIds = modifierChecks
.filter((f) => f.querySelector('input').checked)
.map((f) => f.querySelector('input').dataset.modId);
const body = {
attackerCombatantId: attackerSel.value,
defenderCombatantId: defenderSel.value,
weaponName: weaponSel.value,
declaredMode: modeSel.value || undefined,
attackKind: kindSel.value,
thrown: thrownChk.querySelector('input').checked,
modifierIds,
};
if (reactionType.value) {
body.reaction = { type: reactionType.value, skillPercent: Number(reactionSkill.value) || 0, weaponName: reactionWeapon.value };
}
try {
const res = await api('POST', '/api/combat/attack', body);
state.combat = res.combatState;
resultBox.innerHTML = `
<p>${tierTag(res.result.attackCheck.tier)} roll ${res.result.attackCheck.roll} vs ${res.result.effectiveSkillPercent}% (base ${res.result.effectiveSkillPercent - res.result.modifierTotal}${res.result.modifierTotal ? `, modifiers ${res.result.modifierTotal > 0 ? '+' : ''}${res.result.modifierTotal}` : ''})</p>
${res.result.hitLocationRoll ? `<p>Hit location: ${res.result.hitLocationRoll.location}</p>` : ''}
${res.result.damageThrough != null ? `<p>Damage through: ${res.result.damageThrough}</p>` : ''}
${res.result.fumble ? `<p>Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}</p>` : ''}
`;
await loadLog();
renderSidebar();
renderLog();
} catch (err) {
resultBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
[
['Attacker', attackerSel], ['Defender', defenderSel], ['Weapon', weaponSel], ['Declared Mode', modeSel],
['Attack Kind', kindSel], [null, thrownChk], ['Defender Reaction', reactionType], ['Reaction Skill %', reactionSkill], [null, reactionWeapon],
].forEach(([label, input]) => {
const row = el(`<div class="field-row"></div>`);
if (label) row.appendChild(el(`<label>${label}</label>`));
row.appendChild(input);
form.appendChild(row);
});
form.appendChild(modifiersWrap);
form.appendChild(resolveBtn);
form.appendChild(resultBox);
wrap.appendChild(form);
wrap.appendChild(el(`<h2>Cast Spell</h2>`));
const spellForm = el(`<div class="card"></div>`);
const casterSel = el(`<select>${combatantOptions()}</select>`);
const targetSel = el(`<select><option value="">(none)</option>${combatantOptions()}</select>`);
const mechanicSel = el(`<select>${SPELL_MECHANIC_IDS.map((m) => `<option value="${m}">${m}</option>`).join('')}</select>`);
const mpInput = el(`<input type="number" value="1" min="1">`);
const castBtn = el(`<button class="button primary">Cast</button>`);
const castResult = el(`<div></div>`);
castBtn.addEventListener('click', async () => {
try {
const res = await api('POST', '/api/combat/cast-spell', {
casterCombatantId: casterSel.value,
targetCombatantId: targetSel.value || undefined,
mechanicId: mechanicSel.value,
mpSpent: Number(mpInput.value) || 1,
});
state.combat = res.combatState;
castResult.innerHTML = `<p>${escapeHtml(JSON.stringify(res.result))}</p>`;
await loadLog();
renderSidebar();
renderLog();
} catch (err) {
castResult.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
[['Caster', casterSel], ['Target', targetSel], ['Spell Mechanic', mechanicSel], ['MP Spent', mpInput]].forEach(([label, input]) => {
const row = el(`<div class="field-row"><label>${label}</label></div>`);
row.appendChild(input);
spellForm.appendChild(row);
});
spellForm.append(castBtn, castResult);
wrap.appendChild(spellForm);
return wrap;
}
// ======================================================================
// LOG
// ======================================================================
function renderLog() {
const root = qs('#log-entries');
root.innerHTML = '';
[...state.logEntries].forEach((entry) => {
root.appendChild(el(`
<div class="log-entry">
<div class="log-meta"><span class="log-type">${entry.type}</span> &middot; ${entry.created_at}</div>
<div>${escapeHtml(entry.summary)}</div>
</div>
`));
});
}
async function searchLogAndRender(q) {
await loadLog(q);
renderLog();
}
async function addNote() {
const input = qs('#note-input');
const text = input.value.trim();
if (!text) return;
await api('POST', '/api/log', { type: 'note', summary: text });
input.value = '';
await loadLog();
renderLog();
}
// ======================================================================
// IMPORT / EXPORT
// ======================================================================
async function exportAllData() {
const res = await fetch('/api/export');
const blob = await res.blob();
downloadBlob(blob, `story-tool-export-${Date.now()}.json`);
state.dirty = false;
}
async function exportLogData() {
const res = await fetch('/api/export/log');
const blob = await res.blob();
downloadBlob(blob, `session-log-${Date.now()}.md`);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function importFile(file) {
const text = await file.text();
const dump = JSON.parse(text);
await api('POST', '/api/import', dump);
await Promise.all([loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings()]);
renderSidebar();
renderMain();
renderLog();
state.dirty = false;
}
// ---------- export reminder modal ----------
let pendingLeaveConfirmed = false;
window.addEventListener('beforeunload', (e) => {
if (state.dirty && !pendingLeaveConfirmed) {
e.preventDefault();
e.returnValue = '';
}
});
// ---------- init ----------
async function init() {
document.querySelectorAll('.tab-btn').forEach((btn) => btn.addEventListener('click', () => setView(btn.dataset.view)));
qs('#export-all-btn').addEventListener('click', exportAllData);
qs('#export-log-btn').addEventListener('click', exportLogData);
qs('#import-file').addEventListener('change', (e) => {
if (e.target.files[0]) importFile(e.target.files[0]);
e.target.value = '';
});
qs('#log-search').addEventListener('input', (e) => searchLogAndRender(e.target.value));
qs('#note-submit').addEventListener('click', addNote);
await Promise.all([
loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(),
loadAttackModifiers(), loadArmorTable(),
]);
setView('tables');
renderLog();
}
init();
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RQ3 Story Tool</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="app-header">
<h1>RQ3 Story Tool</h1>
<div class="header-actions">
<label class="button" for="import-file">Import</label>
<input type="file" id="import-file" accept="application/json" hidden>
<button id="export-all-btn" class="button">Export All</button>
<button id="export-log-btn" class="button">Export Log</button>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<nav class="view-tabs">
<button class="tab-btn" data-view="tables">Tables</button>
<button class="tab-btn" data-view="npcs">NPCs</button>
<button class="tab-btn" data-view="enemies">Enemies</button>
<button class="tab-btn" data-view="combat">Combat</button>
</nav>
<div id="sidebar-content"></div>
</aside>
<main id="main-panel"></main>
<aside class="log-panel">
<h2>Session Log</h2>
<input type="search" id="log-search" placeholder="Search log...">
<div class="note-entry">
<textarea id="note-input" placeholder="Add a note..."></textarea>
<button id="note-submit" class="button">Add Note</button>
</div>
<div id="log-entries"></div>
</aside>
</div>
<div id="export-reminder-modal" class="modal hidden">
<div class="modal-box">
<p>You have unsaved changes. Export before leaving?</p>
<button id="reminder-export" class="button">Export Now</button>
<button id="reminder-leave" class="button">Leave Anyway</button>
<button id="reminder-cancel" class="button">Cancel</button>
</div>
</div>
<script src="vendor/marked.umd.js"></script>
<script src="app.js"></script>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
:root {
--bg: #1b1b1f;
--panel: #25252b;
--panel-alt: #2d2d34;
--border: #3a3a42;
--text: #e8e6e3;
--text-dim: #9a98a3;
--accent: #8b6f4e;
--accent-bright: #c9a06a;
--danger: #b5524a;
--ok: #5d8a5e;
font-size: 15px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
}
button, .button {
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
display: inline-block;
}
button:hover, .button:hover { border-color: var(--accent-bright); color: var(--accent-bright); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: #1b1b1f; }
button.danger { border-color: var(--danger); color: var(--danger); }
input, textarea, select {
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.app-header h1 { font-size: 1.1rem; margin: 0; letter-spacing: 0.03em; }
.header-actions { display: flex; gap: 8px; }
.layout {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 320px;
overflow: hidden;
}
.sidebar, .log-panel {
background: var(--panel);
overflow-y: auto;
padding: 10px;
border-right: 1px solid var(--border);
}
.log-panel { border-right: none; border-left: 1px solid var(--border); }
.view-tabs { display: flex; gap: 4px; margin-bottom: 10px; }
.view-tabs .tab-btn { flex: 1; padding: 6px 4px; font-size: 0.8rem; }
.view-tabs .tab-btn.active { background: var(--accent); color: #1b1b1f; border-color: var(--accent); }
main#main-panel {
overflow-y: auto;
padding: 18px 24px;
}
/* Table tree */
.tree-node { margin-left: 0; }
.tree-children { margin-left: 14px; }
.tree-heading {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px;
cursor: pointer;
border-radius: 3px;
font-size: 0.88rem;
}
.tree-heading:hover { background: var(--panel-alt); }
.tree-heading.selected { background: var(--accent); color: #1b1b1f; }
.tree-toggle { width: 12px; display: inline-block; color: var(--text-dim); }
.tree-table-icon { color: var(--accent-bright); font-size: 0.75rem; }
/* Lists (npcs/enemies/combatants) */
.entity-list { list-style: none; padding: 0; margin: 0; }
.entity-list li {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.88rem;
display: flex;
justify-content: space-between;
gap: 6px;
}
.entity-list li:hover { background: var(--panel-alt); }
.entity-list li.selected { background: var(--accent); color: #1b1b1f; }
.entity-list li.status-dead, .entity-list li.status-inactive { opacity: 0.5; text-decoration: line-through; }
/* Cards */
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
}
.card h2 { margin-top: 0; }
.field-row { margin-bottom: 10px; }
.field-row label { display: block; font-size: 0.78rem; color: var(--text-dim); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.04em; }
.field-row .field-value { display: flex; align-items: baseline; gap: 8px; }
.field-row .field-value span { flex: 1; }
.field-row textarea, .field-row input { width: 100%; }
.checkbox-field { display: inline-flex; align-items: center; gap: 6px; width: auto; }
.checkbox-field input { width: auto; }
.checkbox-field-row { margin-bottom: 4px; font-size: 0.85rem; }
.button-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.char-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; margin-bottom: 12px; }
.char-grid div { text-align: center; background: var(--panel-alt); border-radius: 4px; padding: 6px 2px; }
.char-grid .char-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; }
.char-grid .char-value { font-size: 1.1rem; }
.hit-loc-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-bottom: 12px; }
.hit-loc-table th, .hit-loc-table td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
.hit-loc-table tr.disabled { color: var(--danger); }
.weapon-row, .spell-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
.roll-result {
background: var(--panel-alt);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 14px;
margin-bottom: 14px;
}
.roll-result .roll-table-name { color: var(--text-dim); font-size: 0.8rem; }
.roll-result .roll-cells { font-size: 1.05rem; margin-top: 6px; }
.roll-result .roll-cells p { margin: 4px 0; }
.tag {
display: inline-block;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 10px;
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text-dim);
text-transform: uppercase;
}
.tag.tier-critical { background: var(--accent); color: #1b1b1f; border-color: var(--accent); }
.tag.tier-special { background: var(--accent-bright); color: #1b1b1f; border-color: var(--accent-bright); }
.tag.tier-fumble, .tag.tier-failure { background: var(--danger); color: #fff; border-color: var(--danger); }
/* Log */
.log-panel h2 { font-size: 0.95rem; margin: 0 0 8px; }
#log-search { width: 100%; margin-bottom: 8px; }
.note-entry { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.note-entry textarea { height: 50px; resize: vertical; }
#log-entries { display: flex; flex-direction: column-reverse; gap: 8px; }
.log-entry { font-size: 0.8rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.log-entry .log-meta { color: var(--text-dim); font-size: 0.72rem; }
.log-entry .log-type { text-transform: uppercase; letter-spacing: 0.03em; }
.modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
}
.modal.hidden { display: none; }
.modal-box { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 20px; display: flex; flex-direction: column; gap: 10px; max-width: 360px; }
.empty-state { color: var(--text-dim); font-style: italic; padding: 20px 0; }
.error-message { color: var(--danger); border: 1px solid var(--danger); border-radius: 4px; padding: 8px 10px; background: rgba(181, 82, 74, 0.12); }
.combat-round-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.sr-track { display: flex; flex-direction: column; gap: 6px; }
.sr-slot { display: flex; gap: 8px; align-items: center; }
.sr-slot .sr-num { width: 28px; text-align: center; font-weight: bold; color: var(--accent-bright); }
+2735
View File
File diff suppressed because it is too large Load Diff
+947
View File
@@ -0,0 +1,947 @@
// RQ3 rules engine. Pure functions only — no DB or HTTP dependencies.
// ---------- dice ----------
function rollDie(sides) {
return 1 + Math.floor(Math.random() * sides);
}
function rollDice(count, sides) {
let total = 0;
for (let i = 0; i < count; i++) total += rollDie(sides);
return total;
}
// "1d6", "2d8+2", "1d6-2" etc.
function rollNotation(notation) {
const m = /^(\d+)d(\d+)\s*([+-]\s*\d+)?$/i.exec(notation.trim());
if (!m) throw new Error(`Invalid dice notation: "${notation}"`);
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
const mod = m[3] ? parseInt(m[3].replace(/\s/g, ''), 10) : 0;
return rollDice(count, sides) + mod;
}
function maxNotation(notation) {
const m = /^(\d+)d(\d+)\s*([+-]\s*\d+)?$/i.exec(notation.trim());
if (!m) throw new Error(`Invalid dice notation: "${notation}"`);
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
const mod = m[3] ? parseInt(m[3].replace(/\s/g, ''), 10) : 0;
return count * sides + mod;
}
function rollPercentile() {
return rollDie(100);
}
// ---------- characteristic rolls ----------
const CHARACTERISTIC_ROLLS = {
str: '3d6',
con: '3d6',
dex: '3d6',
pow: '3d6',
app: '3d6',
siz: '2d6+6',
int: '2d6+6',
};
function rollCharacteristics() {
const out = {};
for (const [key, notation] of Object.entries(CHARACTERISTIC_ROLLS)) {
out[key] = rollNotation(notation);
}
return out;
}
// ---------- Strike Rank ----------
// SR = DEX Strike Rank + SIZ Strike Rank Modifier (no INT). Weapon SR adds on top for attacks.
const DEX_STRIKE_RANK_TABLE = [
{ min: 1, max: 9, sr: 4 },
{ min: 10, max: 15, sr: 3 },
{ min: 16, max: 19, sr: 2 },
{ min: 20, max: Infinity, sr: 1 },
];
const SIZ_STRIKE_RANK_MODIFIER_TABLE = [
{ min: 1, max: 9, sr: 3 },
{ min: 10, max: 15, sr: 2 },
{ min: 16, max: 19, sr: 1 },
{ min: 20, max: Infinity, sr: 0 },
];
function lookupBand(table, value) {
const band = table.find((b) => value >= b.min && value <= b.max);
if (!band) throw new Error(`No band found for value ${value}`);
return band.sr;
}
function dexStrikeRank(dex) {
return lookupBand(DEX_STRIKE_RANK_TABLE, dex);
}
function sizStrikeRankModifier(siz) {
return lookupBand(SIZ_STRIKE_RANK_MODIFIER_TABLE, siz);
}
// Base SR for a combatant, before any weapon SR is added for a specific attack.
function baseStrikeRank({ dex, siz }) {
return dexStrikeRank(dex) + sizStrikeRankModifier(siz);
}
// ---------- Hit Points per Location ----------
// Keyed by total HP band (CON+SIZ derived). Real per-location values, not approximations.
const HIT_LOCATION_HP_BANDS = [
{ min: 1, max: 3, leg: 1, abdomen: 1, chest: 2, arm: 1, head: 1 },
{ min: 4, max: 6, leg: 2, abdomen: 2, chest: 3, arm: 2, head: 2 },
{ min: 7, max: 9, leg: 3, abdomen: 3, chest: 4, arm: 3, head: 3 },
{ min: 10, max: 12, leg: 4, abdomen: 4, chest: 5, arm: 3, head: 4 },
{ min: 13, max: 15, leg: 5, abdomen: 5, chest: 6, arm: 4, head: 5 },
{ min: 16, max: 18, leg: 6, abdomen: 6, chest: 8, arm: 5, head: 6 },
{ min: 19, max: 21, leg: 7, abdomen: 7, chest: 9, arm: 6, head: 7 },
];
// Beyond 21 total HP the band table (drawn from the rulebook) doesn't extend;
// extrapolate by reusing the top band's per-point ratios rather than guessing.
function hitLocationHpBand(totalHp) {
const band = HIT_LOCATION_HP_BANDS.find((b) => totalHp >= b.min && totalHp <= b.max);
if (band) return band;
const top = HIT_LOCATION_HP_BANDS[HIT_LOCATION_HP_BANDS.length - 1];
const scale = totalHp / top.max;
return {
leg: Math.ceil(top.leg * scale),
abdomen: Math.ceil(top.abdomen * scale),
chest: Math.ceil(top.chest * scale),
arm: Math.ceil(top.arm * scale),
head: Math.ceil(top.head * scale),
};
}
function computeHitLocations(totalHp) {
const band = hitLocationHpBand(totalHp);
return [
{ location_name: 'R-Leg', max_hp: band.leg },
{ location_name: 'L-Leg', max_hp: band.leg },
{ location_name: 'Abdomen', max_hp: band.abdomen },
{ location_name: 'Chest', max_hp: band.chest },
{ location_name: 'R-Arm', max_hp: band.arm },
{ location_name: 'L-Arm', max_hp: band.arm },
{ location_name: 'Head', max_hp: band.head },
].map((loc) => ({ ...loc, current_hp: loc.max_hp, armor_ap: 0, disabled: false }));
}
// ---------- Hit Location rolls (humanoid table reused for all combatants) ----------
const HUMANOID_HIT_LOCATIONS_MELEE = [
{ min: 1, max: 4, location: 'R-Leg' },
{ min: 5, max: 8, location: 'L-Leg' },
{ min: 9, max: 11, location: 'Abdomen' },
{ min: 12, max: 12, location: 'Chest' },
{ min: 13, max: 15, location: 'R-Arm' },
{ min: 16, max: 18, location: 'L-Arm' },
{ min: 19, max: 20, location: 'Head' },
];
const HUMANOID_HIT_LOCATIONS_MISSILE = [
{ min: 1, max: 3, location: 'R-Leg' },
{ min: 4, max: 6, location: 'L-Leg' },
{ min: 7, max: 10, location: 'Abdomen' },
{ min: 11, max: 15, location: 'Chest' },
{ min: 16, max: 17, location: 'R-Arm' },
{ min: 18, max: 19, location: 'L-Arm' },
{ min: 20, max: 20, location: 'Head' },
];
function rollHitLocation(attackKind = 'melee') {
const table = attackKind === 'missile' ? HUMANOID_HIT_LOCATIONS_MISSILE : HUMANOID_HIT_LOCATIONS_MELEE;
const roll = rollDie(20);
const band = table.find((b) => roll >= b.min && roll <= b.max);
return { roll, location: band.location };
}
// ---------- Shielded hit locations ----------
// Which locations a given shield covers (additional AP applies if that location is hit while the shield is raised).
const SHIELD_COVERAGE = {
Buckler: ['shield-arm'],
'Heater/Target': ['shield-arm', 'extra-1'],
Hoplite: ['shield-arm', 'extra-1'],
Kite: ['shield-arm', 'extra-2'],
'Viking Round': ['contiguous'],
};
// ---------- Damage Bonus (STR+SIZ) ----------
const DAMAGE_BONUS_BANDS = [
{ min: 1, max: 12, notation: '-1d4' },
{ min: 13, max: 24, notation: '0' },
{ min: 25, max: 32, notation: '1d4' },
{ min: 33, max: 40, notation: '1d6' },
{ min: 41, max: 56, notation: '2d6' },
];
const DAMAGE_BONUS_BRACKET_SIZE = 16;
const DAMAGE_BONUS_BRACKET_DIE = '1d6';
function damageBonusNotation(strPlusSiz) {
const band = DAMAGE_BONUS_BANDS.find((b) => strPlusSiz >= b.min && strPlusSiz <= b.max);
if (band) return band.notation;
if (strPlusSiz < 1) return '-1d4';
const extraBrackets = Math.ceil((strPlusSiz - 56) / DAMAGE_BONUS_BRACKET_SIZE);
const parts = ['2d6'];
for (let i = 0; i < extraBrackets; i++) parts.push(DAMAGE_BONUS_BRACKET_DIE);
return parts.join('+');
}
function rollDamageBonus(strPlusSiz) {
const notation = damageBonusNotation(strPlusSiz);
if (notation === '0') return 0;
if (notation.startsWith('-')) return -rollNotation(notation.slice(1));
return notation.split('+').reduce((sum, part) => sum + rollNotation(part), 0);
}
// ---------- Skill Results (success tiers) ----------
// Exact rulebook bands - not a clean formula, so transcribed as data rather than computed.
// crit/spec are the upper bound of a 01-N range; fumble is the lower bound of an N-100 range.
const SKILL_RESULT_BANDS = [
{ skillMax: 7, crit: 1, spec: 1, fumbleMin: 96 },
{ skillMax: 10, crit: 1, spec: 2, fumbleMin: 96 },
{ skillMax: 12, crit: 1, spec: 2, fumbleMin: 97 },
{ skillMax: 17, crit: 1, spec: 3, fumbleMin: 97 },
{ skillMax: 22, crit: 1, spec: 4, fumbleMin: 97 },
{ skillMax: 27, crit: 1, spec: 5, fumbleMin: 97 },
{ skillMax: 29, crit: 1, spec: 6, fumbleMin: 97 },
{ skillMax: 30, crit: 2, spec: 6, fumbleMin: 97 },
{ skillMax: 32, crit: 2, spec: 6, fumbleMin: 98 },
{ skillMax: 37, crit: 2, spec: 7, fumbleMin: 98 },
{ skillMax: 42, crit: 2, spec: 8, fumbleMin: 98 },
{ skillMax: 47, crit: 2, spec: 9, fumbleMin: 98 },
{ skillMax: 49, crit: 2, spec: 10, fumbleMin: 98 },
{ skillMax: 50, crit: 3, spec: 10, fumbleMin: 98 },
{ skillMax: 52, crit: 3, spec: 10, fumbleMin: 99 },
{ skillMax: 57, crit: 3, spec: 11, fumbleMin: 99 },
{ skillMax: 62, crit: 3, spec: 12, fumbleMin: 99 },
{ skillMax: 67, crit: 3, spec: 13, fumbleMin: 99 },
{ skillMax: 69, crit: 3, spec: 14, fumbleMin: 99 },
{ skillMax: 70, crit: 4, spec: 14, fumbleMin: 99 },
{ skillMax: 72, crit: 4, spec: 14, fumbleMin: 100 },
{ skillMax: 77, crit: 4, spec: 15, fumbleMin: 100 },
{ skillMax: 82, crit: 4, spec: 16, fumbleMin: 100 },
{ skillMax: 87, crit: 4, spec: 17, fumbleMin: 100 },
{ skillMax: 89, crit: 4, spec: 18, fumbleMin: 100 },
{ skillMax: 92, crit: 5, spec: 18, fumbleMin: 100 },
{ skillMax: 97, crit: 5, spec: 19, fumbleMin: 100 },
{ skillMax: Infinity, crit: 5, spec: 20, fumbleMin: 100 },
];
function skillResultBand(skillPercent) {
const clamped = Math.max(1, skillPercent);
return SKILL_RESULT_BANDS.find((b) => clamped <= b.skillMax) || SKILL_RESULT_BANDS[SKILL_RESULT_BANDS.length - 1];
}
// Resolves a skill check into a tier. Returns { roll, skillPercent, tier }
// tier is one of: 'critical' | 'special' | 'success' | 'failure' | 'fumble'
function resolveSkillCheck(skillPercent) {
const band = skillResultBand(skillPercent);
const roll = rollPercentile();
let tier;
if (roll <= band.crit) tier = 'critical';
else if (roll <= band.spec) tier = 'special';
else if (roll <= skillPercent) tier = 'success';
else if (roll >= band.fumbleMin) tier = 'fumble';
else tier = 'failure';
return { roll, skillPercent, tier };
}
// ---------- Weapon type classification ----------
// 'impale' | 'slash' | 'crush' | 'dual' (declared per-attack) | 'none' (no special-tier bonus)
// 'dual' weapons resolve to 'impale' or 'slash' based on the declared mode for that attack.
const WEAPON_TYPE = {
IMPALE: 'impale',
SLASH: 'slash',
CRUSH: 'crush',
DUAL: 'dual',
NONE: 'none',
};
// ---------- Melee Weapons ----------
// category, weapon, damage, strMin, dexMin, enc, skillPercent (base), ap, sr, type
const MELEE_WEAPONS = [
{ category: 'Axe, 1H', weapon: 'Battleaxe', damage: '1d8+2', strMin: 13, dexMin: 9, enc: 1.0, skillPercent: 10, ap: 8, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 1H', weapon: 'Hatchet', damage: '1d6+1', strMin: 7, dexMin: 9, enc: 0.5, skillPercent: 10, ap: 6, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 2H', weapon: 'Battleaxe', damage: '1d8+2', strMin: 9, dexMin: 9, enc: 1.0, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 2H', weapon: 'Great Axe', damage: '2d6+2', strMin: 11, dexMin: 9, enc: 2.0, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 2H', weapon: 'Halberd', damage: '3d6', impaleDamage: '4d6', strMin: 13, dexMin: 9, enc: 3.0, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.DUAL },
{ category: 'Axe, 2H', weapon: 'Poleaxe', damage: '3d6', strMin: 11, dexMin: 9, enc: 2.5, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.SLASH },
{ category: 'Dagger', weapon: 'Dagger', damage: '1d4+2', strMin: null, dexMin: null, enc: 0.5, skillPercent: 15, ap: 6, sr: 3, type: WEAPON_TYPE.IMPALE },
{ category: 'Dagger', weapon: 'Knife', damage: '1d3+1', strMin: null, dexMin: null, enc: 0.2, skillPercent: 15, ap: 4, sr: 3, type: WEAPON_TYPE.IMPALE },
{ category: 'Dagger', weapon: 'Main Gauche', damage: '1d4+2', strMin: null, dexMin: 9, enc: 0.5, skillPercent: 10, ap: 10, sr: 3, type: WEAPON_TYPE.IMPALE },
{ category: 'Dagger', weapon: 'Sai', damage: '1d6', strMin: null, dexMin: 11, enc: 1.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Fist', weapon: 'Cestus, Heavy', damage: '1d3+2', strMin: 11, dexMin: null, enc: 1.5, skillPercent: 15, ap: 8, sr: 3, type: WEAPON_TYPE.CRUSH },
{ category: 'Fist', weapon: 'Cestus, Light', damage: '1d3+1', strMin: 7, dexMin: null, enc: 1.0, skillPercent: 15, ap: 4, sr: 3, type: WEAPON_TYPE.CRUSH },
{ category: 'Fist', weapon: 'Fighting Claw', damage: '1d4+1', strMin: 7, dexMin: 9, enc: 0.1, skillPercent: 15, ap: null, sr: 3, type: WEAPON_TYPE.SLASH },
{ category: 'Flail, 1H', weapon: 'Ball & Chain', damage: '1d10+1', strMin: 11, dexMin: 7, enc: 2.0, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Flail, 1H', weapon: 'Grain', damage: '1d6', strMin: 9, dexMin: null, enc: 1.0, skillPercent: 10, ap: 6, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Flail, 1H', weapon: 'Three Chain', damage: '1d6+2', strMin: 9, dexMin: 13, enc: 2.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Flail, 2H', weapon: 'Military', damage: '2d6+2', strMin: 9, dexMin: null, enc: 2.5, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Hammer, 1H', weapon: 'Warhammer', damage: '1d6+2', strMin: 11, dexMin: 9, enc: 2.0, skillPercent: 10, ap: 8, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Hammer, 2H', weapon: 'Great Hammer', damage: '2d6+2', strMin: 9, dexMin: 9, enc: 2.5, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.DUAL },
{ category: 'Mace, 1H', weapon: 'Heavy Mace', damage: '1d10', strMin: 13, dexMin: 7, enc: 2.5, skillPercent: 15, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Mace, 1H', weapon: 'Light Mace', damage: '1d8', strMin: 7, dexMin: 7, enc: 1.0, skillPercent: 15, ap: 6, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Mace, 1H', weapon: 'Singlestick', damage: '1d6', strMin: 7, dexMin: 9, enc: 0.5, skillPercent: 15, ap: 5, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Mace, 1H', weapon: 'Wooden Club', damage: '1d6', strMin: null, dexMin: 7, enc: 0.5, skillPercent: 15, ap: 4, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Heavy Mace', damage: '1d10', strMin: 9, dexMin: 7, enc: 2.5, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Quarterstaff', damage: '1d8', strMin: 9, dexMin: 9, enc: 1.5, skillPercent: 10, ap: 8, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Troll Maul', damage: '2d8', strMin: 17, dexMin: 7, enc: 5.5, skillPercent: 10, ap: 16, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'War Maul', damage: '1d10+2', strMin: 11, dexMin: 7, enc: 2.5, skillPercent: 10, ap: 12, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Work Maul', damage: '2d6+2', strMin: 13, dexMin: 7, enc: 4.0, skillPercent: 10, ap: 12, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Rapier', weapon: 'Rapier', damage: '1d6+1', strMin: 7, dexMin: 13, enc: 1.0, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Shortsword', weapon: 'Gladius', damage: '1d6+1', strMin: null, dexMin: null, enc: 1.0, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Shortsword', weapon: 'Kukri', damage: '1d4+3', strMin: null, dexMin: 11, enc: 0.5, skillPercent: 10, ap: 8, sr: 3, type: WEAPON_TYPE.SLASH },
{ category: 'Shield', weapon: 'Buckler', damage: '1d4', strMin: null, dexMin: 9, enc: 1.0, skillPercent: 5, ap: 8, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Heater/Target', damage: '1d6', strMin: 9, dexMin: null, enc: 3.0, skillPercent: 15, ap: 12, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Hoplite Shield', damage: '1d6', strMin: 12, dexMin: null, enc: 7.0, skillPercent: 15, ap: 18, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Kite', damage: '1d6', strMin: 11, dexMin: null, enc: 5.0, skillPercent: 15, ap: 16, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Viking Round', damage: '1d6', strMin: 9, dexMin: 7, enc: 4.0, skillPercent: 15, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Spear, 1H', weapon: 'Javelin', damage: '1d6+1', strMin: 7, dexMin: 7, enc: 1.5, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 1H', weapon: 'Lance (mounted)', damage: '1d10+1', strMin: 7, dexMin: 7, enc: 3.5, skillPercent: 5, ap: 10, sr: 0, type: WEAPON_TYPE.IMPALE, noParry: true },
{ category: 'Spear, 1H', weapon: 'Pilum', damage: '1d6+1', strMin: 9, dexMin: 7, enc: 2.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 1H', weapon: 'Short Spear', damage: '1d8+1', strMin: 7, dexMin: 7, enc: 2.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Long Spear', damage: '1d10+1', strMin: 9, dexMin: 7, enc: 2.0, skillPercent: 15, ap: 10, sr: 1, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Naginata', damage: '2d6+2', strMin: 7, dexMin: 11, enc: 2.0, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Pike', damage: '2d6+2', strMin: 11, dexMin: 7, enc: 3.5, skillPercent: 15, ap: 12, sr: 0, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Short Spear', damage: '1d8+1', strMin: null, dexMin: 7, enc: 2.0, skillPercent: 15, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Sword, 1H', weapon: 'Bastard Sword', damage: '1d10+1', strMin: 13, dexMin: 9, enc: 2.0, skillPercent: 10, ap: 12, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Sword, 1H', weapon: 'Broadsword', damage: '1d8+1', strMin: 9, dexMin: 7, enc: 1.5, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Sword, 1H', weapon: 'Scimitar', damage: '1d6+2', strMin: 7, dexMin: 11, enc: 1.5, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Sword, 2H', weapon: 'Bastard Sword', damage: '1d10+1', strMin: 9, dexMin: 9, enc: 2.0, skillPercent: 5, ap: 12, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Sword, 2H', weapon: 'Greatsword', damage: '2d8', strMin: 11, dexMin: 13, enc: 3.5, skillPercent: 5, ap: 12, sr: 1, type: WEAPON_TYPE.SLASH },
{ category: 'Tools', weapon: 'Hoe (2H)', damage: '1d6', strMin: 7, dexMin: 7, enc: 2.0, skillPercent: 10, ap: 8, sr: 1, type: WEAPON_TYPE.CRUSH, separateSkill: true },
{ category: 'Tools', weapon: 'Scythe', damage: '2d6', strMin: 11, dexMin: 9, enc: 2.5, skillPercent: 10, ap: 8, sr: 1, type: WEAPON_TYPE.SLASH, separateSkill: true },
{ category: 'Tools', weapon: 'Sickle (1H)', damage: '1d6', strMin: null, dexMin: null, enc: 0.5, skillPercent: 5, ap: 6, sr: 3, type: WEAPON_TYPE.DUAL, separateSkill: true },
{ category: 'Tools', weapon: 'Spade (2H)', damage: '1d6+2', strMin: 7, dexMin: 7, enc: 1.5, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.CRUSH, separateSkill: true },
];
// ---------- Natural Weapons ----------
const NATURAL_WEAPONS = [
{ weapon: 'Claw', damage: '1d6', skillPercent: 25, sr: 3, type: WEAPON_TYPE.SLASH },
{ weapon: 'Fist', damage: '1d3', skillPercent: 25, sr: 3, type: WEAPON_TYPE.CRUSH },
{ weapon: 'Grapple', damage: '1d6', skillPercent: 25, sr: 3, type: WEAPON_TYPE.CRUSH },
{ weapon: 'Head Butt', damage: '1d4', skillPercent: 10, sr: 3, type: WEAPON_TYPE.CRUSH },
{ weapon: 'Kick', damage: '1d6', skillPercent: 15, sr: 3, type: WEAPON_TYPE.CRUSH },
];
// ---------- Missile Weapons ----------
// rateOfFire: '1/SR' | '1/MR' | '1/2MR' | '1/3MR' etc.
const MISSILE_WEAPONS = [
{ weapon: 'Atlatl', strMin: 7, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d6', damageNote: 'modifier, added to thrown weapon damage', ap: 6, rangeShort: null, rangeLong: 20, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Bow, Self', strMin: 9, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d6+1', ap: 5, rangeShort: 90, rangeLong: 120, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Bow, Long', strMin: 11, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d8+1', ap: 6, rangeShort: 90, rangeLong: 275, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Bow, Composite', strMin: 13, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d8+1', ap: 7, rangeShort: 120, rangeLong: 225, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Crossbow, Heavy', strMin: 13, dexMin: 7, skillPercent: 25, enc: 8.0, damage: '2d6+2', ap: 10, rangeShort: 55, rangeLong: 300, rateOfFire: '1/3MR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Crossbow, Medium', strMin: 11, dexMin: 7, skillPercent: 25, enc: 4.8, damage: '2d4+2', ap: 8, rangeShort: 50, rangeLong: 270, rateOfFire: '1/2MR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Crossbow, Light', strMin: 9, dexMin: 7, skillPercent: 25, enc: 3.4, damage: '1d6+2', ap: 6, rangeShort: 40, rangeLong: 225, rateOfFire: '1/2MR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Repeater (12 shots)', strMin: 9, dexMin: 7, skillPercent: 25, enc: 3.2, damage: '1d6+2', ap: 6, rangeShort: 60, rangeLong: 170, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE, reloadAfter12: 'DEX SR + 3' },
{ weapon: 'Stonebow', strMin: 11, dexMin: 7, skillPercent: 25, enc: 3.4, damage: '1d6+2', ap: 6, rangeShort: 30, rangeLong: 200, rateOfFire: '1/MR', type: WEAPON_TYPE.CRUSH },
{ weapon: 'Blowgun', strMin: null, dexMin: 11, skillPercent: 10, enc: 0.5, damage: '1d3', ap: 4, rangeShort: 30, rangeLong: 30, rateOfFire: '1/MR', type: WEAPON_TYPE.IMPALE, poisonNote: 'usually 2D10 potency' },
{ weapon: 'Sling', strMin: null, dexMin: 11, skillPercent: 5, enc: 0.1, damage: '1d8', ap: null, rangeShort: 100, rangeLong: 100, rateOfFire: '1/MR', type: WEAPON_TYPE.CRUSH },
{ weapon: 'Staff Sling', strMin: 9, dexMin: 11, skillPercent: 10, enc: 0.5, damage: '1d10', ap: 10, rangeShort: 120, rangeLong: 120, rateOfFire: '1/MR', type: WEAPON_TYPE.CRUSH },
{ weapon: 'Bolas', strMin: 9, dexMin: 13, skillPercent: 5, enc: 3.0, damage: null, ap: null, rangeShort: 15, rangeLong: 25, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Boomerang, War', strMin: 13, dexMin: 9, skillPercent: 10, enc: 1.0, damage: '1d8', ap: 6, rangeShort: 30, rangeLong: 50, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Boomerang, Hunting', strMin: 9, dexMin: 11, skillPercent: 5, enc: 0.5, damage: '1d4', ap: 3, rangeShort: 50, rangeLong: 50, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Dart', strMin: null, dexMin: 9, skillPercent: 10, enc: 0.5, damage: '1d6', ap: 4, rangeShort: 20, rangeLong: 30, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Javelin (thrown)', strMin: 9, dexMin: 9, skillPercent: 10, enc: 1.5, damage: '1d8', ap: 8, rangeShort: 20, rangeLong: 50, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Shuriken', strMin: null, dexMin: 13, skillPercent: 5, enc: 0.1, damage: '1d3', ap: null, rangeShort: 20, rangeLong: 30, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Throwing Axe', strMin: 9, dexMin: 11, skillPercent: 10, enc: 0.5, damage: '1d6', ap: 6, rangeShort: 20, rangeLong: 20, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Throwing Knife', strMin: null, dexMin: 11, skillPercent: 5, enc: 0.2, damage: '1d4', ap: 4, rangeShort: 20, rangeLong: 20, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Thrown Rock', strMin: null, dexMin: null, skillPercent: 15, enc: 0.5, damage: '1d3', ap: null, rangeShort: 20, rangeLong: 20, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Rope Lasso', strMin: 9, dexMin: 13, skillPercent: 5, enc: 1.0, damage: null, ap: null, rangeShort: 10, rangeLong: 10, rateOfFire: '1/5MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Pole Lasso', strMin: 9, dexMin: 9, skillPercent: 20, enc: 3.0, damage: null, ap: 4, rangeShort: 3, rangeLong: 3, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Whip', strMin: 9, dexMin: 9, skillPercent: 10, enc: 1.0, damage: '1d4', ap: 6, rangeShort: 5, rangeLong: 5, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
];
// ---------- Damage bonus (max, for Crush special hits) ----------
function maxDamageBonus(strPlusSiz) {
const notation = damageBonusNotation(strPlusSiz);
if (notation === '0') return 0;
if (notation.startsWith('-')) {
const m = /^(\d+)d(\d+)$/i.exec(notation.slice(1));
return m ? -parseInt(m[1], 10) : 0; // least-bad case for a penalty
}
return notation.split('+').reduce((sum, part) => sum + maxNotation(part), 0);
}
// ---------- Attack damage resolution ----------
// Critical is universal (Attack Results table): max weapon damage + damage bonus, ignores all armor.
// Special uses the weapon-type-specific Impale/Slash/Crush formula.
// Simple (success) is normal damage; knockback if it exceeds the target's SIZ.
// thrown: pass true to halve (round down) the rolled damage bonus, per RQ3 thrown-weapon rule.
function resolveWeaponEffectiveType(weapon, declaredMode) {
if (weapon.type === WEAPON_TYPE.DUAL) {
if (declaredMode !== WEAPON_TYPE.IMPALE && declaredMode !== WEAPON_TYPE.SLASH) {
throw new Error(`"${weapon.weapon}" is dual-mode; declaredMode must be 'impale' or 'slash'`);
}
return declaredMode;
}
return weapon.type;
}
function resolveAttackDamage({ weapon, tier, strPlusSiz, declaredMode, thrown = false }) {
const effectiveType = resolveWeaponEffectiveType(weapon, declaredMode);
let bonus = rollDamageBonus(strPlusSiz);
if (thrown) bonus = Math.floor(bonus / 2);
if (tier === 'fumble' || tier === 'failure') {
return { damage: 0, ignoresArmor: false, knockback: false, special: null };
}
if (tier === 'critical') {
const maxWeaponDamage = weapon.damage ? maxNotation(weapon.damage) : 0;
return { damage: maxWeaponDamage + bonus, ignoresArmor: true, knockback: true, special: null };
}
if (tier === 'special') {
if (effectiveType === WEAPON_TYPE.IMPALE) {
let damage;
if (weapon.impaleDamage) {
damage = rollNotation(weapon.impaleDamage) + bonus;
} else {
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
const maxWeaponDamage = weapon.damage ? maxNotation(weapon.damage) : 0;
damage = rolled + bonus + maxWeaponDamage;
}
return { damage, ignoresArmor: false, knockback: true, special: 'impale' };
}
if (effectiveType === WEAPON_TYPE.SLASH) {
const damage = (weapon.damage ? rollNotation(weapon.damage) + rollNotation(weapon.damage) : 0) + bonus;
return { damage, ignoresArmor: false, knockback: true, special: 'slash' };
}
if (effectiveType === WEAPON_TYPE.CRUSH) {
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
const damage = rolled + maxDamageBonus(strPlusSiz);
return { damage, ignoresArmor: false, knockback: true, special: 'crush' };
}
// NONE type (e.g. thrown axe/knife/boomerang/rock): no special-tier bonus, behaves as Simple.
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
return { damage: rolled + bonus, ignoresArmor: false, knockback: true, special: null };
}
// tier === 'success' (Simple)
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
const damage = rolled + bonus;
return { damage, ignoresArmor: false, knockback: null, special: null }; // caller compares damage to target SIZ for knockback
}
// ---------- Impale / Slash stuck-weapon follow-up ----------
function attemptWeaponRemoval(skillPercent, kind) {
const multiplier = kind === 'slash' ? 0.6 : 0.4; // Impale = 40% of skill, Slash = 60% of skill
const threshold = skillPercent * multiplier;
const band = skillResultBand(skillPercent);
const roll = rollPercentile();
if (roll >= band.fumbleMin) return { roll, success: false, weaponBreaks: true };
return { roll, success: roll <= threshold, weaponBreaks: false };
}
function removeStuckWeaponFromSelf(strPlusCon) {
return rollPercentile() <= strPlusCon;
}
function removeStuckWeaponWithFirstAid(firstAidSkillPercent) {
return rollPercentile() <= firstAidSkillPercent;
}
// ---------- Parry & Dodge ----------
// Per the Melee Sequence rules: a combatant may take 2 of {attack, parry, dodge} per round.
// Parry Results table effects don't gate on the attack's tier - each parry tier has its
// own fixed effect, and AP absorption naturally scales with how much damage gets through.
// critical: blocks all damage, from anything.
// special: absorbs AP like simple, plus an entangle/weapon-damage flavor effect (not
// mechanically resolved here - flagged for the caller/log to narrate).
// success ("simple"): absorbs the parrying item's AP; AP is reduced by 1 if damage exceeds it.
// failure: the attack hits normally.
// fumble: the attack hits normally, and the parrier rolls on the fumble table.
function resolveParry({ parrySkillPercent }) {
const check = resolveSkillCheck(parrySkillPercent);
const effect =
check.tier === 'critical' ? 'block-all' :
check.tier === 'special' ? 'absorb-ap-and-entangle' :
check.tier === 'success' ? 'absorb-ap' :
'attack-hits';
return { ...check, effect };
}
// Applies a parry's effect to incoming damage. parryingItemAp is the weapon/shield's AP.
// Returns { damageThrough, apReducedBy1 }.
function applyParryToDamage({ parryEffect, damage, parryingItemAp }) {
if (parryEffect === 'block-all') return { damageThrough: 0, apReducedBy1: false };
if (parryEffect === 'absorb-ap' || parryEffect === 'absorb-ap-and-entangle') {
const ap = parryingItemAp || 0;
const damageThrough = Math.max(0, damage - ap);
return { damageThrough, apReducedBy1: damage > ap };
}
return { damageThrough: damage, apReducedBy1: false }; // 'attack-hits'
}
function resolveDodge({ dodgeSkillPercent, attackTier }) {
const check = resolveSkillCheck(dodgeSkillPercent);
let avoided;
if (check.tier === 'fumble') avoided = false; // automatic normal hit unless rolled better (already lowest tier)
else if (check.tier === 'failure') avoided = false;
else if (attackTier === 'critical') avoided = check.tier === 'critical';
else if (attackTier === 'special') avoided = check.tier === 'critical' || check.tier === 'special';
else avoided = true; // success/special/critical dodge all avoid a normal (simple) attack
return { ...check, avoided };
}
// ---------- Attack Modifiers (situational, additive to skill %) ----------
const ATTACK_MODIFIERS = [
{ id: 'target-helpless', modifier: 25, description: 'Target helpless' },
{ id: 'target-surprised-noncombat', modifier: 20, description: 'Target surprised during non-combat, or knocked down' },
{ id: 'target-surprised-combat', modifier: 10, description: 'Target surprised during combat' },
{ id: 'unshielded-side-or-behind', modifier: 10, description: "Attack from target's unshielded side or from behind" },
{ id: 'prepared-attack', modifier: 10, description: 'Prepared attack (wait one MR)' },
{ id: 'attacking-from-above', modifier: 10, description: 'Attacking from above target' },
{ id: 'target-large', modifier: 5, perSiz: 10, sizThreshold: 10, direction: 'over', description: 'Target is above SIZ 10 (+5 per 10 SIZ over)' },
{ id: 'target-unseen', modifier: -75, description: 'Target cannot be seen or sensed' },
{ id: 'attacker-knocked-down', modifier: -20, description: 'Attacker has been knocked down' },
{ id: 'target-moving-missile', modifier: -10, description: 'Target moving (missile weapon only)' },
{ id: 'target-small', modifier: -10, perSiz: 1, sizThreshold: 4, direction: 'under', description: 'Target is below SIZ 4 (-10 per SIZ under)' },
{ id: 'attacker-mounted-moving', modifier: -10, description: 'Attacker is riding a moving animal' },
];
// Sums the flat modifiers for the given ids, plus any per-SIZ modifiers scaled by the
// target's actual SIZ (for 'target-large'/'target-small'). Returns the net skill % delta.
function sumAttackModifiers(selectedIds, { targetSiz } = {}) {
return selectedIds.reduce((total, id) => {
const mod = ATTACK_MODIFIERS.find((m) => m.id === id);
if (!mod) return total;
if (!mod.perSiz) return total + mod.modifier;
if (targetSiz == null) return total;
const delta = mod.direction === 'over' ? targetSiz - mod.sizThreshold : mod.sizThreshold - targetSiz;
if (delta <= 0) return total;
return total + mod.modifier * Math.ceil(delta / mod.perSiz);
}, 0);
}
// ---------- Character Culture & Cultural Weapon Bonuses ----------
const CHARACTER_CULTURES = [
{ min: 1, max: 1, culture: 'Primitive' },
{ min: 2, max: 3, culture: 'Nomad' },
{ min: 4, max: 6, culture: 'Barbarian' },
{ min: 7, max: 8, culture: 'Civilized' },
];
function rollCulture() {
const roll = rollDie(8);
return CHARACTER_CULTURES.find((c) => roll >= c.min && roll <= c.max).culture;
}
// Starting % bonuses by culture. "attackParry" applies to both attack and parry skill with
// that weapon/category; "attackOnly" and "parryOnly" apply to just the one.
const CULTURAL_WEAPON_BONUSES = {
Primitive: {
attackParry: [
{ categories: ['Spear, 1H', 'Spear, 2H'], bonus: 25 },
{ categories: ['Axe, 1H', 'Mace, 1H'], bonus: 25 },
],
attackOnly: [
{ categories: ['Javelin', 'Boomerang'], bonus: 20 },
{ categories: ['Sling'], bonus: 25 },
{ categories: ['Bow, Self'], bonus: 25 },
],
parryOnly: [
{ categories: ['Buckler', 'Heater/Target'], bonus: 25 },
],
},
Nomad: {
attackParry: [
{ categories: ['Axe, 1H', 'Mace, 1H', 'Spear, 1H', 'Sword, 1H'], bonus: 20 },
],
attackOnly: [
{ categories: ['Lance (mounted)'], bonus: 30 },
{ categories: ['Bow, Self', 'Bow, Long', 'Bow, Composite', 'Javelin'], bonus: 20 },
],
parryOnly: [
{ categories: ['Buckler', 'Heater/Target'], bonus: 20 },
],
},
Barbarian: {
attackParry: [
{ categories: ['Spear, 1H', 'Spear, 2H'], bonus: 25 },
{ categories: ['Axe, 1H', 'Mace, 1H', 'Sword, 1H'], bonus: 25 },
{ categories: ['Axe, 2H', 'Sword, 2H'], bonus: 15 },
],
attackOnly: [
{ categories: ['Bow, Self', 'Bow, Long', 'Bow, Composite', 'Javelin'], bonus: 25 },
],
parryOnly: [
{ categories: ['Buckler', 'Kite', 'Viking Round'], bonus: 25 }, // any shield except Heater/Target & Hoplite
],
},
Civilized: {
attackParry: [
{ categories: ['Sword, 1H'], weapons: ['Broadsword', 'Rapier', 'Scimitar'], bonus: 25 },
{ categories: ['Shortsword'], bonus: 25 },
{ categories: ['Spear, 1H', 'Spear, 2H'], bonus: 20 },
{ categories: ['Axe, 2H', 'Sword, 2H'], bonus: 15 },
],
attackOnly: [
{ categories: ['Crossbow, Heavy', 'Crossbow, Medium', 'Crossbow, Light', 'Sling'], bonus: 25 },
],
parryOnly: [
{ categories: ['Dagger'], weapons: ['Main Gauche'], bonus: 25 },
{ categories: ['Buckler', 'Heater/Target', 'Kite', 'Hoplite Shield'], bonus: 25 },
],
},
};
// Returns the attack/parry bonus a culture grants for a given weapon (category + name), if any.
function culturalWeaponBonus(culture, category, weaponName) {
const rules = CULTURAL_WEAPON_BONUSES[culture];
if (!rules) return { attack: 0, parry: 0 };
const matches = (entry) =>
entry.categories.includes(category) && (!entry.weapons || entry.weapons.includes(weaponName));
const attackParry = rules.attackParry.find(matches);
const attackOnly = rules.attackOnly.find(matches);
const parryOnly = rules.parryOnly.find(matches);
return {
attack: (attackParry && attackParry.bonus) || (attackOnly && attackOnly.bonus) || 0,
parry: (attackParry && attackParry.bonus) || (parryOnly && parryOnly.bonus) || 0,
};
}
// ---------- Armor ----------
// AP per location, and ENC/cost by adventurer size band (Small 6-10, Medium 11-15, Large 16-20, Troll 21-25).
const ARMOR_TABLE = [
{ name: 'Clothes', ap: 0, costPerEnc: null, bySize: { small: { enc: 2.0, cost: 40 }, medium: { enc: 2.5, cost: 45 }, large: { enc: 3.0, cost: 50 }, troll: { enc: 3.5, cost: 60 } } },
{ name: 'Soft Leather', ap: 1, costPerEnc: 20, bySize: { small: { enc: 3.0, cost: 60 }, medium: { enc: 3.5, cost: 70 }, large: { enc: 4.0, cost: 80 }, troll: { enc: 5.0, cost: 100 } } },
{ name: 'Stiff Leather', ap: 2, costPerEnc: 20, bySize: { small: { enc: 4.0, cost: 80 }, medium: { enc: 5.0, cost: 100 }, large: { enc: 6.0, cost: 120 }, troll: { enc: 7.0, cost: 140 } } },
{ name: 'Cuirbouilli', ap: 3, costPerEnc: 45, bySize: { small: { enc: 4.0, cost: 180 }, medium: { enc: 5.0, cost: 225 }, large: { enc: 6.0, cost: 270 }, troll: { enc: 7.0, cost: 315 } } },
{ name: 'Bezainted', ap: 4, costPerEnc: 70, bySize: { small: { enc: 6.0, cost: 420 }, medium: { enc: 7.5, cost: 563 }, large: { enc: 9.0, cost: 630 }, troll: { enc: 10.5, cost: 735 } } },
{ name: 'Ringmail', ap: 5, costPerEnc: 110, bySize: { small: { enc: 8.0, cost: 880 }, medium: { enc: 10.0, cost: 1100 }, large: { enc: 12.0, cost: 1320 }, troll: { enc: 14.0, cost: 1540 } } },
{ name: 'Lamellar', ap: 6, costPerEnc: 200, bySize: { small: { enc: 14.0, cost: 2800 }, medium: { enc: 18.0, cost: 3600 }, large: { enc: 21.5, cost: 4300 }, troll: { enc: 25.0, cost: 5000 } } },
{ name: 'Scale', ap: 6, costPerEnc: 120, bySize: { small: { enc: 16.0, cost: 1920 }, medium: { enc: 20.0, cost: 2400 }, large: { enc: 24.0, cost: 2880 }, troll: { enc: 28.0, cost: 3360 } } },
{ name: 'Chainmail', ap: 7, costPerEnc: 240, bySize: { small: { enc: 16.0, cost: 3840 }, medium: { enc: 20.0, cost: 4800 }, large: { enc: 24.0, cost: 5760 }, troll: { enc: 28.0, cost: 6720 } } },
{ name: 'Brigandine', ap: 7, costPerEnc: 200, bySize: { small: { enc: 17.5, cost: 3500 }, medium: { enc: 22.0, cost: 4400 }, large: { enc: 26.5, cost: 5300 }, troll: { enc: 31.0, cost: 6200 } } },
{ name: 'Plate', ap: 8, costPerEnc: 270, bySize: { small: { enc: 20.0, cost: 5400 }, medium: { enc: 25.0, cost: 6750 }, large: { enc: 30.0, cost: 8100 }, troll: { enc: 35.0, cost: 9450 } } },
];
const ENC_PER_HIT_LOCATION = {
Head: 0.1,
'R-Arm': 0.1,
'L-Arm': 0.1,
Chest: 0.2,
Abdomen: 0.1,
'R-Leg': 0.2,
'L-Leg': 0.2,
};
function armorByName(name) {
return ARMOR_TABLE.find((a) => a.name === name) || null;
}
// ---------- Experience / Improvement ----------
// "Roll/Add" = roll the die, divide by the given divisor (rounded to nearest), that's the points gained.
// Experience and Research (marked * in the rulebook) require a prior successful experience-increase
// roll: roll d100, improvement only happens if the roll exceeds the current skill/characteristic value.
const EXPERIENCE_IMPROVEMENT = {
experience: { roll: '1d6', divisor: 3, time: '1 week', requiresSuccessfulCheck: true },
training: { roll: '1d6-2', divisor: 2, time: 'hours equal to skill %', requiresSuccessfulCheck: false },
research: { roll: '1d6-2', divisor: 1, time: 'hours equal to skill %', requiresSuccessfulCheck: true },
powGain: { roll: '1d3-1', divisor: 1, time: '1 week', requiresSuccessfulCheck: false },
characteristic: { roll: '1d3-1', divisor: 1, time: 'characteristic × 25 hours', requiresSuccessfulCheck: false },
};
function rollImprovementPoints(method) {
const def = EXPERIENCE_IMPROVEMENT[method];
if (!def) throw new Error(`Unknown improvement method "${method}"`);
const rolled = rollNotation(def.roll);
const points = Math.max(0, Math.round(rolled / def.divisor));
return { rolled, points };
}
function rollExperienceCheck(currentValue) {
const roll = rollPercentile();
return { roll, success: roll > currentValue };
}
// Applies one improvement attempt. For 'experience'/'research', gates on rollExperienceCheck first.
function applyImprovement(method, currentValue) {
const def = EXPERIENCE_IMPROVEMENT[method];
if (!def) throw new Error(`Unknown improvement method "${method}"`);
if (def.requiresSuccessfulCheck) {
const check = rollExperienceCheck(currentValue);
if (!check.success) {
return { method, checkRoll: check.roll, success: false, pointsGained: 0, newValue: currentValue };
}
const { rolled, points } = rollImprovementPoints(method);
return { method, checkRoll: check.roll, success: true, rolled, pointsGained: points, newValue: currentValue + points };
}
const { rolled, points } = rollImprovementPoints(method);
return { method, success: true, rolled, pointsGained: points, newValue: currentValue + points };
}
// ---------- Fumble tables ----------
const MELEE_PARRY_FUMBLE_TABLE = [
{ min: 1, max: 5, effect: 'Lose next parry' },
{ min: 6, max: 10, effect: 'Lose next attack' },
{ min: 11, max: 15, effect: 'Lose next attack & parry' },
{ min: 16, max: 20, effect: 'Lose next attack, parry, & Dodge' },
{ min: 21, max: 25, effect: 'Lose next 1D3 attacks' },
{ min: 26, max: 30, effect: 'Lose next 1D3 attacks & parries' },
{ min: 31, max: 35, effect: 'Shield strap breaks, shield falls' },
{ min: 36, max: 40, effect: 'Shield strap breaks, shield falls; also lose next attack' },
{ min: 41, max: 45, effect: 'Armor strap breaks, roll hit location' },
{ min: 46, max: 50, effect: 'Armor strap breaks, roll hit location; also lose next attack & parry' },
{ min: 51, max: 55, effect: 'Fall; lose parry & Dodge, take 1D3 rounds to get up' },
{ min: 56, max: 60, effect: 'Twist ankle: Movement rate halved for 5D10 rounds' },
{ min: 61, max: 63, effect: 'Twist ankle & fall (apply both 51-55 and 56-60)' },
{ min: 64, max: 67, effect: 'Vision impaired: -25% on attacks & parries, 1D3 rounds unengaged to fix' },
{ min: 68, max: 70, effect: 'Vision impaired: -50% on attacks & parries, 1D6 rounds unengaged to fix' },
{ min: 71, max: 72, effect: 'Vision blocked: lose all attacks and parries, 1D6 rounds to fix' },
{ min: 73, max: 74, effect: 'Distracted: foes attack/parry at +25% for next round' },
{ min: 75, max: 78, effect: 'Attack: weapon dropped (1D2 rounds to recover). Parry: parrying weapon/shield dropped (1D2 rounds to recover)' },
{ min: 79, max: 82, effect: 'Weapon or parrying shield knocked away 1D6 meters (1D8 direction), 1D3+1 rounds to recover' },
{ min: 83, max: 86, effect: 'Weapon or shield shatters: 100% if unenchanted, -10%/pt Spirit or Sorcery magic, -20%/pt Divine magic' },
{ min: 87, max: 89, effect: 'Attack: hit nearest friend (self if none). Parry: foe automatically hits' },
{ min: 90, max: 91, effect: 'Attack: hit nearest friend for maximum damage (self if none). Parry: foe automatically hits' },
{ min: 92, max: 92, effect: 'Attack: hit nearest friend critically (self if none). Parry: foe automatically hits (rolled damage)' },
{ min: 93, max: 95, effect: 'Attack: hit self (rolled damage). Parry: foe automatically hits' },
{ min: 96, max: 97, effect: 'Attack: hit self for maximum damage. Parry: foe automatically hits' },
{ min: 98, max: 98, effect: 'Attack: critical hit on self. Parry: foe scores a critical' },
{ min: 99, max: 99, effect: 'Roll twice on this table, apply both results', rollTwice: true },
{ min: 100, max: 100, effect: 'Roll three times on this table, apply all results', rollThrice: true },
];
const MISSILE_FUMBLE_TABLE = [
{ min: 1, max: 10, effect: 'Lose next attack' },
{ min: 11, max: 20, effect: 'Lose next 1D4 attacks' },
{ min: 21, max: 30, effect: 'Lose all activities for next 1D3 melee rounds' },
{ min: 31, max: 40, effect: 'Weapon strap breaks; lose melee weapon' },
{ min: 41, max: 50, effect: 'Armor strap breaks, roll hit location' },
{ min: 51, max: 60, effect: 'Armor strap breaks, roll hit location; also lose attack and parry next round' },
{ min: 61, max: 65, effect: 'Fall to ground' },
{ min: 66, max: 70, effect: 'Vision impaired; -50% on attacks for 1D3 rounds' },
{ min: 71, max: 73, effect: 'Vision blocked; cannot see for next 1D3 rounds' },
{ min: 74, max: 80, effect: 'Drop weapon; lands 1D6-1 meters away (1D8 direction)' },
{ min: 81, max: 85, effect: 'Weapon shatters (resolve as Melee/Parry fumble 83-86)' },
{ min: 86, max: 89, effect: 'Hit nearest friend; rolled damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 90, max: 92, effect: 'Impale nearest friend; if no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 93, max: 94, effect: 'Critical hit on nearest friend; if no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 95, max: 98, effect: 'Roll twice on this table, apply both results', rollTwice: true },
{ min: 99, max: 100, effect: 'Roll three times on this table, apply all results', rollThrice: true },
];
const NATURAL_WEAPONS_FUMBLE_TABLE = [
{ min: 1, max: 5, effect: 'Lose next Dodge' },
{ min: 6, max: 10, effect: 'Lose next attack' },
{ min: 11, max: 15, effect: 'Lose next Dodge and parry' },
{ min: 16, max: 20, effect: 'Lose next Dodge, parry, and attack' },
{ min: 21, max: 25, effect: 'Lose Dodge, parry, and attack for next 1D3 melee rounds' },
{ min: 26, max: 30, effect: 'Lose next 1D6 attacks' },
{ min: 31, max: 35, effect: 'Armor strap breaks; roll hit location' },
{ min: 36, max: 40, effect: 'Armor strap breaks; roll hit location; also lose next round as per 21-25' },
{ min: 41, max: 50, effect: 'Fall; lose Dodge and parry this round' },
{ min: 51, max: 60, effect: 'Fall and twist ankle; lose 1 meter of Movement per melee round for 5D10 rounds' },
{ min: 61, max: 70, effect: 'Vision impaired: -25% on attacks & parries, 1D3 rounds unengaged to fix' },
{ min: 71, max: 73, effect: 'Vision impaired: -50% on attacks & parries, 1D4 rounds unengaged to fix' },
{ min: 74, max: 75, effect: 'Vision blocked; blind for 1D3 rounds' },
{ min: 76, max: 80, effect: 'Distracted; all foes +25% attack next round' },
{ min: 81, max: 85, effect: 'Strain muscle; lose 1 HP in attacking limb and 3 Fatigue points' },
{ min: 86, max: 90, effect: 'Hit nearest friend, rolled damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 91, max: 94, effect: 'Hit nearest friend, maximum damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 95, max: 96, effect: 'Hit nearest friend, critical damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 97, max: 98, effect: 'Hit self; maximum rolled damage' },
{ min: 99, max: 99, effect: 'Roll twice, apply both results', rollTwice: true },
{ min: 100, max: 100, effect: 'Roll three times, apply all results', rollThrice: true },
];
const FUMBLE_TABLES = {
meleeParry: MELEE_PARRY_FUMBLE_TABLE,
missile: MISSILE_FUMBLE_TABLE,
natural: NATURAL_WEAPONS_FUMBLE_TABLE,
};
function rollFumble(tableName, _depth = 0) {
const table = FUMBLE_TABLES[tableName];
if (!table) throw new Error(`Unknown fumble table "${tableName}"`);
const roll = rollPercentile();
const entry = table.find((e) => roll >= e.min && roll <= e.max);
const results = [{ roll, effect: entry.effect }];
if (_depth < 5 && entry.rollTwice) {
results.push(...rollFumble(tableName, _depth + 1).results);
} else if (_depth < 5 && entry.rollThrice) {
results.push(...rollFumble(tableName, _depth + 1).results);
results.push(...rollFumble(tableName, _depth + 1).results);
}
return { results };
}
// ---------- Hit location disable effects ----------
// From the original spec's mechanical hit-location table; not superseded by the gamesheet,
// which doesn't redefine these. Head injury is checked only when armor was penetrated.
const LOCATION_DISABLE_EFFECTS = {
'R-Arm': 'attack-skill-penalty-20',
'L-Arm': 'no-shield-or-offhand',
'R-Leg': 'skip-next-action',
'L-Leg': 'skip-next-action',
};
function locationDisabledEffect(locationName) {
return LOCATION_DISABLE_EFFECTS[locationName] || null;
}
// Roll on armor-penetrating Head damage: CON×5 roll to avoid Dazed/Stunned (GM d100 picks which).
function resolveHeadInjury(con) {
const roll = rollPercentile();
const conX5 = con * 5;
if (roll <= conX5) return { roll, conX5, outcome: 'ok' };
const subRoll = rollPercentile();
const outcome = subRoll <= 50 ? 'dazed' : 'stunned';
return { roll, conX5, outcome, subRoll };
}
// ---------- Damage application ----------
function applyDamageToLocation({ hitLocation, totalHp, damage, ignoresArmor }) {
const effectiveDamage = ignoresArmor ? damage : Math.max(0, damage - (hitLocation.armor_ap || 0));
const newLocationHp = hitLocation.current_hp - effectiveDamage;
const newTotalHp = totalHp - effectiveDamage;
const wasDisabled = !!hitLocation.disabled;
const disabled = wasDisabled || newLocationHp <= 0;
return {
effectiveDamage,
newLocationHp,
newTotalHp,
disabled,
justDisabled: disabled && !wasDisabled,
disableEffect: disabled ? locationDisabledEffect(hitLocation.location_name) : null,
};
}
function checkIncapacitation({ totalHp, con }) {
if (totalHp <= -con) return 'dead';
if (totalHp <= 0) return 'unconscious';
return 'conscious';
}
// ---------- Spirit/Battle Magic mechanics ----------
// Known spells cast automatically (no roll) and just spend MP. POW×5 is only rolled when an
// effect is resisted by a target (e.g. Demoralize).
function powVsPowChance(activePow, passivePow) {
return Math.max(5, Math.min(95, 50 + (activePow - passivePow) * 5));
}
const SPELL_MECHANICS = {
bladesharp: { name: 'Bladesharp', minMp: 1, maxMp: 4, resisted: false, effect: (mp) => ({ damageBonus: mp }) },
protection: { name: 'Protection', minMp: 1, maxMp: 4, resisted: false, effect: (mp) => ({ armorBonusAllLocations: mp }) },
heal: { name: 'Heal', minMp: 1, maxMp: 3, resisted: false, effect: (mp) => ({ healHp: mp }) },
disruption: { name: 'Disruption', minMp: 1, maxMp: 1, resisted: false, effect: () => ({ damage: rollNotation('1d3'), ignoresArmor: true }) },
demoralize: { name: 'Demoralize', minMp: 2, maxMp: 2, resisted: true, effect: () => ({ skillPenaltyPercent: -20, duration: 'next round' }) },
coordination: { name: 'Coordination', minMp: 1, maxMp: 2, resisted: false, effect: (mp) => ({ strikeRankBonus: mp }) },
};
function castSpell(mechanicId, mpSpent, { casterPow, targetPow } = {}) {
const def = SPELL_MECHANICS[mechanicId];
if (!def) throw new Error(`Unknown spell mechanic "${mechanicId}"`);
const mp = Math.max(def.minMp, Math.min(def.maxMp, mpSpent));
const result = { mechanicId, name: def.name, mpSpent: mp, ...def.effect(mp) };
if (def.resisted) {
const chance = casterPow != null && targetPow != null ? powVsPowChance(casterPow, targetPow) : 50;
const roll = rollPercentile();
result.resistChance = chance;
result.resistRoll = roll;
result.resisted = roll > chance;
}
return result;
}
function regenerateMp(current, max, amount = 1) {
return Math.min(max, current + amount);
}
// ---------- Turn scheduling ----------
// 10 strike ranks per melee round (Melee Sequence). Groups declared actions by SR, lowest first.
function buildStrikeRankSchedule(actions) {
const schedule = {};
for (let sr = 1; sr <= 10; sr++) schedule[sr] = [];
for (const action of actions) {
const sr = Math.min(10, Math.max(1, action.strikeRank));
schedule[sr].push(action);
}
return schedule;
}
module.exports = {
rollDie,
rollDice,
rollNotation,
maxNotation,
rollPercentile,
rollCharacteristics,
dexStrikeRank,
sizStrikeRankModifier,
baseStrikeRank,
computeHitLocations,
rollHitLocation,
SHIELD_COVERAGE,
damageBonusNotation,
rollDamageBonus,
resolveSkillCheck,
WEAPON_TYPE,
MELEE_WEAPONS,
NATURAL_WEAPONS,
MISSILE_WEAPONS,
maxDamageBonus,
resolveWeaponEffectiveType,
resolveAttackDamage,
attemptWeaponRemoval,
removeStuckWeaponFromSelf,
removeStuckWeaponWithFirstAid,
resolveParry,
applyParryToDamage,
resolveDodge,
ATTACK_MODIFIERS,
sumAttackModifiers,
CHARACTER_CULTURES,
rollCulture,
CULTURAL_WEAPON_BONUSES,
culturalWeaponBonus,
ARMOR_TABLE,
ENC_PER_HIT_LOCATION,
armorByName,
EXPERIENCE_IMPROVEMENT,
rollImprovementPoints,
rollExperienceCheck,
applyImprovement,
FUMBLE_TABLES,
rollFumble,
locationDisabledEffect,
resolveHeadInjury,
applyDamageToLocation,
checkIncapacitation,
powVsPowChance,
SPELL_MECHANICS,
castSpell,
regenerateMp,
buildStrikeRankSchedule,
};
+128
View File
@@ -0,0 +1,128 @@
const fs = require('fs');
const path = require('path');
const dbApi = require('../db.js');
const { parseMarkdownFile } = require('../lib/markdownTableParser.js');
const TABLES_DIR = path.join(__dirname, '..', 'tables');
const NPC_CORE_COLUMNS = [
'first name', 'last name', 'brief description', 'wants and needs', 'secret or obstacle', 'also carrying',
];
const NPC_ATTRIBUTE_PATTERNS = [
[/\bpronoun/i, 'npc_pronouns'],
[/\bage\b/i, 'npc_age'],
[/\bintelligence\b/i, 'npc_intelligence'],
[/^hair$/i, 'npc_hair'],
[/^build$/i, 'npc_build'],
[/^race$/i, 'npc_race'],
];
const LAYER1_LINKS = [
[/weather turns/i, 'Layer 2C: Weather Turns (d20)'],
[/encounter a person/i, 'Layer 2A: Person Encountered (d20)'],
[/encounter a group/i, 'Layer 2B: Group Encountered (d20)'],
[/find something/i, 'Layer 2D: Find Something (d100)'],
[/natural hazard/i, 'Layer 2E: Natural Hazard (d20)'],
[/signs of recent violence/i, 'Layer 2F: Signs of Recent Violence (d20)'],
[/pursuit or being followed/i, 'Layer 2G: Pursuit \/ Being Followed (d20)'],
[/physical hardship/i, 'Layer 2H: Physical Hardship (d20)'],
[/something uncanny/i, 'Layer 2I: Something Uncanny (d20)'],
];
function isNpcCoreTable(columns) {
const norm = columns.map((c) => c.toLowerCase().trim());
return NPC_CORE_COLUMNS.every((req) => norm.includes(req));
}
function detectNpcRole(headingText, columns, insideNpcSection) {
if (isNpcCoreTable(columns)) return 'npc_core';
if (!insideNpcSection) return null;
const trimmed = headingText.trim();
for (const [pattern, role] of NPC_ATTRIBUTE_PATTERNS) {
if (pattern.test(trimmed)) return role;
}
return null;
}
const stats = { files: 0, nodes: 0, tables: 0, rows: 0, links: 0 };
function insertNode(node, sourceFile, parentId, sortOrder, insideNpcSection) {
const treeNodeId = dbApi.tables.insertTreeNode({
source_file: sourceFile,
parent_id: parentId,
heading_text: node.headingText,
heading_level: node.level,
sort_order: sortOrder,
});
stats.nodes++;
const childInsideNpc = insideNpcSection || /random npc generator/i.test(node.headingText || '');
if (node.table) {
const npcRole = detectNpcRole(node.headingText, node.table.columns, childInsideNpc);
const tableId = dbApi.tables.insertTable({
tree_node_id: treeNodeId,
dice_notation: node.table.diceNotation,
table_type: node.table.tableType,
npc_role: npcRole,
});
stats.tables++;
dbApi.tables.insertColumns(tableId, node.table.columns);
const rowIds = dbApi.tables.insertRows(tableId, node.table.rows);
stats.rows += rowIds.length;
}
node.children.forEach((child, idx) => insertNode(child, sourceFile, treeNodeId, idx, childInsideNpc));
}
function importFile(filePath) {
const sourceFile = path.basename(filePath);
const roots = parseMarkdownFile(filePath);
roots.forEach((node, idx) => insertNode(node, sourceFile, null, idx, false));
stats.files++;
console.log(`Imported ${sourceFile}`);
}
function resolveLayer1Links() {
const sourceFile = 'norse_encounter_tables.md';
const layer1Table = dbApi.tables.findTableByHeading(sourceFile, 'Layer 1: What Happens? (d20)');
if (!layer1Table) {
console.warn('Could not find Layer 1 table for link resolution');
return;
}
const rows = dbApi.tables.getRowsForTable(layer1Table.id);
for (const row of rows) {
const text = row.cells[0] || '';
const match = LAYER1_LINKS.find(([pattern]) => pattern.test(text));
if (!match) continue;
const [, targetHeading] = match;
const targetTable = dbApi.tables.findTableByHeading(sourceFile, targetHeading);
if (!targetTable) {
console.warn(`Layer 1 link target not found: "${targetHeading}" (row: "${text}")`);
continue;
}
dbApi.tables.insertLink({ table_id: layer1Table.id, row_id: row.id, target_table_id: targetTable.id });
stats.links++;
}
}
function main() {
dbApi.tables.clearImported();
const files = fs.readdirSync(TABLES_DIR).filter((f) => f.endsWith('.md'));
for (const file of files) {
importFile(path.join(TABLES_DIR, file));
}
resolveLayer1Links();
console.log('---');
console.log(`Files: ${stats.files}`);
console.log(`Tree nodes: ${stats.nodes}`);
console.log(`Tables: ${stats.tables}`);
console.log(`Rows: ${stats.rows}`);
console.log(`Layer links: ${stats.links}`);
}
main();
+560
View File
@@ -0,0 +1,560 @@
const path = require('path');
const express = require('express');
const dbApi = require('./db.js');
const rq3 = require('./rq3.js');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
function notFound(res, what) {
return res.status(404).json({ error: `${what} not found` });
}
// ---------- NPCs ----------
const NPC_CORE_FIELD_BY_COLUMN = {
'first name': 'first_name',
'last name': 'last_name',
'brief description': 'brief_description',
'wants and needs': 'wants_needs',
'secret or obstacle': 'secret_obstacle',
'also carrying': 'also_carrying',
};
const NPC_ATTRIBUTE_ROLES = {
race: 'npc_race',
pronouns: 'npc_pronouns',
age: 'npc_age',
intelligence: 'npc_intelligence',
hair: 'npc_hair',
build: 'npc_build',
};
function rollCoreFields() {
const table = dbApi.tables.findByNpcRole('npc_core');
if (!table) return {};
const result = dbApi.tables.roll(table.id);
const fields = {};
table.columns.forEach((col, idx) => {
const key = NPC_CORE_FIELD_BY_COLUMN[col.column_name.toLowerCase().trim()];
if (key) fields[key] = result.row.cells[idx];
});
return fields;
}
function rollAttributeField(field) {
const role = NPC_ATTRIBUTE_ROLES[field];
if (!role) return null;
const table = dbApi.tables.findByNpcRole(role);
if (!table) return null;
const result = dbApi.tables.roll(table.id);
return result.row.cells[0];
}
function generateNpcFields(npcType) {
const core = rollCoreFields();
if (npcType === 'filler') {
return {
npc_type: 'filler',
first_name: core.first_name,
last_name: core.last_name,
brief_description: core.brief_description,
};
}
const fields = { npc_type: 'full', ...core };
for (const attr of Object.keys(NPC_ATTRIBUTE_ROLES)) {
fields[attr] = rollAttributeField(attr);
}
return fields;
}
app.get('/api/npcs', (req, res) => {
res.json(dbApi.npcs.list());
});
app.get('/api/npcs/:id', (req, res) => {
const npc = dbApi.npcs.get(req.params.id);
if (!npc) return notFound(res, 'NPC');
res.json(npc);
});
app.post('/api/npcs', (req, res) => {
const npc = dbApi.npcs.create(req.body || {});
const name = `${npc.first_name || ''} ${npc.last_name || ''}`.trim();
dbApi.log.append({ type: 'npc', summary: `Created NPC "${name}"`, details: npc });
res.status(201).json(npc);
});
app.post('/api/npcs/generate', (req, res) => {
const npcType = req.body && req.body.npc_type === 'filler' ? 'filler' : 'full';
const fields = generateNpcFields(npcType);
const npc = dbApi.npcs.create(fields);
const name = `${npc.first_name || '?'} ${npc.last_name || ''}`.trim();
dbApi.log.append({ type: 'npc', summary: `Generated ${npcType} NPC "${name}"`, details: npc });
res.status(201).json(npc);
});
app.put('/api/npcs/:id', (req, res) => {
const npc = dbApi.npcs.update(req.params.id, req.body || {});
if (!npc) return notFound(res, 'NPC');
dbApi.log.append({ type: 'npc', summary: `Updated NPC #${npc.id}`, details: npc });
res.json(npc);
});
app.post('/api/npcs/:id/reroll-field', (req, res) => {
const npc = dbApi.npcs.get(req.params.id);
if (!npc) return notFound(res, 'NPC');
const field = req.body && req.body.field;
if (!field) return res.status(400).json({ error: 'field is required' });
let value;
if (Object.values(NPC_CORE_FIELD_BY_COLUMN).includes(field)) {
const core = rollCoreFields();
value = core[field];
} else if (NPC_ATTRIBUTE_ROLES[field]) {
value = rollAttributeField(field);
} else {
return res.status(400).json({ error: `Unknown rerollable field "${field}"` });
}
const updated = dbApi.npcs.update(npc.id, { [field]: value });
dbApi.log.append({ type: 'npc', summary: `Re-rolled "${field}" for NPC #${npc.id}`, details: { field, value } });
res.json(updated);
});
app.delete('/api/npcs/:id', (req, res) => {
const ok = dbApi.npcs.delete(req.params.id);
if (!ok) return notFound(res, 'NPC');
dbApi.log.append({ type: 'npc', summary: `Deleted NPC #${req.params.id}` });
res.status(204).end();
});
app.post('/api/npcs/:id/generate-stat-block', (req, res) => {
const npc = dbApi.npcs.get(req.params.id);
if (!npc) return notFound(res, 'NPC');
if (npc.stat_block) return res.status(400).json({ error: 'NPC already has a stat block' });
const statBlock = rollHumanoidStatBlock();
const created = dbApi.statBlocks.create(statBlock);
dbApi.statBlocks.setHitLocations(created.id, rq3.computeHitLocations(statBlock.max_hp));
const updated = dbApi.npcs.linkStatBlock(npc.id, created.id);
dbApi.log.append({ type: 'npc', summary: `Attached stat block to NPC #${npc.id}` });
res.status(201).json(updated);
});
// ---------- Enemies ----------
function applyStatBlockUpdate(enemy, statBlockPatch) {
if (!statBlockPatch) return;
const { hit_locations, weapons, spells, ...characteristics } = statBlockPatch;
if (Object.keys(characteristics).length) {
dbApi.statBlocks.update(enemy.stat_block_id, characteristics);
}
if (hit_locations) dbApi.statBlocks.setHitLocations(enemy.stat_block_id, hit_locations);
if (weapons) dbApi.statBlocks.setWeapons(enemy.stat_block_id, weapons);
if (spells) dbApi.statBlocks.setSpells(enemy.stat_block_id, spells);
}
app.get('/api/enemies', (req, res) => {
res.json(dbApi.enemies.list());
});
app.get('/api/enemies/:id', (req, res) => {
const enemy = dbApi.enemies.get(req.params.id);
if (!enemy) return notFound(res, 'Enemy');
res.json(enemy);
});
app.post('/api/enemies', (req, res) => {
const enemy = dbApi.enemies.create(req.body || {});
dbApi.log.append({ type: 'enemy', summary: `Created enemy "${enemy.name}"`, details: enemy });
res.status(201).json(enemy);
});
// Rolls a fresh humanoid stat block (characteristics, HP, hit locations) via rq3.js,
// shared by enemy generation and the NPC "attach stat block" action.
function rollHumanoidStatBlock() {
const chars = rq3.rollCharacteristics();
const maxHp = Math.ceil((chars.con + chars.siz) / 2);
return {
...chars,
max_hp: maxHp,
current_hp: maxHp,
move: 8,
magic_points_max: chars.pow,
magic_points_current: chars.pow,
culture: rq3.rollCulture(),
};
}
app.post('/api/enemies/generate', (req, res) => {
const name = (req.body && req.body.name) || 'Unnamed Enemy';
const category = (req.body && req.body.category) || 'Humanoid';
const statBlock = rollHumanoidStatBlock();
const enemy = dbApi.enemies.create({ name, category, stat_block: statBlock });
dbApi.statBlocks.setHitLocations(enemy.stat_block.id, rq3.computeHitLocations(statBlock.max_hp));
const refreshed = dbApi.enemies.get(enemy.id);
dbApi.log.append({ type: 'enemy', summary: `Generated enemy "${refreshed.name}" (${category})`, details: refreshed });
res.status(201).json(refreshed);
});
app.put('/api/enemies/:id', (req, res) => {
const existing = dbApi.enemies.get(req.params.id);
if (!existing) return notFound(res, 'Enemy');
const { stat_block, ...rest } = req.body || {};
const enemy = dbApi.enemies.update(req.params.id, rest);
applyStatBlockUpdate(enemy, stat_block);
const refreshed = dbApi.enemies.get(req.params.id);
dbApi.log.append({ type: 'enemy', summary: `Updated enemy "${refreshed.name}"`, details: refreshed });
res.json(refreshed);
});
app.delete('/api/enemies/:id', (req, res) => {
const existing = dbApi.enemies.get(req.params.id);
if (!existing) return notFound(res, 'Enemy');
dbApi.enemies.delete(req.params.id);
dbApi.log.append({ type: 'enemy', summary: `Deleted enemy "${existing.name}"` });
res.status(204).end();
});
// ---------- Log ----------
app.get('/api/log', (req, res) => {
res.json(dbApi.log.search(req.query.search || ''));
});
app.post('/api/log', (req, res) => {
const { type, summary, details } = req.body || {};
if (!type || !summary) return res.status(400).json({ error: 'type and summary are required' });
const entry = dbApi.log.append({ type, summary, details });
res.status(201).json(entry);
});
app.get('/api/export/log', (req, res) => {
const entries = dbApi.log.search('');
const md = [
'# Session Log',
'',
...entries.map((e) => {
const details = e.details ? `\n\n\`\`\`json\n${JSON.stringify(e.details, null, 2)}\n\`\`\`` : '';
return `## ${e.created_at}${e.type}\n\n${e.summary}${details}\n`;
}),
].join('\n');
const filename = `session-log-${new Date().toISOString().replace(/[:.]/g, '-')}.md`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'text/markdown');
res.send(md);
});
// ---------- Combat ----------
app.get('/api/combat', (req, res) => {
res.json(dbApi.combat.get());
});
app.post('/api/combat', (req, res) => {
const state = dbApi.combat.set(req.body || {});
dbApi.log.append({ type: 'combat', summary: 'Combat state updated', details: req.body });
res.json(state);
});
app.delete('/api/combat', (req, res) => {
dbApi.combat.clear();
dbApi.log.append({ type: 'combat', summary: 'Combat ended' });
res.status(204).end();
});
function findWeaponDef(weaponName, category) {
const pools = [rq3.MELEE_WEAPONS, rq3.NATURAL_WEAPONS, rq3.MISSILE_WEAPONS];
for (const pool of pools) {
const match = pool.find((w) =>
w.weapon.toLowerCase() === weaponName.toLowerCase() && (!category || !w.category || w.category === category)
);
if (match) return match;
}
for (const pool of pools) {
const match = pool.find((w) => w.weapon.toLowerCase() === weaponName.toLowerCase());
if (match) return match;
}
return null;
}
function combatantSourceRecord(ref) {
if (ref.type === 'enemy') return dbApi.enemies.get(ref.id);
if (ref.type === 'npc') return dbApi.npcs.get(ref.id);
throw new Error(`Unknown combatant ref type "${ref.type}"`);
}
function buildCombatantSnapshot(ref) {
const source = combatantSourceRecord(ref);
if (!source) return null;
const sb = source.stat_block;
if (!sb) return null;
const name = ref.type === 'enemy' ? source.name : `${source.first_name || ''} ${source.last_name || ''}`.trim();
return {
id: Math.random().toString(36).slice(2, 9),
ref,
statBlockId: sb.id,
name,
str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app,
currentHp: sb.current_hp,
maxHp: sb.max_hp,
magicPointsCurrent: sb.magic_points_current,
magicPointsMax: sb.magic_points_max,
strikeRank: rq3.baseStrikeRank({ dex: sb.dex, siz: sb.siz }),
hitLocations: sb.hit_locations.map((l) => ({ ...l })),
weapons: sb.weapons.map((w) => ({ ...w })),
spells: sb.spells.map((s) => ({ ...s })),
status: 'active',
};
}
app.post('/api/combat/add-combatant', (req, res) => {
const { type, id } = req.body || {};
if (!type || !id) return res.status(400).json({ error: 'type and id are required' });
const snapshot = buildCombatantSnapshot({ type, id });
if (!snapshot) return notFound(res, 'Combatant source');
const current = dbApi.combat.get();
const state = current ? current.state : { round: 1, combatants: [] };
state.combatants = state.combatants || [];
state.combatants.push(snapshot);
const saved = dbApi.combat.set(state);
dbApi.log.append({ type: 'combat', summary: `${snapshot.name} joined combat (SR ${snapshot.strikeRank})`, details: snapshot });
res.status(201).json(saved);
});
app.post('/api/combat/attack', (req, res) => {
const { attackerCombatantId, defenderCombatantId, weaponName, declaredMode, attackKind = 'melee', thrown = false, reaction, modifierIds = [] } = req.body || {};
const current = dbApi.combat.get();
if (!current) return res.status(400).json({ error: 'No active combat' });
const state = current.state;
const attacker = (state.combatants || []).find((c) => c.id === attackerCombatantId);
const defender = (state.combatants || []).find((c) => c.id === defenderCombatantId);
if (!attacker || !defender) return res.status(400).json({ error: 'Unknown attacker or defender combatant id' });
const weaponEntry = attacker.weapons.find((w) => w.weapon_name.toLowerCase() === (weaponName || '').toLowerCase());
if (!weaponEntry) return res.status(400).json({ error: `Attacker has no weapon named "${weaponName}"` });
const weaponDef = findWeaponDef(weaponEntry.weapon_name, weaponEntry.category);
if (!weaponDef) return res.status(400).json({ error: `No rq3 weapon definition found for "${weaponEntry.weapon_name}"` });
if (weaponDef.type === rq3.WEAPON_TYPE.DUAL && declaredMode !== 'impale' && declaredMode !== 'slash') {
return res.status(400).json({ error: `"${weaponEntry.weapon_name}" is dual-mode; declaredMode must be 'impale' or 'slash'` });
}
const modifierTotal = rq3.sumAttackModifiers(modifierIds, { targetSiz: defender.siz });
const effectiveSkillPercent = weaponEntry.skill_percent + modifierTotal;
const attackCheck = rq3.resolveSkillCheck(effectiveSkillPercent);
const result = { attackCheck, weapon: weaponEntry.weapon_name, modifierTotal, effectiveSkillPercent };
if (attackCheck.tier === 'fumble') {
result.fumble = rq3.rollFumble(attackKind === 'missile' ? 'missile' : 'meleeParry');
}
let damageThrough = 0;
let hitLocationRoll = null;
let damageResult = null;
let parryOrDodge = null;
if (attackCheck.tier !== 'fumble' && attackCheck.tier !== 'failure') {
if (reaction && reaction.type === 'dodge') {
parryOrDodge = rq3.resolveDodge({ dodgeSkillPercent: reaction.skillPercent, attackTier: attackCheck.tier });
damageThrough = parryOrDodge.avoided ? 0 : null; // null = not yet determined, fall through to damage calc
}
if (!parryOrDodge || !parryOrDodge.avoided) {
damageResult = rq3.resolveAttackDamage({
weapon: weaponDef,
tier: attackCheck.tier,
strPlusSiz: attacker.str + attacker.siz,
declaredMode,
thrown,
});
hitLocationRoll = rq3.rollHitLocation(attackKind);
const location = defender.hitLocations.find((l) => l.location_name === hitLocationRoll.location);
if (reaction && reaction.type === 'parry') {
parryOrDodge = rq3.resolveParry({ parrySkillPercent: reaction.skillPercent });
const parryWeapon = defender.weapons.find((w) => w.weapon_name.toLowerCase() === (reaction.weaponName || '').toLowerCase());
const applied = rq3.applyParryToDamage({
parryEffect: parryOrDodge.effect,
damage: damageResult.damage,
parryingItemAp: parryWeapon ? findWeaponDef(parryWeapon.weapon_name, parryWeapon.category)?.ap : 0,
});
damageThrough = applied.damageThrough;
} else {
damageThrough = damageResult.damage;
}
if (damageThrough > 0 && location) {
const applied = rq3.applyDamageToLocation({
hitLocation: location,
totalHp: defender.currentHp,
damage: damageThrough,
ignoresArmor: damageResult.ignoresArmor,
});
location.current_hp = applied.newLocationHp;
location.disabled = applied.disabled;
defender.currentHp = applied.newTotalHp;
result.damageApplied = applied;
defender.status = rq3.checkIncapacitation({ totalHp: defender.currentHp, con: defender.con }) === 'conscious'
? 'active' : rq3.checkIncapacitation({ totalHp: defender.currentHp, con: defender.con });
}
}
}
result.damageResult = damageResult;
result.hitLocationRoll = hitLocationRoll;
result.reactionResult = parryOrDodge;
result.damageThrough = damageThrough;
dbApi.combat.set(state);
dbApi.statBlocks.update(defender.statBlockId, { current_hp: defender.currentHp });
dbApi.statBlocks.setHitLocations(defender.statBlockId, defender.hitLocations);
dbApi.log.append({
type: 'combat',
summary: `${attacker.name} attacks ${defender.name} with ${weaponEntry.weapon_name}: ${attackCheck.tier}${damageThrough ? `, ${damageThrough} dmg to ${hitLocationRoll?.location}` : ''}`,
details: result,
});
res.json({ combatState: dbApi.combat.get(), result });
});
app.post('/api/combat/cast-spell', (req, res) => {
const { casterCombatantId, targetCombatantId, mechanicId, mpSpent } = req.body || {};
const current = dbApi.combat.get();
if (!current) return res.status(400).json({ error: 'No active combat' });
const state = current.state;
const caster = (state.combatants || []).find((c) => c.id === casterCombatantId);
if (!caster) return res.status(400).json({ error: 'Unknown caster combatant id' });
const target = (state.combatants || []).find((c) => c.id === targetCombatantId) || null;
const def = rq3.SPELL_MECHANICS[mechanicId];
if (!def) return res.status(400).json({ error: `Unknown spell mechanic "${mechanicId}"` });
if (caster.magicPointsCurrent < mpSpent) return res.status(400).json({ error: 'Not enough magic points' });
const spellResult = rq3.castSpell(mechanicId, mpSpent, {
casterPow: caster.pow,
targetPow: target ? target.pow : undefined,
});
caster.magicPointsCurrent -= spellResult.mpSpent;
if (spellResult.damage && target) {
const hitLocationRoll = rq3.rollHitLocation('melee');
const location = target.hitLocations.find((l) => l.location_name === hitLocationRoll.location);
if (location) {
const applied = rq3.applyDamageToLocation({
hitLocation: location, totalHp: target.currentHp, damage: spellResult.damage, ignoresArmor: spellResult.ignoresArmor,
});
location.current_hp = applied.newLocationHp;
location.disabled = applied.disabled;
target.currentHp = applied.newTotalHp;
dbApi.statBlocks.update(target.statBlockId, { current_hp: target.currentHp });
dbApi.statBlocks.setHitLocations(target.statBlockId, target.hitLocations);
}
}
if (spellResult.healHp && target) {
target.currentHp = Math.min(target.maxHp, target.currentHp + spellResult.healHp);
dbApi.statBlocks.update(target.statBlockId, { current_hp: target.currentHp });
}
dbApi.combat.set(state);
dbApi.statBlocks.update(caster.statBlockId, { magic_points_current: caster.magicPointsCurrent });
dbApi.log.append({
type: 'spell',
summary: `${caster.name} casts ${spellResult.name}${target ? ` on ${target.name}` : ''} (${spellResult.mpSpent} MP)`,
details: spellResult,
});
res.json({ combatState: dbApi.combat.get(), result: spellResult });
});
// ---------- Tables (roller) ----------
app.get('/api/tables/tree', (req, res) => {
res.json(dbApi.tables.getTree());
});
app.get('/api/tables/:id', (req, res) => {
const table = dbApi.tables.getById(req.params.id);
if (!table) return notFound(res, 'Table');
res.json(table);
});
app.post('/api/tables/:id/roll', (req, res) => {
const result = dbApi.tables.roll(req.params.id);
if (!result) return notFound(res, 'Table');
dbApi.log.append({
type: 'roll',
summary: `Rolled on "${result.table.name}": ${result.row.cells.join(' / ')}`,
details: { table_id: result.table.id, row: result.row, links: result.links },
});
res.json(result);
});
// ---------- Rules reference (read-only lookups for the frontend) ----------
app.get('/api/rules/attack-modifiers', (req, res) => {
res.json(rq3.ATTACK_MODIFIERS);
});
app.get('/api/rules/armor-table', (req, res) => {
res.json(rq3.ARMOR_TABLE);
});
app.get('/api/rules/cultural-bonus', (req, res) => {
const { culture, category, weapon } = req.query;
if (!culture || !category) return res.status(400).json({ error: 'culture and category are required' });
res.json(rq3.culturalWeaponBonus(culture, category, weapon));
});
// ---------- Spell mappings ----------
app.get('/api/spell-mappings', (req, res) => {
res.json(dbApi.spellMappings.list());
});
app.post('/api/spell-mappings', (req, res) => {
const { custom_name, mechanic_id, default_mp_cost } = req.body || {};
if (!custom_name || !mechanic_id) {
return res.status(400).json({ error: 'custom_name and mechanic_id are required' });
}
const mapping = dbApi.spellMappings.upsert({ custom_name, mechanic_id, default_mp_cost });
res.status(201).json(mapping);
});
// ---------- Export / Import ----------
app.get('/api/export', (req, res) => {
const dump = dbApi.exportAll();
const filename = `story-tool-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.json(dump);
});
app.post('/api/import', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'Request body must be a JSON export dump' });
}
const dump = dbApi.importAll(req.body);
dbApi.log.append({ type: 'note', summary: 'Data imported from JSON dump' });
res.json(dump);
});
// ---------- Errors ----------
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message || 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`RQ3 Story Tool listening on http://localhost:${PORT}`);
});
+297
View File
@@ -0,0 +1,297 @@
# Norse Journey Encounter Tables
Roll on **Layer 1** first. If you want more detail, roll on the relevant **Layer 2** table.
---
## Layer 1: What Happens? (d20)
| Roll | Event |
|------|-------|
| 13 | Nothing — uneventful travel |
| 45 | Weather turns |
| 68 | Encounter a person |
| 910 | Encounter a group |
| 1112 | Find something |
| 1314 | Natural hazard |
| 1516 | Signs of recent violence |
| 1718 | Pursuit or being followed |
| 19 | Physical hardship |
| 20 | Something uncanny |
---
## Layer 2A: Person Encountered (d20)
| Roll | Who |
|------|-----|
| 1 | Wounded traveller, won't say how |
| 2 | Thrall fleeing their master |
| 3 | Merchant with something hidden under the cart |
| 4 | Grieving widow, heading nowhere in particular |
| 5 | Lost child, won't say where they came from |
| 6 | Outlaw watching from the treeline |
| 7 | Skald collecting stories, too curious by half |
| 8 | Old woman who knows your name somehow |
| 9 | Disgraced jarl's son, armed and bitter |
| 10 | A healer with blood on their hands they can't explain |
| 11 | A fisherman far from any water |
| 12 | A young woman disguised as a man |
| 13 | A former thrall now free, with nowhere to go |
| 14 | A blind man navigating perfectly |
| 15 | A priest of the old ways, wary and watchful |
| 16 | A very old warrior with nothing left to prove |
| 17 | Someone who claims to know the protagonist's kin |
| 18 | A deserter from a nearby lord's warband |
| 19 | A young man running an errand he refuses to describe |
| 20 | A stranger in foreign dress, lost and afraid |
---
## Layer 2B: Group Encountered (d20)
| Roll | Who |
|------|-----|
| 1 | Raiders looking for easy prey |
| 2 | Funeral procession, silent and watchful |
| 3 | Displaced villagers with everything they own |
| 4 | Hunting party — purpose unclear |
| 5 | Mercenaries between contracts, drinking heavily |
| 6 | Settlers arguing loudly among themselves |
| 7 | A jarl's tax collectors with an armed escort |
| 8 | Pilgrims travelling to a sacred site |
| 9 | A warband returning home, fewer than they left |
| 10 | Slavers with a coffle of thralls |
| 11 | Fishermen sheltering from weather inland |
| 12 | A family fleeing a blood feud |
| 13 | Young warriors seeking their first raid |
| 14 | Traders from the east, nervously off-route |
| 15 | A lord's patrol, suspicious of lone travellers |
| 16 | Refugees from a burned settlement |
| 17 | Drunken men from a wedding, armed but unfocused |
| 18 | A group of thralls transporting goods without supervision |
| 19 | Outlaws who've mistaken the protagonist for someone else |
| 20 | A band of women — armed, organised, and saying little |
---
## Layer 2C: Weather Turns (d20)
| Roll | Weather |
|------|---------|
| 1 | Sudden blizzard — visibility drops to nothing |
| 2 | Ice storm — exposed skin at risk within the hour |
| 3 | Fog so thick landmarks vanish entirely |
| 4 | Flash flood from snowmelt upstream |
| 5 | Freezing rain turns every surface treacherous |
| 6 | Unseasonable warmth — ice gives way underfoot without warning |
| 7 | Wind strong enough to knock a person sideways |
| 8 | Sleet drives horizontally, stings like needles |
| 9 | Temperature drops sharply at nightfall — no shelter near |
| 10 | Heavy snow buries the trail ahead |
| 11 | Lightning storm with nowhere to take cover |
| 12 | Hail strips leaves, spooks animals, bruises skin |
| 13 | Mist rising off ice — beautiful and disorienting |
| 14 | Frost so hard the ground rings like iron underfoot |
| 15 | A thaw that turns solid ground to mud overnight |
| 16 | Darkness falls two hours early — unnatural-feeling |
| 17 | Wind that carries sound strangely — voices from nowhere |
| 18 | Snow so dry it shifts like sand, fills tracks instantly |
| 19 | A brief clearing — then worse than before |
| 20 | Sky turns green before the storm hits |
---
## Layer 2D: Find Something (d100)
| Roll | What |
|------|------|
| 0102 | Abandoned camp, fire still warm |
| 0304 | A corpse stripped of everything but its boots |
| 0506 | A child's toy in the middle of nowhere |
| 0708 | A boat run aground with no crew |
| 0910 | A cache of stolen goods, poorly hidden |
| 1112 | A runestone defaced recently |
| 1314 | A hanged man — still breathing |
| 1516 | A sword driven into the earth, no grave beneath it |
| 1718 | A bag of silver with no name on it |
| 1920 | A dead horse with all four legs broken |
| 2122 | A campfire surrounded by nine untouched seats |
| 2324 | A letter sealed and addressed, never delivered |
| 2526 | A pit dug and abandoned mid-task |
| 2728 | Fresh tracks leading off the path and then stopping |
| 2930 | A crude grave with a weapon laid across it |
| 3132 | Signs of a large camp, abandoned days ago |
| 3334 | A locked chest with no key and no owner |
| 3536 | A cart overturned, goods intact, horse gone |
| 3738 | A bundle of clothes belonging to a child |
| 3940 | A pile of ash where something large was burned |
| 4142 | A trap set on the trail — recently, and well |
| 4344 | A carcass half-butchered and left |
| 4546 | A carved idol left at a crossroads |
| 4748 | A shield split cleanly in two |
| 4950 | A door standing alone in a field, hinged to nothing |
| 5152 | Bloodstained snow leading in two directions |
| 5354 | A drowned man face-down in a shallow stream |
| 5556 | A fire laid but never lit |
| 5758 | An empty cage sized for a person |
| 5960 | A broken cart axle and scattered grain |
| 6162 | A wound dressing discarded on the trail |
| 6364 | A ring of stones too large to be a cooking fire |
| 6566 | A well with something tied to the rope that isn't a bucket |
| 6768 | A fresh cairn with no name |
| 6970 | Burnt offerings at a tree, still smouldering |
| 7172 | A pair of boots, side by side, perfectly placed |
| 7374 | A journal in a language the protagonist doesn't know |
| 7576 | A broken spear shaft — a fine one, from somewhere important |
| 7778 | A midden heap with something that doesn't belong |
| 7980 | A ford marked with warning signs ignored by someone recently |
| 8182 | A farmstead intact but completely empty |
| 8384 | A bell hanging from a tree with no rope to ring it |
| 8586 | Animal bones arranged in a deliberate pattern |
| 8788 | A cloak pinned to the ground with a knife |
| 8990 | A child's hiding spot — recently used |
| 9192 | A rope bridge cut from one side |
| 9394 | A message carved into living wood |
| 9596 | A body buried face-down — someone afraid of what it might do |
| 9798 | A ship's figurehead far from any coast |
| 99100 | A perfectly preserved meal laid out with no one to eat it |
---
## Layer 2E: Natural Hazard (d20)
| Roll | Hazard |
|------|--------|
| 1 | River crossing — current deceptively strong |
| 2 | Thin ice over deep water |
| 3 | Rockslide blocking the pass |
| 4 | Wolves — hungry, patient, following |
| 5 | Marsh that doesn't appear on any mental map |
| 6 | Trail disappears under fresh snowfall |
| 7 | A tree falls across the path in the night |
| 8 | Loose scree on a slope that looked solid |
| 9 | A frozen waterfall — must be climbed or bypassed at great cost |
| 10 | Bog hidden under a dusting of snow |
| 11 | A river swollen and impassable where it wasn't before |
| 12 | Ice fog — cold enough to freeze breath on a scarf |
| 13 | Bear newly woken, confused and aggressive |
| 14 | A cliff path with recent falls along its edge |
| 15 | Night catches the protagonist without shelter in an exposed place |
| 16 | A ford turned to deep water by rain upstream |
| 17 | Deadfall — a tangle of fallen timber blocks the wood path |
| 18 | A crack in the ice the protagonist nearly doesn't see |
| 19 | Ravens massing overhead — drawing attention |
| 20 | An avalanche track still moving |
---
## Layer 2F: Signs of Recent Violence (d20)
| Roll | What You Find |
|------|---------------|
| 1 | Burned farmstead, still smoking |
| 2 | Bodies left unburied — deliberate dishonour |
| 3 | Blood trail leading off the path |
| 4 | A survivor too shocked to speak |
| 5 | Livestock scattered, no people anywhere |
| 6 | A child hiding, refusing to move |
| 7 | A settlement intact but everyone inside is dead |
| 8 | A battlefield — small scale, very recent |
| 9 | Weapons abandoned mid-fight and not retrieved |
| 10 | A man dying slowly, won't say who did it |
| 11 | Tracks of many boots leaving in a hurry |
| 12 | A house torn apart from the inside |
| 13 | Drag marks leading to the water |
| 14 | A warning left by the attackers for anyone who follows |
| 15 | Looted grave goods scattered on the road |
| 16 | A woman alive among the dead, armed with what she could find |
| 17 | A child carrying something that belonged to someone else |
| 18 | Signs of a struggle with no bodies — just blood |
| 19 | A man hanged from a tree with a carved accusation around his neck |
| 20 | Heads displayed on stakes at the settlement boundary |
---
## Layer 2G: Pursuit / Being Followed (d20)
| Roll | Who or What |
|------|-------------|
| 1 | Debt collector with hired muscle |
| 2 | Someone who blames the protagonist for a death |
| 3 | A thrall-catcher — wrong target |
| 4 | Outlaw seeking to silence a witness |
| 5 | Unknown — never shows themselves |
| 6 | A dog that won't stop following |
| 7 | A boy who wants to come along |
| 8 | A woman who says she's been hired to watch |
| 9 | A wounded man trailing blood and asking for help |
| 10 | Two riders keeping distance but matching pace |
| 11 | Someone who knows the protagonist's destination |
| 12 | A man who claims to be following the same person |
| 13 | A local lord's men, claiming lawful business |
| 14 | A sworn enemy of someone the protagonist helped |
| 15 | A crow — too consistent to be coincidence |
| 16 | A group that disappears whenever checked but reappears at dusk |
| 17 | A young woman escaping a marriage arrangement |
| 18 | A former comrade who shouldn't be alive |
| 19 | Someone who says they're there for the protagonist's protection |
| 20 | A figure glimpsed only at the edge of firelight |
---
## Layer 2H: Physical Hardship (d20)
| Roll | Hardship |
|------|----------|
| 1 | Food runs out a day earlier than expected |
| 2 | Shelter falls through — night in the open |
| 3 | Injury from a stumble — nothing fatal, everything inconvenient |
| 4 | Equipment fails at the worst moment |
| 5 | Illness sets in slowly over the day |
| 6 | Exhaustion forces a stop in exposed terrain |
| 7 | Water source is frozen or fouled |
| 8 | Boots give out — blisters or worse |
| 9 | A fire won't start in wet conditions |
| 10 | Pack comes loose and gear is scattered in the dark |
| 11 | An old wound reopens |
| 12 | Hands too cold to grip properly |
| 13 | Stomach sickness from something eaten |
| 14 | Sleep impossible due to cold, noise, or dread |
| 15 | Frostbite threatening fingers or toes |
| 16 | Navigation error adds half a day to the journey |
| 17 | Horse or pack animal goes lame |
| 18 | Weight carried proves too much — something must be abandoned |
| 19 | A tool or weapon lost in a river crossing |
| 20 | Dehydration from sweating in cold gear |
---
## Layer 2I: Something Uncanny (d20)
| Roll | What |
|------|------|
| 1 | Tracks in snow that begin from nowhere |
| 2 | A voice calling a name — no source found |
| 3 | An animal behaving with deliberate intelligence |
| 4 | The same landmark passed twice on a straight road |
| 5 | A dream so vivid it bleeds into waking |
| 6 | A stranger who vanishes between one breath and the next |
| 7 | A fire that burns green for a moment |
| 8 | The protagonist's own reflection does something different |
| 9 | A sound like weeping from deep in the ice |
| 10 | Birds all flee at once, in silence |
| 11 | A path that wasn't there yesterday and isn't on any map |
| 12 | A figure standing very still on the horizon — gone when approached |
| 13 | Stars in the wrong positions for an hour |
| 14 | The smell of the sea miles from any coast |
| 15 | An old man who answers questions the protagonist didn't ask aloud |
| 16 | A handprint in frost on the inside of a sealed shelter |
| 17 | A sound of battle faint and distant — no source ever found |
| 18 | The protagonist wakes with soil under their fingernails |
| 19 | A raven speaks one word, clearly, and flies away |
| 20 | Time passes differently — a night feels like minutes, or hours |
---
*These tables are designed to layer. Roll Layer 1 to frame what kind of event occurs, then roll Layer 2 if you want specifics. Trust your instincts — skip or reroll anything that doesn't fit the moment.*
File diff suppressed because it is too large Load Diff