2026-06-30 09:10:05 +10:00
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 );
});
2026-07-03 16:00:54 +10:00
// ---------- 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 ();
});
2026-06-30 09:10:05 +10:00
// ---------- 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 ,
});
2026-07-01 08:19:01 +10:00
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 ,
});
2026-06-30 09:10:05 +10:00
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 } ` );
});