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}`); });