2026-06-30 09:10:05 +10:00
// Frontend logic only. No RQ3 rules calculations happen here - everything goes through /api/*.
const SPELL_MECHANIC_IDS = [ 'bladesharp' , 'protection' , 'heal' , 'disruption' , 'demoralize' , 'coordination' ];
const state = {
view : 'tables' ,
tablesTree : [],
expandedNodes : new Set (),
selectedTableId : null ,
selectedTable : null ,
lastRoll : null ,
manualSelectOpen : false ,
npcs : [],
selectedNpcId : null ,
enemies : [],
selectedEnemyId : null ,
combat : null ,
reactionType : 'none' ,
spellMappings : [],
logEntries : [],
2026-07-03 16:00:54 +10:00
playerCharacters : [],
selectedCharacterId : null ,
characterInventory : [],
adventure : null ,
2026-06-30 09:10:05 +10:00
dirty : false ,
attackModifiers : [],
armorTable : [],
selectedModifierIds : new Set (),
};
// ---------- low-level helpers ----------
function qs ( sel , root = document ) { return root . querySelector ( sel ); }
function el ( html ) {
const t = document . createElement ( 'template' );
t . innerHTML = html . trim ();
return t . content . firstElementChild ;
}
function escapeHtml ( s ) {
return String ( s ?? '' ). replace ( /[&<>"']/g , ( c ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
}
function md ( text ) {
if ( ! text ) return '' ;
// parseInline (not parse) - these are short flavor-text fragments rendered inside our
// own wrapper elements; parse()'s block-level <p> wrapping causes nested-<p> auto-close
// bugs when our wrapper is also a <p>.
return window . marked ? window . marked . parseInline ( text ) : escapeHtml ( text );
}
async function api ( method , path , body ) {
const opts = { method , headers : {} };
if ( body !== undefined ) {
opts . headers [ 'Content-Type' ] = 'application/json' ;
opts . body = JSON . stringify ( body );
}
const res = await fetch ( path , opts );
if ( res . status === 204 ) return null ;
let data = null ;
try { data = await res . json (); } catch ( e ) { /* no body */ }
if ( ! res . ok ) throw new Error (( data && data . error ) || `Request failed ( ${ res . status } )` );
state . dirty = true ;
return data ;
}
function tierTag ( tier ) {
return `<span class="tag tier- ${ tier } "> ${ tier } </span>` ;
}
// ---------- load ----------
async function loadTablesTree () { state . tablesTree = await api ( 'GET' , '/api/tables/tree' ); }
async function loadNpcs () { state . npcs = await api ( 'GET' , '/api/npcs' ); }
async function loadEnemies () { state . enemies = await api ( 'GET' , '/api/enemies' ); }
async function loadCombat () { state . combat = await api ( 'GET' , '/api/combat' ); }
async function loadSpellMappings () { state . spellMappings = await api ( 'GET' , '/api/spell-mappings' ); }
2026-07-03 16:00:54 +10:00
async function loadPlayerCharacters () { state . playerCharacters = await api ( 'GET' , '/api/characters' ); }
async function loadInventory ( characterId ) {
const res = await api ( 'GET' , `/api/characters/ ${ characterId } /inventory` );
state . characterInventory = res ? res . items : [];
return res ;
}
async function loadAdventure () { state . adventure = await api ( 'GET' , '/api/adventure' ); }
2026-06-30 09:10:05 +10:00
async function loadLog ( search = '' ) {
state . logEntries = await api ( 'GET' , `/api/log ${ search ? `?search= ${ encodeURIComponent ( search ) } ` : '' } ` );
}
async function loadAttackModifiers () { state . attackModifiers = await api ( 'GET' , '/api/rules/attack-modifiers' ); }
async function loadArmorTable () { state . armorTable = await api ( 'GET' , '/api/rules/armor-table' ); }
// ---------- top-level render ----------
function setView ( view ) {
state . view = view ;
document . querySelectorAll ( '.tab-btn' ). forEach (( b ) => b . classList . toggle ( 'active' , b . dataset . view === view ));
renderSidebar ();
renderMain ();
}
function renderSidebar () {
const root = qs ( '#sidebar-content' );
root . innerHTML = '' ;
if ( state . view === 'tables' ) root . appendChild ( renderTablesSidebar ());
if ( state . view === 'npcs' ) root . appendChild ( renderNpcsSidebar ());
if ( state . view === 'enemies' ) root . appendChild ( renderEnemiesSidebar ());
if ( state . view === 'combat' ) root . appendChild ( renderCombatSidebar ());
2026-07-03 16:00:54 +10:00
if ( state . view === 'characters' ) root . appendChild ( renderCharactersSidebar ());
if ( state . view === 'adventure' ) root . appendChild ( renderAdventureSidebar ());
2026-06-30 09:10:05 +10:00
}
function renderMain () {
const root = qs ( '#main-panel' );
root . innerHTML = '' ;
if ( state . view === 'tables' ) root . appendChild ( renderTablesMain ());
if ( state . view === 'npcs' ) root . appendChild ( renderNpcsMain ());
if ( state . view === 'enemies' ) root . appendChild ( renderEnemiesMain ());
if ( state . view === 'combat' ) root . appendChild ( renderCombatMain ());
2026-07-03 16:00:54 +10:00
if ( state . view === 'characters' ) root . appendChild ( renderCharactersMain ());
if ( state . view === 'adventure' ) root . appendChild ( renderAdventureMain ());
2026-06-30 09:10:05 +10:00
}
// ======================================================================
// TABLES
// ======================================================================
function renderTreeNode ( node ) {
const hasChildren = node . children && node . children . length > 0 ;
const expanded = state . expandedNodes . has ( node . id );
const wrap = el ( `<div class="tree-node"></div>` );
const heading = el ( `
<div class="tree-heading ${ node . table && state . selectedTableId === node . table . id ? 'selected' : '' } " data-node-id=" ${ node . id } ">
<span class="tree-toggle"> ${ hasChildren ? ( expanded ? '▾' : '▸' ) : '' } </span>
<span> ${ escapeHtml ( node . heading_text ) } </span>
${ node . table ? `<span class="tree-table-icon">[ ${ node . table . dice_notation || '?' } ]</span>` : '' }
</div>
` );
heading . addEventListener ( 'click' , () => {
if ( node . table ) selectTable ( node . table . id );
if ( hasChildren ) {
if ( expanded ) state . expandedNodes . delete ( node . id ); else state . expandedNodes . add ( node . id );
renderSidebar ();
}
});
wrap . appendChild ( heading );
if ( hasChildren && expanded ) {
const childWrap = el ( `<div class="tree-children"></div>` );
node . children . forEach (( c ) => childWrap . appendChild ( renderTreeNode ( c )));
wrap . appendChild ( childWrap );
}
return wrap ;
}
function renderTablesSidebar () {
const wrap = el ( `<div></div>` );
if ( ! state . tablesTree . length ) {
wrap . appendChild ( el ( `<p class="empty-state">No tables imported.</p>` ));
return wrap ;
}
state . tablesTree . forEach (( root ) => wrap . appendChild ( renderTreeNode ( root )));
return wrap ;
}
async function selectTable ( tableId ) {
state . selectedTableId = tableId ;
state . selectedTable = await api ( 'GET' , `/api/tables/ ${ tableId } ` );
state . lastRoll = null ;
state . manualSelectOpen = false ;
renderSidebar ();
renderMain ();
}
async function rollSelectedTable () {
if ( ! state . selectedTableId ) return ;
const result = await api ( 'POST' , `/api/tables/ ${ state . selectedTableId } /roll` );
state . lastRoll = result ;
state . manualSelectOpen = false ;
await loadLog ();
renderMain ();
renderLog ();
}
async function manualSelectRow ( rowId ) {
const table = state . selectedTable ;
const row = table . rows . find (( r ) => r . id === rowId );
if ( ! row ) return ;
await api ( 'POST' , '/api/log' , {
type : 'roll' ,
summary : `Manually selected on " ${ table . name } ": ${ row . cells . join ( ' / ' ) } ` ,
details : { table_id : table . id , row },
});
state . lastRoll = { table , row , links : [] };
state . manualSelectOpen = false ;
await loadLog ();
renderMain ();
renderLog ();
}
function renderRollResult ( result ) {
const box = el ( `<div class="roll-result"></div>` );
box . appendChild ( el ( `<div class="roll-table-name"> ${ escapeHtml ( result . table . name ) } </div>` ));
const cellsWrap = el ( `<div class="roll-cells"></div>` );
result . row . cells . forEach (( cell , idx ) => {
const colName = result . table . columns [ idx ] ? result . table . columns [ idx ]. column_name : '' ;
cellsWrap . appendChild ( el ( `<p> ${ colName ? `<strong> ${ escapeHtml ( colName ) } :</strong> ` : '' }${ md ( cell ) } </p>` ));
});
box . appendChild ( cellsWrap );
if ( result . links && result . links . length ) {
const linkWrap = el ( `<div class="button-row"></div>` );
result . links . forEach (( link ) => {
const btn = el ( `<button class="button">Roll Linked Table</button>` );
btn . addEventListener ( 'click' , () => selectTable ( link . target_table_id ). then ( rollSelectedTable ));
linkWrap . appendChild ( btn );
});
box . appendChild ( linkWrap );
}
return box ;
}
function renderTablesMain () {
const wrap = el ( `<div></div>` );
if ( ! state . selectedTable ) {
wrap . appendChild ( el ( `<p class="empty-state">Select a table from the tree to roll on it.</p>` ));
return wrap ;
}
const table = state . selectedTable ;
wrap . appendChild ( el ( `<h2> ${ escapeHtml ( table . name ) } </h2>` ));
const actions = el ( `<div class="button-row"></div>` );
const rollBtn = el ( `<button class="button primary">Roll</button>` );
rollBtn . addEventListener ( 'click' , rollSelectedTable );
const rerollBtn = el ( `<button class="button">Re-roll</button>` );
rerollBtn . addEventListener ( 'click' , rollSelectedTable );
const manualBtn = el ( `<button class="button">Select Manually</button>` );
manualBtn . addEventListener ( 'click' , () => { state . manualSelectOpen = ! state . manualSelectOpen ; renderMain (); });
actions . append ( rollBtn , rerollBtn , manualBtn );
wrap . appendChild ( actions );
if ( state . lastRoll ) wrap . appendChild ( renderRollResult ( state . lastRoll ));
if ( state . manualSelectOpen ) {
const list = el ( `<div class="card"><h3>All entries</h3></div>` );
table . rows . forEach (( row ) => {
const item = el ( `<div class="weapon-row" style="cursor:pointer"><span> ${ escapeHtml ( row . roll_min === row . roll_max ? String ( row . roll_min ) : ` ${ row . roll_min } - ${ row . roll_max } ` ) } </span><span> ${ md ( row . cells . join ( ' / ' )) } </span></div>` );
item . addEventListener ( 'click' , () => manualSelectRow ( row . id ));
list . appendChild ( item );
});
wrap . appendChild ( list );
}
return wrap ;
}
// ======================================================================
// NPCS
// ======================================================================
function renderNpcsSidebar () {
const wrap = el ( `<div></div>` );
const actions = el ( `<div class="button-row"></div>` );
const fullBtn = el ( `<button class="button primary">+ Full NPC</button>` );
fullBtn . addEventListener ( 'click' , () => generateNpc ( 'full' ));
const fillerBtn = el ( `<button class="button">+ Filler NPC</button>` );
fillerBtn . addEventListener ( 'click' , () => generateNpc ( 'filler' ));
actions . append ( fullBtn , fillerBtn );
wrap . appendChild ( actions );
const list = el ( `<ul class="entity-list"></ul>` );
state . npcs . forEach (( npc ) => {
const name = ` ${ npc . first_name || '(unnamed)' } ${ npc . last_name || '' } ` . trim ();
const li = el ( `<li class=" ${ state . selectedNpcId === npc . id ? 'selected' : '' } status- ${ npc . status } "><span> ${ escapeHtml ( name ) } </span><span class="tag"> ${ npc . status } </span></li>` );
li . addEventListener ( 'click' , () => { state . selectedNpcId = npc . id ; renderSidebar (); renderMain (); });
list . appendChild ( li );
});
wrap . appendChild ( list );
return wrap ;
}
async function generateNpc ( npcType ) {
const npc = await api ( 'POST' , '/api/npcs/generate' , { npc_type : npcType });
await loadNpcs ();
state . selectedNpcId = npc . id ;
await loadLog ();
renderSidebar ();
renderMain ();
renderLog ();
}
async function rerollNpcField ( field ) {
await api ( 'POST' , `/api/npcs/ ${ state . selectedNpcId } /reroll-field` , { field });
await loadNpcs ();
await loadLog ();
renderSidebar ();
renderMain ();
renderLog ();
}
async function updateNpcField ( field , value ) {
await api ( 'PUT' , `/api/npcs/ ${ state . selectedNpcId } ` , { [ field ] : value });
await loadNpcs ();
renderSidebar ();
}
async function setNpcStatus ( status ) {
await api ( 'PUT' , `/api/npcs/ ${ state . selectedNpcId } ` , { status });
await loadNpcs ();
renderSidebar ();
renderMain ();
}
async function deleteSelectedNpc () {
if ( ! confirm ( 'Delete this NPC? This cannot be undone.' )) return ;
await api ( 'DELETE' , `/api/npcs/ ${ state . selectedNpcId } ` );
state . selectedNpcId = null ;
await loadNpcs ();
await loadLog ();
renderSidebar ();
renderMain ();
renderLog ();
}
async function attachNpcStatBlock () {
await api ( 'POST' , `/api/npcs/ ${ state . selectedNpcId } /generate-stat-block` );
await loadNpcs ();
renderMain ();
}
const NPC_CORE_FIELDS = [
[ 'first_name' , 'First Name' ], [ 'last_name' , 'Last Name' ], [ 'brief_description' , 'Brief Description' ],
[ 'wants_needs' , 'Wants and Needs' ], [ 'secret_obstacle' , 'Secret or Obstacle' ], [ 'also_carrying' , 'Also Carrying' ],
];
const NPC_ATTR_FIELDS = [
[ 'race' , 'Race' ], [ 'pronouns' , 'Pronouns' ], [ 'age' , 'Age' ], [ 'intelligence' , 'Intelligence' ], [ 'hair' , 'Hair' ], [ 'build' , 'Build' ],
];
function renderNpcField ( npc , key , label , rerollable ) {
const row = el ( `<div class="field-row"><label> ${ label } </label></div>` );
const valueWrap = el ( `<div class="field-value"></div>` );
const span = el ( `<span> ${ md ( npc [ key ]) || '<em>—</em>' } </span>` );
span . contentEditable = 'true' ;
span . addEventListener ( 'blur' , () => {
const text = span . innerText . trim ();
if ( text !== ( npc [ key ] || '' )) updateNpcField ( key , text );
});
valueWrap . appendChild ( span );
if ( rerollable ) {
const btn = el ( `<button class="button" title="Re-roll">🎲</button>` );
btn . addEventListener ( 'click' , () => rerollNpcField ( key ));
valueWrap . appendChild ( btn );
}
row . appendChild ( valueWrap );
return row ;
}
function renderNpcsMain () {
const wrap = el ( `<div></div>` );
const npc = state . npcs . find (( n ) => n . id === state . selectedNpcId );
if ( ! npc ) {
wrap . appendChild ( el ( `<p class="empty-state">Generate or select an NPC.</p>` ));
return wrap ;
}
const card = el ( `<div class="card"></div>` );
card . appendChild ( el ( `<h2> ${ escapeHtml ( ` ${ npc . first_name || '' } ${ npc . last_name || '' } ` . trim () || '(unnamed)' ) } <span class="tag"> ${ npc . npc_type } </span></h2>` ));
const statusRow = el ( `<div class="button-row"></div>` );
[ 'active' , 'dead' , 'inactive' ]. forEach (( s ) => {
const b = el ( `<button class="button ${ npc . status === s ? 'primary' : '' } "> ${ s } </button>` );
b . addEventListener ( 'click' , () => setNpcStatus ( s ));
statusRow . appendChild ( b );
});
const delBtn = el ( `<button class="button danger">Delete</button>` );
delBtn . addEventListener ( 'click' , deleteSelectedNpc );
statusRow . appendChild ( delBtn );
card . appendChild ( statusRow );
NPC_CORE_FIELDS . forEach (([ key , label ]) => card . appendChild ( renderNpcField ( npc , key , label , true )));
if ( npc . npc_type === 'full' ) {
NPC_ATTR_FIELDS . forEach (([ key , label ]) => card . appendChild ( renderNpcField ( npc , key , label , true )));
}
wrap . appendChild ( card );
const sbCard = el ( `<div class="card"><h3>Stat Block</h3></div>` );
if ( ! npc . stat_block ) {
const attachBtn = el ( `<button class="button">Attach Random Stat Block</button>` );
attachBtn . addEventListener ( 'click' , attachNpcStatBlock );
sbCard . appendChild ( attachBtn );
} else {
sbCard . appendChild ( renderStatBlockReadout ( npc . stat_block ));
}
wrap . appendChild ( sbCard );
return wrap ;
}
// ======================================================================
// shared stat block readout (used by NPC + enemy detail views)
// ======================================================================
function renderStatBlockReadout ( sb ) {
const wrap = el ( `<div></div>` );
const grid = el ( `<div class="char-grid"></div>` );
[ 'str' , 'con' , 'siz' , 'int' , 'pow' , 'dex' , 'app' ]. forEach (( c ) => {
grid . appendChild ( el ( `<div><div class="char-label"> ${ c } </div><div class="char-value"> ${ sb [ c ] } </div></div>` ));
});
wrap . appendChild ( grid );
wrap . appendChild ( el ( `<p>HP ${ sb . current_hp } / ${ sb . max_hp } MP ${ sb . magic_points_current } / ${ sb . magic_points_max } Move ${ sb . move }${ sb . culture ? ` <span class="tag"> ${ escapeHtml ( sb . culture ) } </span>` : '' } </p>` ));
const table = el ( `<table class="hit-loc-table"><thead><tr><th>Location</th><th>HP</th><th>AP</th></tr></thead></table>` );
const tbody = el ( `<tbody></tbody>` );
( sb . hit_locations || []). forEach (( loc ) => {
tbody . appendChild ( el ( `<tr class=" ${ loc . disabled ? 'disabled' : '' } "><td> ${ loc . location_name } </td><td> ${ loc . current_hp } / ${ loc . max_hp } </td><td> ${ loc . armor_ap } </td></tr>` ));
});
table . appendChild ( tbody );
wrap . appendChild ( table );
if (( sb . weapons || []). length ) {
sb . weapons . forEach (( w ) => {
wrap . appendChild ( el ( `<div class="weapon-row"><span> ${ escapeHtml ( w . weapon_name ) } </span><span class="tag"> ${ w . skill_percent } %</span> ${ w . mode ? `<span class="tag"> ${ w . mode } </span>` : '' } </div>` ));
});
}
if (( sb . spells || []). length ) {
sb . spells . forEach (( s ) => {
wrap . appendChild ( el ( `<div class="spell-row"><span> ${ escapeHtml ( s . custom_name ) } </span><span class="tag"> ${ s . mechanic_id } </span></div>` ));
});
}
return wrap ;
}
// ======================================================================
// ENEMIES
// ======================================================================
function renderEnemiesSidebar () {
const wrap = el ( `<div></div>` );
const genBtn = el ( `<button class="button primary">+ Generate Enemy</button>` );
genBtn . addEventListener ( 'click' , async () => {
const name = prompt ( 'Enemy name?' , 'New Enemy' );
if ( name === null ) return ;
const enemy = await api ( 'POST' , '/api/enemies/generate' , { name , category : 'Humanoid' });
await loadEnemies ();
state . selectedEnemyId = enemy . id ;
await loadLog ();
renderSidebar (); renderMain (); renderLog ();
});
wrap . appendChild ( genBtn );
const list = el ( `<ul class="entity-list"></ul>` );
state . enemies . forEach (( enemy ) => {
const li = el ( `<li class=" ${ state . selectedEnemyId === enemy . id ? 'selected' : '' } status- ${ enemy . status } "><span> ${ escapeHtml ( enemy . name ) } </span><span class="tag"> ${ enemy . stat_block . current_hp } / ${ enemy . stat_block . max_hp } </span></li>` );
li . addEventListener ( 'click' , () => { state . selectedEnemyId = enemy . id ; renderSidebar (); renderMain (); });
list . appendChild ( li );
});
wrap . appendChild ( list );
return wrap ;
}
async function updateEnemyStatBlock ( patch ) {
await api ( 'PUT' , `/api/enemies/ ${ state . selectedEnemyId } ` , { stat_block : patch });
await loadEnemies ();
renderSidebar ();
renderMain ();
}
async function deleteSelectedEnemy () {
if ( ! confirm ( 'Delete this enemy? This cannot be undone.' )) return ;
await api ( 'DELETE' , `/api/enemies/ ${ state . selectedEnemyId } ` );
state . selectedEnemyId = null ;
await loadEnemies ();
await loadLog ();
renderSidebar (); renderMain (); renderLog ();
}
function renderEnemiesMain () {
const wrap = el ( `<div></div>` );
const enemy = state . enemies . find (( e ) => e . id === state . selectedEnemyId );
if ( ! enemy ) {
wrap . appendChild ( el ( `<p class="empty-state">Generate or select an enemy.</p>` ));
return wrap ;
}
const card = el ( `<div class="card"></div>` );
card . appendChild ( el ( `<h2> ${ escapeHtml ( enemy . name ) } <span class="tag"> ${ enemy . category || '' } </span></h2>` ));
const delBtn = el ( `<button class="button danger">Delete</button>` );
delBtn . addEventListener ( 'click' , deleteSelectedEnemy );
card . appendChild ( delBtn );
card . appendChild ( renderStatBlockReadout ( enemy . stat_block ));
const weaponForm = el ( `<div class="card"><h3>Add Weapon</h3></div>` );
const wName = el ( `<input placeholder="Weapon name (e.g. Broadsword)">` );
const wCat = el ( `<input placeholder="Category (e.g. Sword, 1H)">` );
const wSkill = el ( `<input type="number" placeholder="Skill %" value="50">` );
const wAdd = el ( `<button class="button">Add</button>` );
const wBonusBtn = el ( `<button class="button">Check Cultural Bonus</button>` );
const wBonusResult = el ( `<span class="tag"></span>` );
wBonusBtn . addEventListener ( 'click' , async () => {
if ( ! enemy . stat_block . culture || ! wCat . value . trim ()) {
wBonusResult . textContent = 'need culture + category' ;
return ;
}
const bonus = await api ( 'GET' , `/api/rules/cultural-bonus?culture= ${ encodeURIComponent ( enemy . stat_block . culture ) } &category= ${ encodeURIComponent ( wCat . value . trim ()) } &weapon= ${ encodeURIComponent ( wName . value . trim ()) } ` );
wBonusResult . textContent = `+ ${ bonus . attack } atk / + ${ bonus . parry } parry ( ${ enemy . stat_block . culture } )` ;
});
wAdd . addEventListener ( 'click' , async () => {
if ( ! wName . value . trim ()) return ;
const weapons = enemy . stat_block . weapons . map (( w ) => ({ weapon_name : w . weapon_name , category : w . category , skill_percent : w . skill_percent , mode : w . mode }));
weapons . push ({ weapon_name : wName . value . trim (), category : wCat . value . trim () || null , skill_percent : Number ( wSkill . value ) || 0 });
await updateEnemyStatBlock ({ weapons });
});
weaponForm . append ( wName , wCat , wSkill , wAdd , wBonusBtn , wBonusResult );
wrap . append ( card , weaponForm );
const armorForm = el ( `<div class="card"><h3>Apply Armor Type</h3></div>` );
const armorSelect = el ( `<select> ${ state . armorTable . map (( a ) => `<option value=" ${ escapeHtml ( a . name ) } "> ${ escapeHtml ( a . name ) } (AP ${ a . ap } )</option>` ). join ( '' ) } </select>` );
const armorApply = el ( `<button class="button">Apply to All Locations</button>` );
armorApply . addEventListener ( 'click' , async () => {
const armor = state . armorTable . find (( a ) => a . name === armorSelect . value );
if ( ! armor ) return ;
const hit_locations = enemy . stat_block . hit_locations . map (( loc ) => ({ ... loc , armor_ap : armor . ap }));
await updateEnemyStatBlock ({ hit_locations });
});
armorForm . append ( armorSelect , armorApply );
if ( state . armorTable . length ) wrap . appendChild ( armorForm );
const spellForm = el ( `<div class="card"><h3>Add Known Spell</h3></div>` );
const spellSelect = el ( `<select></select>` );
state . spellMappings . forEach (( sm ) => spellSelect . appendChild ( el ( `<option value=" ${ sm . id } "> ${ escapeHtml ( sm . custom_name ) } ( ${ sm . mechanic_id } )</option>` )));
const spellAdd = el ( `<button class="button">Add</button>` );
spellAdd . addEventListener ( 'click' , async () => {
if ( ! state . spellMappings . length ) return ;
const spells = enemy . stat_block . spells . map (( s ) => ({ spell_mapping_id : s . id }));
spells . push ({ spell_mapping_id : Number ( spellSelect . value ) });
await updateEnemyStatBlock ({ spells });
});
spellForm . append ( spellSelect , spellAdd );
if ( state . spellMappings . length ) wrap . appendChild ( spellForm );
return wrap ;
}
// ======================================================================
// COMBAT
// ======================================================================
function combatants () { return ( state . combat && state . combat . state . combatants ) || []; }
function renderCombatSidebar () {
const wrap = el ( `<div></div>` );
const addWrap = el ( `<div class="field-row"><label>Add Combatant</label></div>` );
const select = el ( `<select></select>` );
select . appendChild ( el ( `<option value="">Choose...</option>` ));
state . npcs . filter (( n ) => n . stat_block ). forEach (( n ) => select . appendChild ( el ( `<option value="npc: ${ n . id } "> ${ escapeHtml ( ` ${ n . first_name || '' } ${ n . last_name || '' } ` . trim ()) } </option>` )));
state . enemies . forEach (( e ) => select . appendChild ( el ( `<option value="enemy: ${ e . id } "> ${ escapeHtml ( e . name ) } </option>` )));
const addBtn = el ( `<button class="button">Add</button>` );
addBtn . addEventListener ( 'click' , async () => {
if ( ! select . value ) return ;
const [ type , id ] = select . value . split ( ':' );
await api ( 'POST' , '/api/combat/add-combatant' , { type , id : Number ( id ) });
await loadCombat ();
await loadLog ();
renderSidebar (); renderMain (); renderLog ();
});
addWrap . append ( select , addBtn );
wrap . appendChild ( addWrap );
const list = el ( `<ul class="entity-list"></ul>` );
combatants (). forEach (( c ) => {
list . appendChild ( el ( `<li><span> ${ escapeHtml ( c . name ) } (SR ${ c . strikeRank } )</span><span class="tag"> ${ c . currentHp } / ${ c . maxHp } ${ c . status } </span></li>` ));
});
wrap . appendChild ( list );
if ( combatants (). length ) {
const endBtn = el ( `<button class="button danger">End Combat</button>` );
endBtn . addEventListener ( 'click' , async () => {
if ( ! confirm ( 'End combat?' )) return ;
await api ( 'DELETE' , '/api/combat' );
await loadCombat ();
await loadLog ();
renderSidebar (); renderMain (); renderLog ();
});
wrap . appendChild ( endBtn );
}
return wrap ;
}
function combatantOptions ( selectedId ) {
return combatants (). map (( c ) => `<option value=" ${ c . id } " ${ c . id === selectedId ? 'selected' : '' } > ${ escapeHtml ( c . name ) } </option>` ). join ( '' );
}
2026-07-01 08:19:01 +10:00
function renderStuckWeaponFollowUp ({ attackerCombatantId , defenderCombatantId , weaponName , kind }) {
const wrap = el ( `<div class="card" style="margin-top:0.75rem"></div>` );
wrap . appendChild ( el ( `<p><strong>Weapon stuck ( ${ kind } ).</strong> Choose removal attempt:</p>` ));
const removalResult = el ( `<div></div>` );
async function doRemoval ( removalType , extra = {}) {
try {
const res = await api ( 'POST' , '/api/combat/stuck-weapon-removal' , {
attackerCombatantId , defenderCombatantId , weaponName , kind , removalType , ... extra ,
});
state . combat = res . combatState ;
const r = res . result ;
const outcome = r . weaponBreaks ? 'Weapon breaks!' : r . success ? 'Weapon removed successfully.' : 'Weapon stays stuck.' ;
removalResult . innerHTML = `<p> ${ escapeHtml ( outcome ) } </p>` ;
await loadLog ();
renderSidebar ();
renderLog ();
} catch ( err ) {
removalResult . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
}
const attackerBtn = el ( `<button class="button">Attacker removes weapon</button>` );
attackerBtn . addEventListener ( 'click' , () => doRemoval ( 'attacker' ));
const selfBtn = el ( `<button class="button">Target removes from self</button>` );
selfBtn . addEventListener ( 'click' , () => doRemoval ( 'self' ));
const faSkill = el ( `<input type="number" placeholder="First Aid %" style="width:7rem">` );
const faBtn = el ( `<button class="button">Remove with First Aid</button>` );
faBtn . addEventListener ( 'click' , () => doRemoval ( 'first-aid' , { firstAidSkillPercent : Number ( faSkill . value ) || 0 }));
const btnRow = el ( `<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-top:0.5rem"></div>` );
btnRow . append ( attackerBtn , selfBtn , faSkill , faBtn );
wrap . appendChild ( btnRow );
wrap . appendChild ( removalResult );
return wrap ;
}
2026-06-30 09:10:05 +10:00
function renderCombatMain () {
const wrap = el ( `<div></div>` );
if ( combatants (). length < 1 ) {
wrap . appendChild ( el ( `<p class="empty-state">Add combatants from the sidebar to begin.</p>` ));
return wrap ;
}
wrap . appendChild ( el ( `<h2>Attack</h2>` ));
const form = el ( `<div class="card"></div>` );
const attackerSel = el ( `<select> ${ combatantOptions () } </select>` );
const defenderSel = el ( `<select> ${ combatantOptions () } </select>` );
const weaponSel = el ( `<select></select>` );
const modeSel = el ( `<select><option value="">(mode if dual)</option><option value="impale">impale</option><option value="slash">slash</option></select>` );
const kindSel = el ( `<select><option value="melee">melee</option><option value="missile">missile</option></select>` );
const thrownChk = el ( `<span class="checkbox-field"><input type="checkbox"> thrown</span>` );
function refreshWeapons () {
const attacker = combatants (). find (( c ) => c . id === attackerSel . value ) || combatants ()[ 0 ];
weaponSel . innerHTML = '' ;
( attacker ? attacker . weapons : []). forEach (( w ) => weaponSel . appendChild ( el ( `<option> ${ escapeHtml ( w . weapon_name ) } </option>` )));
}
attackerSel . addEventListener ( 'change' , refreshWeapons );
refreshWeapons ();
const reactionType = el ( `<select><option value="">no reaction</option><option value="parry">parry</option><option value="dodge">dodge</option></select>` );
const reactionSkill = el ( `<input type="number" placeholder="Reaction skill %">` );
const reactionWeapon = el ( `<input placeholder="Parrying weapon name (for parry)">` );
const modifiersWrap = el ( `<div class="field-row"><label>Situational Modifiers</label></div>` );
const modifierChecks = state . attackModifiers . map (( mod ) => el ( `
<div class="checkbox-field-row">
<span class="checkbox-field"><input type="checkbox" data-mod-id=" ${ mod . id } "> ${ escapeHtml ( mod . description ) } ( ${ mod . modifier > 0 ? '+' : '' }${ mod . modifier }${ mod . perSiz ? ` per ${ mod . perSiz } SIZ` : '' } )</span>
</div>
` ));
modifierChecks . forEach (( f ) => modifiersWrap . appendChild ( f ));
const resolveBtn = el ( `<button class="button primary">Resolve Attack</button>` );
const resultBox = el ( `<div></div>` );
resolveBtn . addEventListener ( 'click' , async () => {
const modifierIds = modifierChecks
. filter (( f ) => f . querySelector ( 'input' ). checked )
. map (( f ) => f . querySelector ( 'input' ). dataset . modId );
const body = {
attackerCombatantId : attackerSel . value ,
defenderCombatantId : defenderSel . value ,
weaponName : weaponSel . value ,
declaredMode : modeSel . value || undefined ,
attackKind : kindSel . value ,
thrown : thrownChk . querySelector ( 'input' ). checked ,
modifierIds ,
};
if ( reactionType . value ) {
body . reaction = { type : reactionType . value , skillPercent : Number ( reactionSkill . value ) || 0 , weaponName : reactionWeapon . value };
}
try {
const res = await api ( 'POST' , '/api/combat/attack' , body );
state . combat = res . combatState ;
2026-07-01 08:19:01 +10:00
resultBox . innerHTML = '' ;
const summary = el ( `<div>
2026-06-30 09:10:05 +10:00
<p> ${ tierTag ( res . result . attackCheck . tier ) } roll ${ res . result . attackCheck . roll } vs ${ res . result . effectiveSkillPercent } % (base ${ res . result . effectiveSkillPercent - res . result . modifierTotal }${ res . result . modifierTotal ? `, modifiers ${ res . result . modifierTotal > 0 ? '+' : '' }${ res . result . modifierTotal } ` : '' } )</p>
${ res . result . hitLocationRoll ? `<p>Hit location: ${ res . result . hitLocationRoll . location } </p>` : '' }
${ res . result . damageThrough != null ? `<p>Damage through: ${ res . result . damageThrough } </p>` : '' }
${ res . result . fumble ? `<p>Fumble: ${ res . result . fumble . results . map (( r ) => r . effect ). join ( '; ' ) } </p>` : '' }
2026-07-01 08:19:01 +10:00
</div>` );
resultBox . appendChild ( summary );
const special = res . result . damageResult ? . special ;
const weaponStuck = ( special === 'impale' || special === 'slash' ) && res . result . damageThrough > 0 ;
if ( weaponStuck ) {
const stuckBox = renderStuckWeaponFollowUp ({
attackerCombatantId : body . attackerCombatantId ,
defenderCombatantId : body . defenderCombatantId ,
weaponName : body . weaponName ,
kind : special ,
});
resultBox . appendChild ( stuckBox );
}
2026-06-30 09:10:05 +10:00
await loadLog ();
renderSidebar ();
renderLog ();
} catch ( err ) {
resultBox . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
[
[ 'Attacker' , attackerSel ], [ 'Defender' , defenderSel ], [ 'Weapon' , weaponSel ], [ 'Declared Mode' , modeSel ],
[ 'Attack Kind' , kindSel ], [ null , thrownChk ], [ 'Defender Reaction' , reactionType ], [ 'Reaction Skill %' , reactionSkill ], [ null , reactionWeapon ],
]. forEach (([ label , input ]) => {
const row = el ( `<div class="field-row"></div>` );
if ( label ) row . appendChild ( el ( `<label> ${ label } </label>` ));
row . appendChild ( input );
form . appendChild ( row );
});
form . appendChild ( modifiersWrap );
form . appendChild ( resolveBtn );
form . appendChild ( resultBox );
wrap . appendChild ( form );
wrap . appendChild ( el ( `<h2>Cast Spell</h2>` ));
const spellForm = el ( `<div class="card"></div>` );
const casterSel = el ( `<select> ${ combatantOptions () } </select>` );
const targetSel = el ( `<select><option value="">(none)</option> ${ combatantOptions () } </select>` );
const mechanicSel = el ( `<select> ${ SPELL_MECHANIC_IDS . map (( m ) => `<option value=" ${ m } "> ${ m } </option>` ). join ( '' ) } </select>` );
const mpInput = el ( `<input type="number" value="1" min="1">` );
const castBtn = el ( `<button class="button primary">Cast</button>` );
const castResult = el ( `<div></div>` );
castBtn . addEventListener ( 'click' , async () => {
try {
const res = await api ( 'POST' , '/api/combat/cast-spell' , {
casterCombatantId : casterSel . value ,
targetCombatantId : targetSel . value || undefined ,
mechanicId : mechanicSel . value ,
mpSpent : Number ( mpInput . value ) || 1 ,
});
state . combat = res . combatState ;
castResult . innerHTML = `<p> ${ escapeHtml ( JSON . stringify ( res . result )) } </p>` ;
await loadLog ();
renderSidebar ();
renderLog ();
} catch ( err ) {
castResult . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
[[ 'Caster' , casterSel ], [ 'Target' , targetSel ], [ 'Spell Mechanic' , mechanicSel ], [ 'MP Spent' , mpInput ]]. forEach (([ label , input ]) => {
const row = el ( `<div class="field-row"><label> ${ label } </label></div>` );
row . appendChild ( input );
spellForm . appendChild ( row );
});
spellForm . append ( castBtn , castResult );
wrap . appendChild ( spellForm );
return wrap ;
}
// ======================================================================
// LOG
// ======================================================================
function renderLog () {
const root = qs ( '#log-entries' );
root . innerHTML = '' ;
[... state . logEntries ]. forEach (( entry ) => {
root . appendChild ( el ( `
<div class="log-entry">
<div class="log-meta"><span class="log-type"> ${ entry . type } </span> · ${ entry . created_at } </div>
<div> ${ escapeHtml ( entry . summary ) } </div>
</div>
` ));
});
}
async function searchLogAndRender ( q ) {
await loadLog ( q );
renderLog ();
}
async function addNote () {
const input = qs ( '#note-input' );
const text = input . value . trim ();
if ( ! text ) return ;
await api ( 'POST' , '/api/log' , { type : 'note' , summary : text });
input . value = '' ;
await loadLog ();
renderLog ();
}
// ======================================================================
// IMPORT / EXPORT
// ======================================================================
async function exportAllData () {
const res = await fetch ( '/api/export' );
const blob = await res . blob ();
downloadBlob ( blob , `story-tool-export- ${ Date . now () } .json` );
state . dirty = false ;
}
async function exportLogData () {
const res = await fetch ( '/api/export/log' );
const blob = await res . blob ();
downloadBlob ( blob , `session-log- ${ Date . now () } .md` );
}
function downloadBlob ( blob , filename ) {
const url = URL . createObjectURL ( blob );
const a = document . createElement ( 'a' );
a . href = url ;
a . download = filename ;
document . body . appendChild ( a );
a . click ();
a . remove ();
URL . revokeObjectURL ( url );
}
async function importFile ( file ) {
const text = await file . text ();
const dump = JSON . parse ( text );
await api ( 'POST' , '/api/import' , dump );
await Promise . all ([ loadNpcs (), loadEnemies (), loadCombat (), loadLog (), loadSpellMappings ()]);
renderSidebar ();
renderMain ();
renderLog ();
state . dirty = false ;
}
2026-07-03 16:00:54 +10:00
// ---------- Player Characters ----------
const CHAR_KEYS = [ 'str' , 'con' , 'siz' , 'int' , 'pow' , 'dex' , 'app' ];
const CHAR_LABELS = { str : 'STR' , con : 'CON' , siz : 'SIZ' , int : 'INT' , pow : 'POW' , dex : 'DEX' , app : 'APP' };
function renderCharactersSidebar () {
const wrap = el ( `<div></div>` );
wrap . appendChild ( el ( `<h3>Saved Characters</h3>` ));
if ( ! state . playerCharacters . length ) {
wrap . appendChild ( el ( `<p class="empty-state">No characters yet.</p>` ));
return wrap ;
}
state . playerCharacters . forEach (( pc ) => {
const btn = el ( `<button class="sidebar-item ${ state . selectedCharacterId === pc . id ? ' active' : '' } "> ${ escapeHtml ( pc . name ) } </button>` );
btn . addEventListener ( 'click' , () => {
state . selectedCharacterId = pc . id ;
renderSidebar ();
renderMain ();
});
wrap . appendChild ( btn );
});
return wrap ;
}
function renderDerivedStats ( derived ) {
const locs = derived . hitLocations . map (( l ) =>
`<tr><td> ${ escapeHtml ( l . location_name ) } </td><td> ${ l . max_hp } </td></tr>`
). join ( '' );
const sm = derived . skillModifiers || {};
function sign ( n ) { return n > 0 ? `+ ${ n } ` : ` ${ n } ` ; }
const modRows = [
[ 'Agility' , sm . agility ], [ 'Communication' , sm . communication ], [ 'Knowledge' , sm . knowledge ],
[ 'Magic' , sm . magic ], [ 'Manipulation' , sm . manipulation ], [ 'Perception' , sm . perception ],
[ 'Stealth' , sm . stealth ],
]. map (([ name , v ]) => `<tr><td style="padding-right:1rem"> ${ name } </td><td> ${ v != null ? sign ( v ) : '—' } %</td></tr>` ). join ( '' );
return el ( `<div class="card" style="margin-top:0.75rem">
<p>
<strong>HP:</strong> ${ derived . totalHp }
<strong>FP:</strong> ${ derived . fatigue ?? '—' }
<strong>MP:</strong> ${ derived . magicPoints }
<strong>DB:</strong> ${ escapeHtml ( derived . damageBonus ) }
<strong>SR:</strong> ${ derived . strikeRank }
</p>
${ sm . attack != null ? `<p><strong>Attack bonus:</strong> ${ sign ( sm . attack ) } % <strong>Parry bonus:</strong> ${ sign ( sm . parry ) } %</p>` : '' }
<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-top:0.5rem">
<div>
<strong>Hit Locations</strong>
<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.25rem">
<thead><tr><th style="text-align:left;padding-right:0.75rem">Location</th><th style="text-align:left">HP</th></tr></thead>
<tbody> ${ locs } </tbody>
</table>
</div>
${ modRows ? `<div>
<strong>Skill Modifiers</strong>
<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.25rem">
<tbody> ${ modRows } </tbody>
</table>
</div>` : '' }
</div>
</div>` );
}
function renderCharactersMain () {
const wrap = el ( `<div></div>` );
// --- Generator section ---
wrap . appendChild ( el ( `<h2>Character Generator</h2>` ));
const genCard = el ( `<div class="card"></div>` );
// Method picker
const methodRow = el ( `<div class="field-row"><label>Method</label></div>` );
const methodSel = el ( `<select>
<option value="random">Random (3D6 / 2D6+6)</option>
<option value="deliberate">Deliberate (80 points)</option>
<option value="combined">Combined (roll + 6 bonus points)</option>
</select>` );
methodRow . appendChild ( methodSel );
genCard . appendChild ( methodRow );
const statInputsWrap = el ( `<div></div>` );
const derivedWrap = el ( `<div></div>` );
const saveWrap = el ( `<div style="display:none"></div>` );
const genMsg = el ( `<div></div>` );
// Budget tracker for deliberate
const budgetDisplay = el ( `<p style="display:none"></p>` );
let currentChars = null ;
function buildStatInputs ( readOnly , chars ) {
statInputsWrap . innerHTML = '' ;
budgetDisplay . style . display = 'none' ;
if ( readOnly ) {
const row = el ( `<div class="field-row" style="flex-wrap:wrap;gap:0.75rem"></div>` );
CHAR_KEYS . forEach (( k ) => {
row . appendChild ( el ( `<span><strong> ${ CHAR_LABELS [ k ] } :</strong> ${ chars [ k ] } </span>` ));
});
statInputsWrap . appendChild ( row );
return null ;
}
// Editable inputs
const inputs = {};
const grid = el ( `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(8rem,1fr));gap:0.5rem;margin:0.5rem 0"></div>` );
CHAR_KEYS . forEach (( k ) => {
const cell = el ( `<div><label style="font-size:0.8em;display:block"> ${ CHAR_LABELS [ k ] } </label></div>` );
const inp = el ( `<input type="number" min="1" max="18" value=" ${ chars ? chars [ k ] : ( k === 'siz' || k === 'int' ? 8 : 6 ) } " style="width:100%">` );
inputs [ k ] = inp ;
cell . appendChild ( inp );
grid . appendChild ( cell );
});
statInputsWrap . appendChild ( grid );
if ( methodSel . value === 'deliberate' ) {
budgetDisplay . style . display = '' ;
function updateBudget () {
const total = CHAR_KEYS . reduce (( s , k ) => s + ( Number ( inputs [ k ]. value ) || 0 ), 0 );
const rem = 80 - total ;
budgetDisplay . textContent = `Points used: ${ total } /80 ( ${ rem >= 0 ? rem + ' remaining' : Math . abs ( rem ) + ' over' } )` ;
budgetDisplay . style . color = rem === 0 ? 'var(--success,green)' : rem < 0 ? 'red' : 'inherit' ;
}
CHAR_KEYS . forEach (( k ) => inputs [ k ]. addEventListener ( 'input' , updateBudget ));
updateBudget ();
}
if ( methodSel . value === 'combined' && chars ) {
// Show +/- buttons with bonus point tracking
let bonusLeft = 6 ;
const bonusLabel = el ( `<p>Bonus points remaining: <strong id="bonus-left">6</strong></p>` );
statInputsWrap . appendChild ( bonusLabel );
CHAR_KEYS . forEach (( k ) => {
const base = chars [ k ];
inputs [ k ]. readOnly = true ;
inputs [ k ]. style . background = 'var(--input-disabled-bg, #f0f0f0)' ;
const plusBtn = el ( `<button class="button" style="padding:0 0.4rem">+</button>` );
const minusBtn = el ( `<button class="button" style="padding:0 0.4rem">– </button>` );
plusBtn . addEventListener ( 'click' , () => {
if ( bonusLeft <= 0 || Number ( inputs [ k ]. value ) >= 18 ) return ;
inputs [ k ]. value = Number ( inputs [ k ]. value ) + 1 ;
bonusLeft -- ;
bonusLabel . querySelector ( '#bonus-left' ). textContent = bonusLeft ;
});
minusBtn . addEventListener ( 'click' , () => {
if ( Number ( inputs [ k ]. value ) <= base ) return ;
inputs [ k ]. value = Number ( inputs [ k ]. value ) - 1 ;
bonusLeft ++ ;
bonusLabel . querySelector ( '#bonus-left' ). textContent = bonusLeft ;
});
// attach buttons next to each input
const cell = inputs [ k ]. parentElement ;
const btnRow = el ( `<div style="display:flex;gap:2px;margin-top:2px"></div>` );
btnRow . append ( minusBtn , plusBtn );
cell . appendChild ( btnRow );
});
}
return inputs ;
}
let activeInputs = null ;
function showRolledResult ( chars , derived , bonusPoints ) {
currentChars = chars ;
if ( bonusPoints > 0 ) {
activeInputs = buildStatInputs ( false , chars );
} else {
buildStatInputs ( true , chars );
activeInputs = null ;
}
derivedWrap . innerHTML = '' ;
derivedWrap . appendChild ( renderDerivedStats ( derived ));
saveWrap . style . display = '' ;
genMsg . innerHTML = '' ;
}
methodSel . addEventListener ( 'change' , () => {
statInputsWrap . innerHTML = '' ;
derivedWrap . innerHTML = '' ;
saveWrap . style . display = 'none' ;
genMsg . innerHTML = '' ;
budgetDisplay . style . display = 'none' ;
currentChars = null ;
activeInputs = null ;
if ( methodSel . value === 'deliberate' ) {
activeInputs = buildStatInputs ( false , null );
}
});
const rollBtn = el ( `<button class="button primary" id="gen-roll-btn">Roll</button>` );
const calcBtn = el ( `<button class="button primary" id="gen-calc-btn" style="display:none">Calculate</button>` );
methodSel . addEventListener ( 'change' , () => {
rollBtn . style . display = methodSel . value === 'deliberate' ? 'none' : '' ;
calcBtn . style . display = methodSel . value === 'deliberate' ? '' : 'none' ;
});
rollBtn . addEventListener ( 'click' , async () => {
try {
const res = await api ( 'POST' , '/api/characters/roll' , { method : methodSel . value });
showRolledResult ( res . chars , res . derived , res . bonusPoints );
} catch ( err ) {
genMsg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
calcBtn . addEventListener ( 'click' , async () => {
const chars = {};
CHAR_KEYS . forEach (( k ) => { chars [ k ] = Number ( activeInputs [ k ]. value ) || 0 ; });
try {
const val = await api ( 'POST' , '/api/characters/validate' , { method : 'deliberate' , chars });
if ( ! val . valid ) {
genMsg . innerHTML = `<p class="error-message"> ${ val . errors . map ( escapeHtml ). join ( '<br>' ) } </p>` ;
return ;
}
const res = await api ( 'POST' , '/api/characters/derive' , chars );
currentChars = chars ;
derivedWrap . innerHTML = '' ;
derivedWrap . appendChild ( renderDerivedStats ( res . derived ));
saveWrap . style . display = '' ;
genMsg . innerHTML = '' ;
} catch ( err ) {
genMsg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
// Apply combined bonus button
const applyBonusBtn = el ( `<button class="button" id="gen-apply-btn" style="display:none">Apply Bonus Points</button>` );
methodSel . addEventListener ( 'change' , () => {
applyBonusBtn . style . display = methodSel . value === 'combined' ? '' : 'none' ;
});
applyBonusBtn . addEventListener ( 'click' , async () => {
if ( ! activeInputs ) return ;
const chars = {};
CHAR_KEYS . forEach (( k ) => { chars [ k ] = Number ( activeInputs [ k ]. value ) || 0 ; });
try {
const val = await api ( 'POST' , '/api/characters/validate' , { method : 'combined' , chars });
if ( ! val . valid ) {
genMsg . innerHTML = `<p class="error-message"> ${ val . errors . map ( escapeHtml ). join ( '<br>' ) } </p>` ;
return ;
}
const res = await api ( 'POST' , '/api/characters/derive' , chars );
currentChars = chars ;
derivedWrap . innerHTML = '' ;
derivedWrap . appendChild ( renderDerivedStats ( res . derived ));
saveWrap . style . display = '' ;
genMsg . innerHTML = '' ;
} catch ( err ) {
genMsg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
// Save section
const nameInput = el ( `<input placeholder="Character name" style="margin-right:0.5rem">` );
const saveBtn = el ( `<button class="button primary">Save Character</button>` );
saveWrap . append ( nameInput , saveBtn );
saveBtn . addEventListener ( 'click' , async () => {
const name = nameInput . value . trim ();
if ( ! name ) { genMsg . innerHTML = `<p class="error-message">Name is required.</p>` ; return ; }
if ( ! currentChars ) return ;
const chars = activeInputs
? Object . fromEntries ( CHAR_KEYS . map (( k ) => [ k , Number ( activeInputs [ k ]. value ) || 0 ]))
: currentChars ;
try {
await api ( 'POST' , '/api/characters' , { name , generation_method : methodSel . value , chars });
await loadPlayerCharacters ();
genMsg . innerHTML = `<p style="color:var(--success,green)">Saved " ${ escapeHtml ( name ) } ".</p>` ;
nameInput . value = '' ;
renderSidebar ();
} catch ( err ) {
genMsg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
const btnRow = el ( `<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap"></div>` );
btnRow . append ( rollBtn , calcBtn , applyBonusBtn );
genCard . append ( statInputsWrap , budgetDisplay , btnRow , derivedWrap , saveWrap , genMsg );
wrap . appendChild ( genCard );
// --- Selected PC view ---
const pc = state . playerCharacters . find (( c ) => c . id === state . selectedCharacterId );
if ( pc ) {
wrap . appendChild ( el ( `<h2> ${ escapeHtml ( pc . name ) } </h2>` ));
const pcCard = el ( `<div class="card"></div>` );
const sb = pc . stat_block ;
const statsRow = el ( `<div class="field-row" style="flex-wrap:wrap;gap:0.75rem"></div>` );
[ 'str' , 'con' , 'siz' , 'int' , 'pow' , 'dex' , 'app' ]. forEach (( k ) => {
statsRow . appendChild ( el ( `<span><strong> ${ CHAR_LABELS [ k ] } :</strong> ${ sb [ k ] } </span>` ));
});
pcCard . appendChild ( statsRow );
pcCard . appendChild ( el ( `<p style="margin-top:0.5rem"><strong>HP:</strong> ${ sb . current_hp } / ${ sb . max_hp } <strong>MP:</strong> ${ sb . magic_points_current } / ${ sb . magic_points_max } </p>` ));
if ( sb . hit_locations && sb . hit_locations . length ) {
const locs = sb . hit_locations . map (( l ) => `<tr><td> ${ escapeHtml ( l . location_name ) } </td><td> ${ l . current_hp } / ${ l . max_hp } </td></tr>` ). join ( '' );
pcCard . appendChild ( el ( `<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.5rem">
<thead><tr><th style="text-align:left;padding-right:1rem">Location</th><th style="text-align:left">HP</th></tr></thead>
<tbody> ${ locs } </tbody>
</table>` ));
}
const delBtn = el ( `<button class="button" style="margin-top:0.75rem;color:red">Delete Character</button>` );
delBtn . addEventListener ( 'click' , async () => {
if ( ! confirm ( `Delete " ${ pc . name } "?` )) return ;
await api ( 'DELETE' , `/api/characters/ ${ pc . id } ` );
state . selectedCharacterId = null ;
await loadPlayerCharacters ();
renderSidebar ();
renderMain ();
});
pcCard . appendChild ( delBtn );
wrap . appendChild ( pcCard );
// --- Inventory ---
wrap . appendChild ( el ( `<h2>Inventory</h2>` ));
wrap . appendChild ( renderInventory ( pc ));
}
return wrap ;
}
function renderInventory ( pc ) {
const wrap = el ( `<div class="card"></div>` );
const listWrap = el ( `<div></div>` );
const encDisplay = el ( `<p></p>` );
const errMsg = el ( `<div></div>` );
async function refresh () {
const res = await loadInventory ( pc . id );
listWrap . innerHTML = '' ;
if ( ! state . characterInventory . length ) {
listWrap . appendChild ( el ( `<p class="empty-state">No items.</p>` ));
} else {
const tbl = el ( `<table style="border-collapse:collapse;width:100%;font-size:0.9em"></table>` );
tbl . innerHTML = `<thead><tr>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Item</th>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Category</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Qty</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">ENC</th>
<th></th>
</tr></thead>` ;
const tbody = el ( `<tbody></tbody>` );
state . characterInventory . forEach (( item ) => {
const row = el ( `<tr></tr>` );
const notesTip = item . notes ? ` — ${ escapeHtml ( item . notes ) } ` : '' ;
row . innerHTML = `
<td style="padding:0.15rem 0.5rem 0.15rem 0"> ${ escapeHtml ( item . name ) }${ notesTip ? `<span style="color:var(--muted,#888);font-size:0.8em"> ${ notesTip } </span>` : '' } </td>
<td style="padding:0.15rem 0.5rem 0.15rem 0"> ${ escapeHtml ( item . category ) } </td>
<td style="text-align:right;padding:0.15rem 0.5rem 0.15rem 0"> ${ item . quantity } </td>
<td style="text-align:right;padding:0.15rem 0.5rem 0.15rem 0"> ${ ( item . enc * item . quantity ). toFixed ( 1 ) } </td>
` ;
const removeBtn = el ( `<button class="button" style="padding:0.1rem 0.4rem;font-size:0.8em">✕</button>` );
removeBtn . addEventListener ( 'click' , async () => {
await api ( 'DELETE' , `/api/characters/ ${ pc . id } /inventory/ ${ item . id } ` );
await refresh ();
});
const td = el ( `<td style="padding:0.15rem 0"></td>` );
td . appendChild ( removeBtn );
row . appendChild ( td );
tbody . appendChild ( row );
});
tbl . appendChild ( tbody );
listWrap . appendChild ( tbl );
}
const totalEnc = res ? res . totalEnc : 0 ;
const fp = pc . stat_block . str + pc . stat_block . con ;
const effectiveFp = fp - totalEnc ;
encDisplay . innerHTML = `<strong>Total ENC carried:</strong> ${ totalEnc . toFixed ( 1 ) } <strong>Effective FP:</strong> ${ effectiveFp . toFixed ( 1 ) } / ${ fp } ` ;
}
// Add item form
const nameIn = el ( `<input placeholder="Item name" style="flex:1;min-width:8rem">` );
const qtyIn = el ( `<input type="number" value="1" min="1" style="width:4rem">` );
const encIn = el ( `<input type="number" value="0" min="0" step="0.1" style="width:4.5rem" placeholder="ENC">` );
const catSel = el ( `<select>
<option value="equipment">equipment</option>
<option value="weapon">weapon</option>
<option value="armor">armor</option>
<option value="money">money</option>
<option value="provisions">provisions</option>
</select>` );
const notesIn = el ( `<input placeholder="Notes (optional)" style="flex:1;min-width:8rem">` );
const addBtn = el ( `<button class="button primary">Add</button>` );
addBtn . addEventListener ( 'click' , async () => {
const name = nameIn . value . trim ();
if ( ! name ) return ;
try {
await api ( 'POST' , `/api/characters/ ${ pc . id } /inventory` , {
name ,
quantity : Number ( qtyIn . value ) || 1 ,
enc : Number ( encIn . value ) || 0 ,
category : catSel . value ,
notes : notesIn . value . trim () || null ,
});
nameIn . value = '' ; notesIn . value = '' ; qtyIn . value = '1' ; encIn . value = '0' ;
await refresh ();
} catch ( err ) {
errMsg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
const formRow = el ( `<div style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;margin-top:0.5rem"></div>` );
formRow . append ( nameIn , qtyIn , encIn , catSel , notesIn , addBtn );
wrap . append ( encDisplay , listWrap , formRow , errMsg );
refresh ();
return wrap ;
}
// ---------- Adventure ----------
function renderAdventureSidebar () {
const wrap = el ( `<div></div>` );
wrap . appendChild ( el ( `<h3>Characters</h3>` ));
if ( ! state . playerCharacters . length ) {
wrap . appendChild ( el ( `<p class="empty-state">Create a character first.</p>` ));
return wrap ;
}
state . playerCharacters . forEach (( pc ) => {
const active = state . adventure && state . adventure . characterId === pc . id ;
const btn = el ( `<button class="sidebar-item ${ active ? ' active' : '' } "> ${ escapeHtml ( pc . name ) } </button>` );
btn . addEventListener ( 'click' , () => {
state . selectedCharacterId = pc . id ;
renderSidebar ();
renderMain ();
});
wrap . appendChild ( btn );
});
return wrap ;
}
function renderAdventureMain () {
const wrap = el ( `<div></div>` );
wrap . appendChild ( el ( `<h2>Adventure</h2>` ));
const pc = state . playerCharacters . find (( c ) => c . id === state . selectedCharacterId )
|| state . playerCharacters [ 0 ];
if ( ! pc ) {
wrap . appendChild ( el ( `<p class="empty-state">Create and select a character to begin adventuring.</p>` ));
return wrap ;
}
const sceneCard = el ( `<div class="card"></div>` );
const sceneBox = el ( `<div></div>` );
const choiceBox = el ( `<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem"></div>` );
const outcomeBox = el ( `<div style="margin-top:0.75rem"></div>` );
const errBox = el ( `<div></div>` );
// Table selector for scene generation
const tableOptions = state . tablesTree . flatMap ( function collect ( node ) {
return node . table ? [{ id : node . table . id , name : node . heading_text }] : ( node . children || []). flatMap ( collect );
});
const tableSel = el ( `<select style="flex:1"><option value="">— no table (narrative only) —</option> ${
tableOptions . map (( t ) => `<option value=" ${ t . id } "> ${ escapeHtml ( t . name ) } </option>` ). join ( '' )
} </select>` );
const startBtn = el ( `<button class="button primary">New Scene</button>` );
const clearBtn = el ( `<button class="button">Clear</button>` );
function renderScene ( adventureState ) {
sceneBox . innerHTML = '' ;
choiceBox . innerHTML = '' ;
outcomeBox . innerHTML = '' ;
if ( ! adventureState ) return ;
const scene = adventureState . scene ;
sceneBox . appendChild ( el ( `<p style="font-style:italic;font-size:1.05em"> ${ escapeHtml ( scene . description ) } </p>` ));
if ( scene . effectiveFp != null ) {
sceneBox . appendChild ( el ( `<p style="font-size:0.85em;color:var(--muted,#888)">Effective FP: ${ scene . effectiveFp } </p>` ));
}
if ( scene . status === 'active' ) {
scene . choices . forEach (( ch ) => {
const label = ch . skillPercent != null
? ` ${ ch . label } ( ${ ch . skill } ${ ch . skillPercent } %)`
: ch . label ;
const btn = el ( `<button class="button"> ${ escapeHtml ( label ) } </button>` );
btn . addEventListener ( 'click' , async () => {
try {
const res = await api ( 'POST' , '/api/adventure/choose' , { choiceId : ch . id });
state . adventure = res ;
renderScene ( res );
await loadLog (); renderLog ();
} catch ( err ) {
errBox . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
choiceBox . appendChild ( btn );
});
}
if ( scene . resolution ) {
const r = scene . resolution ;
let html = `<p><strong> ${ escapeHtml ( scene . choices . find ( c => c . id === scene . selectedChoice ) ? . label || scene . selectedChoice ) } </strong>` ;
if ( r . roll != null ) html += ` — rolled ${ r . roll } vs ${ r . skillPercent } % ( ${ r . tier } )` ;
html += `</p><p> ${ escapeHtml ( r . outcome ) } </p>` ;
if ( scene . status === 'combat' ) {
html += `<p><strong>Set up the combat encounter in the Combat tab.</strong></p>` ;
}
if ( scene . status === 'escalated' ) {
html += `<p>The situation is now hostile — fight or flee!</p>` ;
// Offer fight button
const fightBtn = el ( `<button class="button primary">Engage in Combat</button>` );
fightBtn . addEventListener ( 'click' , async () => {
await api ( 'POST' , '/api/adventure/choose' , { choiceId : 'fight' });
setView ( 'combat' );
});
outcomeBox . appendChild ( el ( `<div> ${ html } </div>` ));
outcomeBox . appendChild ( fightBtn );
return ;
}
outcomeBox . innerHTML = html ;
if ( scene . status === 'resolved' || scene . status === 'combat' ) {
const nextBtn = el ( `<button class="button" style="margin-top:0.5rem">Next Scene</button>` );
nextBtn . addEventListener ( 'click' , () => startBtn . click ());
outcomeBox . appendChild ( nextBtn );
}
}
}
startBtn . addEventListener ( 'click' , async () => {
try {
const res = await api ( 'POST' , '/api/adventure/start' , {
characterId : pc . id ,
tableId : tableSel . value ? Number ( tableSel . value ) : undefined ,
});
state . adventure = res ;
renderScene ( res );
await loadLog (); renderLog ();
} catch ( err ) {
errBox . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
clearBtn . addEventListener ( 'click' , async () => {
await api ( 'DELETE' , '/api/adventure' );
state . adventure = null ;
renderScene ( null );
sceneBox . innerHTML = '' ;
});
const ctrlRow = el ( `<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem"></div>` );
ctrlRow . append ( tableSel , startBtn , clearBtn );
sceneCard . append ( ctrlRow , sceneBox , choiceBox , outcomeBox , errBox );
wrap . appendChild ( sceneCard );
// Render active scene on load
if ( state . adventure && state . adventure . characterId === pc . id ) {
renderScene ( state . adventure );
}
return wrap ;
}
2026-06-30 09:10:05 +10:00
// ---------- export reminder modal ----------
let pendingLeaveConfirmed = false ;
window . addEventListener ( 'beforeunload' , ( e ) => {
if ( state . dirty && ! pendingLeaveConfirmed ) {
e . preventDefault ();
e . returnValue = '' ;
}
});
// ---------- init ----------
async function init () {
document . querySelectorAll ( '.tab-btn' ). forEach (( btn ) => btn . addEventListener ( 'click' , () => setView ( btn . dataset . view )));
qs ( '#export-all-btn' ). addEventListener ( 'click' , exportAllData );
qs ( '#export-log-btn' ). addEventListener ( 'click' , exportLogData );
qs ( '#import-file' ). addEventListener ( 'change' , ( e ) => {
if ( e . target . files [ 0 ]) importFile ( e . target . files [ 0 ]);
e . target . value = '' ;
});
qs ( '#log-search' ). addEventListener ( 'input' , ( e ) => searchLogAndRender ( e . target . value ));
qs ( '#note-submit' ). addEventListener ( 'click' , addNote );
await Promise . all ([
loadTablesTree (), loadNpcs (), loadEnemies (), loadCombat (), loadLog (), loadSpellMappings (),
2026-07-03 16:00:54 +10:00
loadAttackModifiers (), loadArmorTable (), loadPlayerCharacters (), loadAdventure (),
2026-06-30 09:10:05 +10:00
]);
setView ( 'tables' );
renderLog ();
}
init ();