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 : [],
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' ); }
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 ());
}
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 ());
}
// ======================================================================
// 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 ;
}
// ---------- 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 (),
loadAttackModifiers (), loadArmorTable (),
]);
setView ( 'tables' );
renderLog ();
}
init ();