First Commit
This commit is contained in:
@@ -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.
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
Generated
+1265
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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} MP ${sb.magic_points_current}/${sb.magic_points_max} Move ${sb.move}${sb.culture ? ` <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> · ${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();
|
||||||
@@ -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>
|
||||||
@@ -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); }
|
||||||
Vendored
+2735
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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 |
|
||||||
|
|------|-------|
|
||||||
|
| 1–3 | Nothing — uneventful travel |
|
||||||
|
| 4–5 | Weather turns |
|
||||||
|
| 6–8 | Encounter a person |
|
||||||
|
| 9–10 | Encounter a group |
|
||||||
|
| 11–12 | Find something |
|
||||||
|
| 13–14 | Natural hazard |
|
||||||
|
| 15–16 | Signs of recent violence |
|
||||||
|
| 17–18 | 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 |
|
||||||
|
|------|------|
|
||||||
|
| 01–02 | Abandoned camp, fire still warm |
|
||||||
|
| 03–04 | A corpse stripped of everything but its boots |
|
||||||
|
| 05–06 | A child's toy in the middle of nowhere |
|
||||||
|
| 07–08 | A boat run aground with no crew |
|
||||||
|
| 09–10 | A cache of stolen goods, poorly hidden |
|
||||||
|
| 11–12 | A runestone defaced recently |
|
||||||
|
| 13–14 | A hanged man — still breathing |
|
||||||
|
| 15–16 | A sword driven into the earth, no grave beneath it |
|
||||||
|
| 17–18 | A bag of silver with no name on it |
|
||||||
|
| 19–20 | A dead horse with all four legs broken |
|
||||||
|
| 21–22 | A campfire surrounded by nine untouched seats |
|
||||||
|
| 23–24 | A letter sealed and addressed, never delivered |
|
||||||
|
| 25–26 | A pit dug and abandoned mid-task |
|
||||||
|
| 27–28 | Fresh tracks leading off the path and then stopping |
|
||||||
|
| 29–30 | A crude grave with a weapon laid across it |
|
||||||
|
| 31–32 | Signs of a large camp, abandoned days ago |
|
||||||
|
| 33–34 | A locked chest with no key and no owner |
|
||||||
|
| 35–36 | A cart overturned, goods intact, horse gone |
|
||||||
|
| 37–38 | A bundle of clothes belonging to a child |
|
||||||
|
| 39–40 | A pile of ash where something large was burned |
|
||||||
|
| 41–42 | A trap set on the trail — recently, and well |
|
||||||
|
| 43–44 | A carcass half-butchered and left |
|
||||||
|
| 45–46 | A carved idol left at a crossroads |
|
||||||
|
| 47–48 | A shield split cleanly in two |
|
||||||
|
| 49–50 | A door standing alone in a field, hinged to nothing |
|
||||||
|
| 51–52 | Bloodstained snow leading in two directions |
|
||||||
|
| 53–54 | A drowned man face-down in a shallow stream |
|
||||||
|
| 55–56 | A fire laid but never lit |
|
||||||
|
| 57–58 | An empty cage sized for a person |
|
||||||
|
| 59–60 | A broken cart axle and scattered grain |
|
||||||
|
| 61–62 | A wound dressing discarded on the trail |
|
||||||
|
| 63–64 | A ring of stones too large to be a cooking fire |
|
||||||
|
| 65–66 | A well with something tied to the rope that isn't a bucket |
|
||||||
|
| 67–68 | A fresh cairn with no name |
|
||||||
|
| 69–70 | Burnt offerings at a tree, still smouldering |
|
||||||
|
| 71–72 | A pair of boots, side by side, perfectly placed |
|
||||||
|
| 73–74 | A journal in a language the protagonist doesn't know |
|
||||||
|
| 75–76 | A broken spear shaft — a fine one, from somewhere important |
|
||||||
|
| 77–78 | A midden heap with something that doesn't belong |
|
||||||
|
| 79–80 | A ford marked with warning signs ignored by someone recently |
|
||||||
|
| 81–82 | A farmstead intact but completely empty |
|
||||||
|
| 83–84 | A bell hanging from a tree with no rope to ring it |
|
||||||
|
| 85–86 | Animal bones arranged in a deliberate pattern |
|
||||||
|
| 87–88 | A cloak pinned to the ground with a knife |
|
||||||
|
| 89–90 | A child's hiding spot — recently used |
|
||||||
|
| 91–92 | A rope bridge cut from one side |
|
||||||
|
| 93–94 | A message carved into living wood |
|
||||||
|
| 95–96 | A body buried face-down — someone afraid of what it might do |
|
||||||
|
| 97–98 | A ship's figurehead far from any coast |
|
||||||
|
| 99–100 | 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
Reference in New Issue
Block a user