Files
story_builder/server.js
stefwill 8bccf6f484 feat: inventory system, adventure scene engine, and PC generator fixes
- Inventory: items linked to PCs with name, qty, ENC, category; effective
  FP = STR+CON minus total ENC carried; CRUD API + inline UI on Characters tab
- Adventure scene engine: new tab with procedural CYOA choices (Fight, Sneak,
  Talk, Investigate, Flee) resolved via skill checks; scenes generated from
  any rollable table with automatic L2 cascade; scene state persisted in DB
- Fix total HP formula: was CON+SIZ, now correctly ceil((CON+SIZ)/2) per RQ3
- Fix hit location HPs: were computed from wrong total HP, now correct
- Add fatigue points (STR+CON) to derived stats
- Add all seven skill category modifiers computed from characteristics
  (primary/secondary/negative influences per rulebook), shown in generator UI
- Add base skill computation (computeBaseSkills) for use in scene engine
- Add RQ3 Players Book to docs/ as reference
2026-07-03 16:00:54 +10:00

765 lines
29 KiB
JavaScript
Raw Permalink 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 ----------
app.get('/api/characters', (req, res) => {
res.json(dbApi.playerCharacters.list());
});
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 } = 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,
};
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock });
dbApi.statBlocks.setHitLocations(pc.stat_block_id, derived.hitLocations);
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived } });
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();
});
// ---------- 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);
});
// ---------- 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}`);
});