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
865 lines
33 KiB
JavaScript
865 lines
33 KiB
JavaScript
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}`);
|
||
});
|