Files
story_builder/server.js
T
stefwill 5e83fe1ef2 feat: full character creation, personality traits, and session quality-of-life
Character creation:
- Full RQ3 Previous Experience wizard (4-step: identity, characteristics,
  culture/occupation/skills, review+save) with all 29 occupations across
  4 cultures; skills computed as base + category modifier + years × multiplier
- Character sheet expanded to show culture, occupation, derived stats (HP/FP/MP/
  DB/SR), all 7 skill category modifier badges, computed skill percentages grouped
  by category, weapon attack/parry %, hit locations with armour AP
- Location/destination tracker on character sheet (auto-saves on blur)
- parry_percent stored on combatant_weapons

Personality traits:
- 24 predefined traits (Brave, Greedy, Cautious, etc.) each rated 0-100%
- Trait → action bias map drives scene choice weighting
- rollPersonalityAction() rolls d100 per trait, sums biases, returns suggestion
- Trait editor (pill UI) on both character sheet and NPC view
- Adventure tab: Roll Personality button fires traits, highlights suggested choice

Tables and data:
- Import RQ3 character creation tables (Culture d8, Occupation d100 ×4, Craft
  sub-tables, Language Proficiency, Dropped Oil Lamp, Aging, Armor Points)
- Cross-table links: Culture → Occupation, Barbarian/Civilized Crafter → Craft
- Fix rollOnTable to use actual dice notation instead of flat random row index
- Remove unused resolveHeadInjury function

Session tools:
- Clear All button wipes all session data (characters, NPCs, enemies, combat,
  adventure, log) while keeping tables and spell mappings
- PATCH /api/characters/:id/traits and /api/npcs/:id/traits endpoints
- GET /api/rules/personality-traits reference endpoint
- POST /api/adventure/personality-roll endpoint
2026-07-03 21:44:11 +10:00

865 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
// ---------- Player Characters ----------
function enrichCharacter(pc) {
if (!pc || !pc.stat_block) return pc;
const sb = pc.stat_block;
const chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
pc.derived = rq3.deriveCharacterStats(chars);
if (pc.culture && pc.occupation) {
const years = Math.max(0, (pc.age || 21) - 15);
const occ = rq3.computeOccupationSkills(chars, pc.culture, pc.occupation, years);
if (occ) {
pc.skills = occ.skills;
pc.craftBonuses = occ.craftBonuses;
pc.ritualBonuses = occ.ritualBonuses;
pc.occupation_label = occ.occupation && occ.occupation.label;
}
} else {
pc.skills = rq3.computeBaseSkills(chars);
}
return pc;
}
app.get('/api/characters', (req, res) => {
res.json(dbApi.playerCharacters.list().map(enrichCharacter));
});
app.get('/api/characters/:id', (req, res) => {
const pc = dbApi.playerCharacters.get(Number(req.params.id));
if (!pc) return res.status(404).json({ error: 'Character not found' });
res.json(enrichCharacter(pc));
});
app.patch('/api/characters/:id/location', (req, res) => {
const { current_location, destination } = req.body || {};
const pc = dbApi.playerCharacters.updateLocation(Number(req.params.id), { current_location, destination });
if (!pc) return res.status(404).json({ error: 'Character not found' });
res.json({ current_location: pc.current_location, destination: pc.destination });
});
app.post('/api/characters/roll-age', (req, res) => {
res.json({ age: rq3.rollAge() });
});
app.post('/api/characters/compute-occupation', (req, res) => {
const { characterId, culture, occupation, years } = req.body || {};
if (!culture || !occupation) return res.status(400).json({ error: 'culture and occupation are required' });
let chars;
if (characterId) {
const pc = dbApi.playerCharacters.get(Number(characterId));
if (!pc) return res.status(404).json({ error: 'Character not found' });
const sb = pc.stat_block;
chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
} else if (req.body.chars) {
chars = req.body.chars;
} else {
return res.status(400).json({ error: 'characterId or chars is required' });
}
try {
const result = rq3.computeOccupationSkills(chars, culture, occupation, Number(years) || 0);
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/characters/roll', (req, res) => {
const { method } = req.body || {};
if (method !== 'random' && method !== 'combined') {
return res.status(400).json({ error: 'method must be random or combined' });
}
const chars = rq3.rollCharacteristics();
const derived = rq3.deriveCharacterStats(chars);
res.json({ chars, derived, bonusPoints: method === 'combined' ? 6 : 0 });
});
app.post('/api/characters/derive', (req, res) => {
const { str, con, siz, int, pow, dex, app: app_ } = req.body || {};
const chars = { str: Number(str) || 0, con: Number(con) || 0, siz: Number(siz) || 0, int: Number(int) || 0, pow: Number(pow) || 0, dex: Number(dex) || 0, app: Number(app_) || 0 };
const derived = rq3.deriveCharacterStats(chars);
res.json({ chars, derived });
});
app.post('/api/characters/validate', (req, res) => {
const { method, chars } = req.body || {};
if (method === 'deliberate') return res.json(rq3.validateDeliberate(chars || {}));
if (method === 'combined') return res.json(rq3.validateCombined(chars || {}));
return res.status(400).json({ error: 'method must be deliberate or combined' });
});
app.post('/api/characters', (req, res) => {
const { name, generation_method, chars, age, culture, occupation, weapons } = req.body || {};
if (!name) return res.status(400).json({ error: 'name is required' });
if (!chars) return res.status(400).json({ error: 'chars is required' });
const derived = rq3.deriveCharacterStats(chars);
const statBlock = {
str: chars.str, con: chars.con, siz: chars.siz, int: chars.int,
pow: chars.pow, dex: chars.dex, app: chars.app,
max_hp: derived.totalHp, current_hp: derived.totalHp,
magic_points_max: derived.magicPoints, magic_points_current: derived.magicPoints,
culture: culture ?? null,
};
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock, age, culture, occupation });
dbApi.statBlocks.setHitLocations(pc.stat_block_id, derived.hitLocations);
if (weapons && Array.isArray(weapons) && weapons.length) {
dbApi.statBlocks.setWeapons(pc.stat_block_id, weapons);
}
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived, age, culture, occupation } });
res.status(201).json(dbApi.playerCharacters.get(pc.id));
});
app.delete('/api/characters/:id', (req, res) => {
const ok = dbApi.playerCharacters.delete(Number(req.params.id));
if (!ok) return res.status(404).json({ error: 'Character not found' });
res.status(204).end();
});
app.patch('/api/characters/:id/traits', (req, res) => {
const traits = req.body && Array.isArray(req.body.traits) ? req.body.traits : [];
const pc = dbApi.playerCharacters.updateTraits(Number(req.params.id), traits);
if (!pc) return res.status(404).json({ error: 'Character not found' });
res.json({ traits: pc.traits || [] });
});
app.patch('/api/npcs/:id/traits', (req, res) => {
const traits = req.body && Array.isArray(req.body.traits) ? req.body.traits : [];
const npc = dbApi.npcs.updateTraits(Number(req.params.id), traits);
if (!npc) return res.status(404).json({ error: 'NPC not found' });
res.json({ traits: npc.traits || [] });
});
app.get('/api/rules/personality-traits', (req, res) => {
res.json({ traits: rq3.PERSONALITY_TRAITS, biases: rq3.TRAIT_ACTION_BIAS });
});
app.post('/api/adventure/personality-roll', (req, res) => {
const state = dbApi.adventure.get();
if (!state) return res.status(400).json({ error: 'No active adventure scene' });
const pc = dbApi.playerCharacters.get(state.characterId);
if (!pc) return res.status(400).json({ error: 'No character for active adventure' });
const result = rq3.rollPersonalityAction(pc.traits || []);
dbApi.log.append({
type: 'adventure',
summary: `Personality roll for ${pc.name}: suggested ${result.suggested || 'none'} (${result.firedTraits.map(t => t.name).join(', ') || 'no traits fired'})`,
details: result,
});
res.json(result);
});
// ---------- Inventory ----------
app.get('/api/characters/:id/inventory', (req, res) => {
const items = dbApi.inventory.list(Number(req.params.id));
const totalEnc = dbApi.inventory.totalEnc(Number(req.params.id));
res.json({ items, totalEnc });
});
app.post('/api/characters/:id/inventory', (req, res) => {
const { name, quantity, enc, category, notes } = req.body || {};
if (!name) return res.status(400).json({ error: 'name is required' });
const item = dbApi.inventory.add({ character_id: Number(req.params.id), name, quantity, enc, category, notes });
res.status(201).json(item);
});
app.patch('/api/characters/:id/inventory/:itemId', (req, res) => {
const item = dbApi.inventory.update(Number(req.params.itemId), req.body || {});
if (!item) return res.status(404).json({ error: 'Item not found' });
res.json(item);
});
app.delete('/api/characters/:id/inventory/:itemId', (req, res) => {
const ok = dbApi.inventory.delete(Number(req.params.itemId));
if (!ok) return res.status(404).json({ error: 'Item not found' });
res.status(204).end();
});
// ---------- Adventure ----------
app.get('/api/adventure', (req, res) => {
res.json(dbApi.adventure.get());
});
app.post('/api/adventure/start', (req, res) => {
const { characterId, tableId } = req.body || {};
const pc = dbApi.playerCharacters.get(Number(characterId));
if (!pc) return res.status(400).json({ error: 'Unknown character' });
// Roll a table to generate the scene description
let tableResult = null;
let description = 'You find yourself in an unexpected situation.';
if (tableId) {
tableResult = dbApi.tables.roll(Number(tableId));
if (tableResult) {
// Cascade into a linked sub-table if available
const link = tableResult.links && tableResult.links[0];
let subResult = null;
if (link) {
subResult = dbApi.tables.roll(link.target_table_id);
}
const cells = tableResult.row.cells.filter((c) => c && !/^\d+[-]\d+$/.test(c.trim()) && !/^\d+$/.test(c.trim()));
const subCells = subResult ? subResult.row.cells.filter((c) => c && !/^\d+[-]\d+$/.test(c.trim()) && !/^\d+$/.test(c.trim())) : [];
description = [...cells, ...subCells].join(' — ') || description;
if (subResult) tableResult.subResult = subResult;
}
}
const sb = pc.stat_block;
const chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
const baseSkills = rq3.computeBaseSkills(chars);
const enc = dbApi.inventory.totalEnc(pc.id);
const effectiveFp = (chars.str + chars.con) - enc;
const choices = rq3.SCENE_CHOICES.map((c) => ({
id: c.id,
label: c.label,
skill: c.skill,
skillPercent: c.skill ? baseSkills[c.skill] ?? 0 : null,
safe: c.safe,
}));
const scene = {
status: 'active',
description,
tableResult: tableResult ? { tableId, tableName: tableResult.table.name, rowCells: tableResult.row.cells } : null,
choices,
effectiveFp,
selectedChoice: null,
resolution: null,
};
dbApi.log.append({ type: 'adventure', summary: `Scene started: ${description.slice(0, 80)}`, details: { characterId, description } });
res.json(dbApi.adventure.set(pc.id, scene));
});
app.post('/api/adventure/choose', (req, res) => {
const { choiceId } = req.body || {};
const state = dbApi.adventure.get();
if (!state) return res.status(400).json({ error: 'No active adventure scene' });
if (state.scene.status !== 'active') return res.status(400).json({ error: 'Scene already resolved' });
const choice = state.scene.choices.find((c) => c.id === choiceId);
if (!choice) return res.status(400).json({ error: 'Unknown choice' });
const resolution = rq3.resolveSceneChoice(choiceId, choice.skillPercent);
const scene = {
...state.scene,
status: resolution.outcome === 'combat' ? 'combat' : resolution.escalates ? 'escalated' : 'resolved',
selectedChoice: choiceId,
resolution,
};
dbApi.log.append({
type: 'adventure',
summary: `Scene choice: ${choice.label}${resolution.outcome === 'combat' ? 'combat' : resolution.outcome}`,
details: resolution,
});
res.json(dbApi.adventure.set(state.characterId, scene));
});
app.delete('/api/adventure', (req, res) => {
dbApi.adventure.clear();
res.status(204).end();
});
// ---------- 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/stuck-weapon-removal', (req, res) => {
const { attackerCombatantId, defenderCombatantId, weaponName, kind, removalType, firstAidSkillPercent } = 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' });
if (kind !== 'impale' && kind !== 'slash') return res.status(400).json({ error: 'kind must be impale or slash' });
let result;
if (removalType === 'attacker') {
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}"` });
result = rq3.attemptWeaponRemoval(weaponEntry.skill_percent, kind);
result.removalType = 'attacker';
} else if (removalType === 'self') {
result = { success: rq3.removeStuckWeaponFromSelf(defender.str + defender.con), weaponBreaks: false, removalType: 'self' };
} else if (removalType === 'first-aid') {
const pct = Number(firstAidSkillPercent) || 0;
result = { success: rq3.removeStuckWeaponWithFirstAid(pct), weaponBreaks: false, removalType: 'first-aid' };
} else {
return res.status(400).json({ error: 'removalType must be attacker, self, or first-aid' });
}
const outcome = result.weaponBreaks ? 'weapon breaks' : result.success ? 'weapon removed' : 'weapon stays stuck';
dbApi.log.append({
type: 'combat',
summary: `Stuck-weapon removal (${removalType}, ${kind}): ${outcome}`,
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);
});
app.post('/api/clear-all', (req, res) => {
dbApi.clearAll();
res.status(204).end();
});
// ---------- 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}`);
});