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 )));
}
2026-07-03 21:44:11 +10:00
const traitsRow = el ( `<div class="field-row"><label>Personality Traits</label></div>` );
traitsRow . appendChild ( renderTraitsEditor ( npc . traits || [], async ( traits ) => {
await api ( 'PATCH' , `/api/npcs/ ${ npc . id } /traits` , { traits });
npc . traits = traits ;
}));
card . appendChild ( traitsRow );
2026-06-30 09:10:05 +10:00
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>` );
}
2026-07-03 21:44:11 +10:00
// ======================================================================
// CHARACTER CREATION WIZARD DATA
// ======================================================================
const CULTURES = [ 'Primitive' , 'Nomad' , 'Barbarian' , 'Civilized' ];
const OCCUPATION_LABELS = {
Primitive : { fisher : 'Fisher' , hunter : 'Hunter' , shaman : 'Assistant Shaman' },
Nomad : { crafter : 'Crafter' , herder : 'Herder' , hunter : 'Hunter' , noble : 'Noble' , shaman : 'Assistant Shaman' , warrior : 'Warrior' },
Barbarian : { crafter : 'Crafter' , entertainer : 'Entertainer' , farmer : 'Farmer' , fisher : 'Fisher' , herder : 'Herder' , hunter : 'Hunter' , noble : 'Noble' , warrior : 'Warrior' , shaman : 'Assistant Shaman' },
Civilized : { crafter : 'Crafter' , entertainer : 'Entertainer' , farmer : 'Farmer' , healer : 'Healer' , herder : 'Herder' , merchant : 'Merchant' , noble : 'Noble' , sailor : 'Sailor' , scribe : 'Scribe' , soldier : 'Soldier' , thief : 'Thief' },
};
const SKILL_DISPLAY_NAMES = {
boat : 'Boat' , climb : 'Climb' , dodge : 'Dodge' , jump : 'Jump' , ride : 'Ride' , swim : 'Swim' , throw : 'Throw' ,
fastTalk : 'Fast Talk' , orate : 'Orate' , sing : 'Sing' ,
firstAid : 'First Aid' , animalLore : 'Animal Lore' , humanLore : 'Human Lore' , mineralLore : 'Mineral Lore' ,
plantLore : 'Plant Lore' , worldLore : 'World Lore' ,
conceal : 'Conceal' , sleight : 'Sleight' , devise : 'Devise' ,
listen : 'Listen' , scan : 'Scan' , search : 'Search' , track : 'Track' ,
hide : 'Hide' , sneak : 'Sneak' ,
evaluate : 'Evaluate' , speakOwnLanguage : 'Speak Own Language' , speakOtherLanguage : 'Speak Other Language' ,
};
const SKILL_CATEGORIES = {
boat : 'Agility' , climb : 'Agility' , dodge : 'Agility' , jump : 'Agility' , ride : 'Agility' , swim : 'Agility' , throw : 'Agility' ,
fastTalk : 'Communication' , orate : 'Communication' , sing : 'Communication' ,
firstAid : 'Knowledge' , animalLore : 'Knowledge' , humanLore : 'Knowledge' , mineralLore : 'Knowledge' ,
plantLore : 'Knowledge' , worldLore : 'Knowledge' , evaluate : 'Knowledge' ,
conceal : 'Manipulation' , sleight : 'Manipulation' , devise : 'Manipulation' ,
listen : 'Perception' , scan : 'Perception' , search : 'Perception' , track : 'Perception' ,
hide : 'Stealth' , sneak : 'Stealth' ,
speakOwnLanguage : 'Communication' , speakOtherLanguage : 'Communication' ,
};
// Cultural weapon lists for dropdowns (simplified from CULTURAL_WEAPON_BONUSES)
const CULTURAL_WEAPONS = {
Primitive : {
attackParry : [ 'Short Spear (1H)' , 'Long Spear (2H)' , 'Battleaxe (1H)' , 'Light Mace (1H)' , 'Wooden Club (1H)' ],
attackOnly : [ 'Javelin (thrown)' , 'Boomerang (War)' , 'Sling' , 'Self Bow' , 'Short Spear (thrown)' ],
parryOnly : [ 'Buckler' , 'Heater/Target Shield' ],
},
Nomad : {
attackParry : [ 'Battleaxe (1H)' , 'Light Mace (1H)' , 'Short Spear (1H)' , 'Broadsword' , 'Scimitar' ],
attackOnly : [ 'Lance (mounted)' , 'Self Bow' , 'Long Bow' , 'Composite Bow' , 'Javelin (thrown)' ],
parryOnly : [ 'Buckler' , 'Heater/Target Shield' ],
},
Barbarian : {
attackParry : [ 'Short Spear (1H)' , 'Long Spear (2H)' , 'Battleaxe (1H)' , 'Light Mace (1H)' , 'Broadsword' , 'Bastard Sword (2H)' , 'Greatsword' ],
attackOnly : [ 'Self Bow' , 'Long Bow' , 'Composite Bow' , 'Javelin (thrown)' ],
parryOnly : [ 'Buckler' , 'Kite Shield' , 'Viking Round Shield' ],
},
Civilized : {
attackParry : [ 'Broadsword' , 'Rapier' , 'Scimitar' , 'Gladius' , 'Short Spear (1H)' , 'Long Spear (2H)' , 'Bastard Sword (2H)' , 'Greatsword' ],
attackOnly : [ 'Heavy Crossbow' , 'Medium Crossbow' , 'Light Crossbow' , 'Sling' ],
parryOnly : [ 'Main Gauche' , 'Buckler' , 'Heater/Target Shield' , 'Kite Shield' , 'Hoplite Shield' ],
},
};
// ======================================================================
// CHARACTER CREATION WIZARD
// ======================================================================
2026-07-03 16:00:54 +10:00
function renderCharactersMain () {
const wrap = el ( `<div></div>` );
2026-07-03 21:44:11 +10:00
// ---- Wizard state ----
const genState = {
step : 1 ,
name : '' , age : 21 , gender : 'M' ,
method : 'random' ,
chars : null , derived : null ,
culture : null , occupation : null ,
occResult : null , // result from compute-occupation API
choiceSelections : {}, // groupName → selected key
primaryWeapon : null , missileWeapon : null , shieldWeapon : null ,
};
// Container for the wizard
2026-07-03 16:00:54 +10:00
wrap . appendChild ( el ( `<h2>Character Generator</h2>` ));
2026-07-03 21:44:11 +10:00
const wizardWrap = el ( `<div class="card"></div>` );
wrap . appendChild ( wizardWrap );
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
// Progress bar
const progressEl = el ( `<p style="font-size:0.85em;color:var(--muted,#888);margin-bottom:0.75rem"></p>` );
wizardWrap . appendChild ( progressEl );
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
const stepContent = el ( `<div></div>` );
wizardWrap . appendChild ( stepContent );
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
function updateProgress () {
const labels = [ 'Identity' , 'Characteristics' , 'Previous Experience' , 'Review & Save' ];
progressEl . innerHTML = labels . map (( l , i ) => {
const n = i + 1 ;
const active = n === genState . step ;
return `<span style="font-weight: ${ active ? 'bold' : 'normal' } ; ${ active ? 'color:var(--accent,#0077cc)' : '' } "> ${ n } . ${ l } </span>` ;
}). join ( ' | ' );
}
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
// ---- Navigation helpers ----
function navRow ( backFn , nextFn , nextLabel ) {
const row = el ( `<div style="display:flex;gap:0.5rem;margin-top:1rem"></div>` );
if ( backFn ) {
const b = el ( `<button class="button">← Back</button>` );
b . addEventListener ( 'click' , backFn );
row . appendChild ( b );
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
if ( nextFn ) {
const n = el ( `<button class="button primary"> ${ nextLabel || 'Next →' } </button>` );
n . addEventListener ( 'click' , nextFn );
row . appendChild ( n );
}
return row ;
}
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
function goToStep ( n ) {
genState . step = n ;
updateProgress ();
stepContent . innerHTML = '' ;
if ( n === 1 ) renderStep1 ();
else if ( n === 2 ) renderStep2 ();
else if ( n === 3 ) renderStep3 ();
else if ( n === 4 ) renderStep4 ();
}
// ======== STEP 1 — Identity ========
function renderStep1 () {
const msg = el ( `<div></div>` );
const nameIn = el ( `<input placeholder="Character name" value=" ${ escapeHtml ( genState . name ) } " style="width:16rem">` );
const genderRow = el ( `<div style="display:flex;gap:1rem;align-items:center;margin:0.5rem 0"></div>` );
[ 'M' , 'F' ]. forEach (( g ) => {
const lbl = el ( `<label style="display:flex;gap:0.3rem;align-items:center"><input type="radio" name="wiz-gender" value=" ${ g } " ${ genState . gender === g ? 'checked' : '' } > ${ g === 'M' ? 'Male' : 'Female' } </label>` );
genderRow . appendChild ( lbl );
2026-07-03 16:00:54 +10:00
});
2026-07-03 21:44:11 +10:00
const ageIn = el ( `<input type="number" min="15" value=" ${ genState . age } " style="width:5rem">` );
const rollAgeBtn = el ( `<button class="button">Roll (2D6+15)</button>` );
rollAgeBtn . addEventListener ( 'click' , async () => {
try {
const res = await api ( 'POST' , '/api/characters/roll-age' , {});
ageIn . value = res . age ;
genState . age = res . age ;
} catch ( err ) {
msg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
});
const fields = [
[ 'Name' , nameIn ],
[ 'Gender' , genderRow ],
[ 'Age' , el ( `<span></span>` )],
];
fields . forEach (([ label , inp ]) => {
const row = el ( `<div class="field-row"><label> ${ label } </label></div>` );
row . appendChild ( inp );
stepContent . appendChild ( row );
});
// replace the placeholder age row with proper age row
stepContent . lastChild . remove ();
const ageRow = el ( `<div class="field-row"><label>Age</label></div>` );
const ageInputWrap = el ( `<div style="display:flex;gap:0.5rem;align-items:center"></div>` );
ageInputWrap . append ( ageIn , rollAgeBtn );
ageRow . appendChild ( ageInputWrap );
stepContent . appendChild ( ageRow );
stepContent . appendChild ( msg );
stepContent . appendChild ( navRow ( null , () => {
genState . name = nameIn . value . trim ();
const genderSel = stepContent . querySelector ( 'input[name="wiz-gender"]:checked' );
genState . gender = genderSel ? genderSel . value : 'M' ;
genState . age = Math . max ( 15 , Number ( ageIn . value ) || 21 );
if ( ! genState . name ) { msg . innerHTML = `<p class="error-message">Name is required.</p>` ; return ; }
goToStep ( 2 );
}));
}
// ======== STEP 2 — Characteristics ========
function renderStep2 () {
const msg = el ( `<div></div>` );
// Method picker
const methodRow = el ( `<div class="field-row"><label>Method</label></div>` );
const methodSel = el ( `<select>
<option value="random" ${ genState . method === 'random' ? 'selected' : '' } >Random (3D6 / 2D6+6)</option>
<option value="deliberate" ${ genState . method === 'deliberate' ? 'selected' : '' } >Deliberate (80 points)</option>
<option value="combined" ${ genState . method === 'combined' ? 'selected' : '' } >Combined (roll + 6 bonus points)</option>
</select>` );
methodRow . appendChild ( methodSel );
stepContent . appendChild ( methodRow );
const statInputsWrap = el ( `<div></div>` );
const derivedWrap = el ( `<div></div>` );
const budgetDisplay = el ( `<p style="display:none"></p>` );
stepContent . append ( statInputsWrap , budgetDisplay , derivedWrap , msg );
let currentChars = genState . chars ;
let activeInputs = 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 ;
}
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 ) {
let bonusLeft = 6 ;
const bonusLabel = el ( `<p>Bonus points remaining: <strong id="wiz-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 ( '#wiz-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 ( '#wiz-bonus-left' ). textContent = bonusLeft ;
});
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 ;
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
if ( genState . method === 'deliberate' && ! currentChars ) {
activeInputs = buildStatInputs ( false , null );
} else if ( currentChars ) {
if ( genState . method === 'combined' ) {
activeInputs = buildStatInputs ( false , currentChars );
} else {
buildStatInputs ( true , currentChars );
}
derivedWrap . innerHTML = '' ;
if ( genState . derived ) derivedWrap . appendChild ( renderDerivedStats ( genState . derived ));
}
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
methodSel . addEventListener ( 'change' , () => {
genState . method = methodSel . value ;
currentChars = null ; genState . chars = null ; genState . derived = null ;
derivedWrap . innerHTML = '' ;
activeInputs = null ;
if ( methodSel . value === 'deliberate' ) { activeInputs = buildStatInputs ( false , null ); }
else { statInputsWrap . innerHTML = '' ; }
});
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
const btnRow = el ( `<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap"></div>` );
const rollBtn = el ( `<button class="button primary">Roll</button>` );
rollBtn . style . display = genState . method === 'deliberate' ? 'none' : '' ;
rollBtn . addEventListener ( 'click' , async () => {
try {
const res = await api ( 'POST' , '/api/characters/roll' , { method : methodSel . value });
currentChars = res . chars ;
genState . method = methodSel . value ;
if ( res . bonusPoints > 0 ) { activeInputs = buildStatInputs ( false , currentChars ); }
else { buildStatInputs ( true , currentChars ); activeInputs = null ; }
derivedWrap . innerHTML = '' ;
derivedWrap . appendChild ( renderDerivedStats ( res . derived ));
genState . chars = currentChars ; genState . derived = res . derived ;
msg . innerHTML = '' ;
} catch ( err ) { msg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ; }
});
const calcBtn = el ( `<button class="button primary">Calculate</button>` );
calcBtn . style . display = genState . method === 'deliberate' ? '' : 'none' ;
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 ) { msg . innerHTML = `<p class="error-message"> ${ val . errors . map ( escapeHtml ). join ( '<br>' ) } </p>` ; return ; }
const res = await api ( 'POST' , '/api/characters/derive' , chars );
currentChars = chars ; genState . chars = chars ; genState . derived = res . derived ;
derivedWrap . innerHTML = '' ;
derivedWrap . appendChild ( renderDerivedStats ( res . derived ));
msg . innerHTML = '' ;
} catch ( err ) { msg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ; }
});
const applyBtn = el ( `<button class="button">Apply Bonus Points</button>` );
applyBtn . style . display = genState . method === 'combined' ? '' : 'none' ;
applyBtn . 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 ) { msg . innerHTML = `<p class="error-message"> ${ val . errors . map ( escapeHtml ). join ( '<br>' ) } </p>` ; return ; }
const res = await api ( 'POST' , '/api/characters/derive' , chars );
currentChars = chars ; genState . chars = chars ; genState . derived = res . derived ;
derivedWrap . innerHTML = '' ;
derivedWrap . appendChild ( renderDerivedStats ( res . derived ));
msg . innerHTML = '' ;
} catch ( err ) { msg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ; }
});
methodSel . addEventListener ( 'change' , () => {
rollBtn . style . display = methodSel . value === 'deliberate' ? 'none' : '' ;
calcBtn . style . display = methodSel . value === 'deliberate' ? '' : 'none' ;
applyBtn . style . display = methodSel . value === 'combined' ? '' : 'none' ;
});
btnRow . append ( rollBtn , calcBtn , applyBtn );
// Insert btnRow before msg
stepContent . insertBefore ( btnRow , msg );
stepContent . appendChild ( navRow (
() => goToStep ( 1 ),
() => {
if ( ! genState . chars ) { msg . innerHTML = `<p class="error-message">Roll or calculate characteristics first.</p>` ; return ; }
goToStep ( 3 );
}
));
}
// ======== STEP 3 — Previous Experience ========
async function renderStep3 () {
const years = genState . age - 15 ;
stepContent . appendChild ( el ( `<p><strong>Years of Experience:</strong> ${ years } (age ${ genState . age } − 15)</p>` ));
const msg = el ( `<div></div>` );
const occResultWrap = el ( `<div></div>` );
// Culture picker
const cultureSel = el ( `<select><option value="">— select culture —</option> ${ CULTURES . map (( c ) => `<option value=" ${ c } " ${ genState . culture === c ? 'selected' : '' } > ${ c } </option>` ). join ( '' ) } </select>` );
const rollCultureBtn = el ( `<button class="button">Roll d8</button>` );
rollCultureBtn . addEventListener ( 'click' , () => {
const d8 = Math . ceil ( Math . random () * 8 );
const mapped = d8 <= 1 ? 'Primitive' : d8 <= 3 ? 'Nomad' : d8 <= 6 ? 'Barbarian' : 'Civilized' ;
cultureSel . value = mapped ;
cultureSel . dispatchEvent ( new Event ( 'change' ));
});
const cultureRow = el ( `<div class="field-row"><label>Culture</label></div>` );
const cultureCtrl = el ( `<div style="display:flex;gap:0.5rem;align-items:center"></div>` );
cultureCtrl . append ( rollCultureBtn , cultureSel );
cultureRow . appendChild ( cultureCtrl );
stepContent . appendChild ( cultureRow );
// Occupation picker
const occSel = el ( `<select><option value="">— select occupation —</option></select>` );
const rollOccBtn = el ( `<button class="button">Roll</button>` );
const occRow = el ( `<div class="field-row"><label>Occupation</label></div>` );
const occCtrl = el ( `<div style="display:flex;gap:0.5rem;align-items:center"></div>` );
occCtrl . append ( rollOccBtn , occSel );
occRow . appendChild ( occCtrl );
stepContent . appendChild ( occRow );
function refreshOccupations () {
const culture = cultureSel . value ;
occSel . innerHTML = '<option value="">— select occupation —</option>' ;
if ( ! culture || ! OCCUPATION_LABELS [ culture ]) return ;
Object . entries ( OCCUPATION_LABELS [ culture ]). forEach (([ key , label ]) => {
occSel . appendChild ( el ( `<option value=" ${ key } " ${ genState . occupation === key ? 'selected' : '' } > ${ label } </option>` ));
2026-07-03 16:00:54 +10:00
});
}
2026-07-03 21:44:11 +10:00
cultureSel . addEventListener ( 'change' , () => {
genState . culture = cultureSel . value || null ;
genState . occupation = null ;
genState . occResult = null ;
genState . choiceSelections = {};
genState . primaryWeapon = null ; genState . missileWeapon = null ; genState . shieldWeapon = null ;
refreshOccupations ();
occResultWrap . innerHTML = '' ;
});
rollOccBtn . addEventListener ( 'click' , () => {
const culture = cultureSel . value ;
if ( ! culture || ! OCCUPATION_LABELS [ culture ]) return ;
const keys = Object . keys ( OCCUPATION_LABELS [ culture ]);
occSel . value = keys [ Math . floor ( Math . random () * keys . length )];
occSel . dispatchEvent ( new Event ( 'change' ));
});
refreshOccupations ();
async function loadOccupation () {
const culture = cultureSel . value ;
const occupation = occSel . value ;
if ( ! culture || ! occupation ) { occResultWrap . innerHTML = '' ; return ; }
genState . culture = culture ;
genState . occupation = occupation ;
genState . choiceSelections = {};
try {
const result = await api ( 'POST' , '/api/characters/compute-occupation' , {
chars : genState . chars ,
culture ,
occupation ,
years ,
});
genState . occResult = result ;
renderOccResult ( result , culture , years );
} catch ( err ) {
occResultWrap . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
}
occSel . addEventListener ( 'change' , loadOccupation );
// If we already have selections from a back-navigation, reload
if ( genState . culture && genState . occupation && ! genState . occResult ) {
await loadOccupation ();
} else if ( genState . occResult ) {
renderOccResult ( genState . occResult , genState . culture , years );
}
stepContent . appendChild ( occResultWrap );
stepContent . appendChild ( msg );
stepContent . appendChild ( navRow (
() => goToStep ( 2 ),
() => {
if ( ! genState . culture || ! genState . occupation ) {
msg . innerHTML = `<p class="error-message">Please select culture and occupation.</p>` ; return ;
}
goToStep ( 4 );
}
));
function sign ( n ) { return n >= 0 ? `+ ${ n } ` : ` ${ n } ` ; }
function renderOccResult ( result , culture , years ) {
occResultWrap . innerHTML = '' ;
const occ = result . occupation ;
// --- Choice groups ---
if ( Object . keys ( result . choiceGroups ). length ) {
const choiceCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.5rem">Choices Required</h4></div>` );
Object . entries ( result . choiceGroups ). forEach (([ groupName , options ]) => {
const groupLabel = {
parryChoice : 'Parry / Defence choice' ,
weaponStyle : 'Weapon style choice' ,
craftPick : 'Craft specialisation' ,
lorePick : 'Lore specialisation' ,
}[ groupName ] || groupName ;
const gDiv = el ( `<div style="margin-bottom:0.5rem"><label style="font-size:0.9em"><strong> ${ escapeHtml ( groupLabel ) } :</strong></label></div>` );
const sel = el ( `<select style="margin-left:0.5rem"><option value="">— pick one —</option></select>` );
options . forEach (( opt ) => {
const label = opt . note === 'weapon' ? ` ${ opt . key } (× ${ opt . mult } , + ${ opt . bonus } %)` :
opt . note === 'craft' ? ` ${ opt . key } (× ${ opt . mult } , + ${ opt . bonus } %)` :
` ${ SKILL_DISPLAY_NAMES [ opt . key ] || opt . key } (× ${ opt . mult } , total ${ opt . total } %)` ;
const optEl = el ( `<option value=" ${ opt . key } " ${ genState . choiceSelections [ groupName ] === opt . key ? 'selected' : '' } > ${ escapeHtml ( label ) } </option>` );
sel . appendChild ( optEl );
});
sel . addEventListener ( 'change' , () => {
genState . choiceSelections [ groupName ] = sel . value || null ;
});
if ( genState . choiceSelections [ groupName ]) sel . value = genState . choiceSelections [ groupName ];
gDiv . appendChild ( sel );
choiceCard . appendChild ( gDiv );
});
occResultWrap . appendChild ( choiceCard );
}
// --- General skills table ---
if ( Object . keys ( result . skills ). length ) {
const skillCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.5rem">Occupation Skills</h4></div>` );
const grouped = {};
Object . entries ( result . skills ). forEach (([ key , val ]) => {
const cat = SKILL_CATEGORIES [ key ] || 'Other' ;
if ( ! grouped [ cat ]) grouped [ cat ] = [];
grouped [ cat ]. push ({ key , val });
});
const tbl = el ( `<table style="border-collapse:collapse;font-size:0.9em;width:100%"></table>` );
tbl . innerHTML = `<thead><tr><th style="text-align:left;padding:0.1rem 0.75rem 0.1rem 0">Skill</th><th style="text-align:left;padding:0.1rem 0.75rem 0.1rem 0">Category</th><th style="text-align:right;padding:0.1rem 0">Total %</th></tr></thead>` ;
const tbody = el ( `<tbody></tbody>` );
Object . keys ( grouped ). sort (). forEach (( cat ) => {
grouped [ cat ]. sort (( a , b ) => a . key . localeCompare ( b . key )). forEach (({ key , val }) => {
tbody . appendChild ( el ( `<tr><td style="padding:0.1rem 0.75rem 0.1rem 0"> ${ escapeHtml ( SKILL_DISPLAY_NAMES [ key ] || key ) } </td><td style="padding:0.1rem 0.75rem 0.1rem 0;color:var(--muted,#888);font-size:0.85em"> ${ cat } </td><td style="text-align:right;padding:0.1rem 0"> ${ val } %</td></tr>` ));
});
});
tbl . appendChild ( tbody );
skillCard . appendChild ( tbl );
occResultWrap . appendChild ( skillCard );
}
// --- Weapon skills ---
const mods = deriveModsFromChars ( genState . chars );
const attackMod = mods ? mods . attack : 0 ;
const parryMod = mods ? mods . parry : 0 ;
const wb = result . weaponBonuses ;
const weaponCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.5rem">Weapon Skills</h4></div>` );
weaponCard . appendChild ( el ( `<p style="font-size:0.85em;color:var(--muted,#888)">Attack modifier: ${ sign ( attackMod ) } % Parry modifier: ${ sign ( parryMod ) } %</p>` ));
// Cultural weapon lists
const cw = CULTURAL_WEAPONS [ culture ] || { attackParry : [], attackOnly : [], parryOnly : [] };
const allPrimary = [... cw . attackParry ];
const allMissile = [... cw . attackOnly ];
const allShield = [... cw . parryOnly ];
function getCulturalBase ( weaponName ) {
// Return approximate cultural base percent for display
if ( cw . attackParry . includes ( weaponName )) {
if ( culture === 'Primitive' ) return 25 ;
if ( culture === 'Nomad' ) return 20 ;
if ( culture === 'Barbarian' ) return ( weaponName . includes ( '2H' ) || weaponName . includes ( 'Greatsword' ) || weaponName . includes ( 'Bastard' )) ? 15 : 25 ;
if ( culture === 'Civilized' ) return ( weaponName . includes ( '2H' ) || weaponName . includes ( 'Greatsword' ) || weaponName . includes ( 'Bastard' )) ? 15 : 25 ;
}
if ( cw . attackOnly . includes ( weaponName )) {
if ( culture === 'Primitive' ) return weaponName . includes ( 'Sling' ) || weaponName . includes ( 'Bow' ) ? 25 : 20 ;
if ( culture === 'Nomad' ) return weaponName . includes ( 'Lance' ) ? 30 : 20 ;
if ( culture === 'Barbarian' ) return 25 ;
if ( culture === 'Civilized' ) return 25 ;
}
if ( cw . parryOnly . includes ( weaponName )) {
if ( culture === 'Primitive' || culture === 'Nomad' || culture === 'Civilized' ) return 25 ;
if ( culture === 'Barbarian' ) return 25 ;
}
return 0 ;
}
// Primary weapon select
const primarySel = el ( `<select style="flex:1"><option value="">— none —</option> ${ allPrimary . map (( w ) => `<option value=" ${ w } " ${ genState . primaryWeapon === w ? 'selected' : '' } > ${ escapeHtml ( w ) } </option>` ). join ( '' ) } </select>` );
const missileSel = el ( `<select style="flex:1"><option value="">— none —</option> ${ allMissile . map (( w ) => `<option value=" ${ w } " ${ genState . missileWeapon === w ? 'selected' : '' } > ${ escapeHtml ( w ) } </option>` ). join ( '' ) } </select>` );
const shieldSel = el ( `<select style="flex:1"><option value="">— none —</option> ${ allShield . map (( w ) => `<option value=" ${ w } " ${ genState . shieldWeapon === w ? 'selected' : '' } > ${ escapeHtml ( w ) } </option>` ). join ( '' ) } </select>` );
[[ 'Primary weapon' , primarySel ], [ 'Missile weapon' , missileSel ], [ 'Parry weapon / shield' , shieldSel ]]. forEach (([ label , sel ]) => {
const row = el ( `<div class="field-row" style="margin-bottom:0.25rem"><label style="min-width:10rem"> ${ label } </label></div>` );
row . appendChild ( sel );
weaponCard . appendChild ( row );
});
primarySel . addEventListener ( 'change' , () => { genState . primaryWeapon = primarySel . value || null ; updateWeaponTable (); });
missileSel . addEventListener ( 'change' , () => { genState . missileWeapon = missileSel . value || null ; updateWeaponTable (); });
shieldSel . addEventListener ( 'change' , () => { genState . shieldWeapon = shieldSel . value || null ; updateWeaponTable (); });
const weaponTableWrap = el ( `<div style="margin-top:0.5rem"></div>` );
weaponCard . appendChild ( weaponTableWrap );
function updateWeaponTable () {
weaponTableWrap . innerHTML = '' ;
const rows = [];
// Fist
const fistAtk = 25 + attackMod + ( wb [ 'attack:fist' ] || 0 );
rows . push ({ name : 'Fist' , attack : Math . max ( 0 , fistAtk ), parry : null , note : 'natural' });
// Dagger
const dagAtk = 15 + attackMod + ( wb [ 'attack:dagger' ] || 0 );
rows . push ({ name : 'Dagger' , attack : Math . max ( 0 , dagAtk ), parry : null , note : 'natural' });
// Primary weapon
if ( genState . primaryWeapon ) {
const w = genState . primaryWeapon ;
const base = getCulturalBase ( w );
const atkBonus = wb [ 'attack:primary' ] || 0 ;
const atk = Math . max ( 0 , base + attackMod + atkBonus );
// Parry depends on what the user chose for parryChoice
const parryChoiceKey = genState . choiceSelections [ 'parryChoice' ] || genState . choiceSelections [ 'weaponStyle' ];
let pry = null ;
if ( parryChoiceKey === 'parry:weapon' ) {
const parryBonus = ( result . choiceGroups [ 'parryChoice' ] || result . choiceGroups [ 'weaponStyle' ] || [])
. find (( o ) => o . key === 'parry:weapon' );
pry = Math . max ( 0 , base + parryMod + ( parryBonus ? parryBonus . bonus : 0 ));
} else {
pry = Math . max ( 0 , base + parryMod );
}
rows . push ({ name : w , attack : atk , parry : pry , note : 'primary' });
}
// Missile weapon
if ( genState . missileWeapon ) {
const w = genState . missileWeapon ;
const base = getCulturalBase ( w );
const missileBonus = wb [ 'attack:missile' ] || 0 ;
const atk = Math . max ( 0 , base + attackMod + missileBonus );
rows . push ({ name : w , attack : atk , parry : null , note : 'missile' });
}
// Shield / parry weapon
if ( genState . shieldWeapon ) {
const w = genState . shieldWeapon ;
const base = getCulturalBase ( w );
const parryChoiceKey = genState . choiceSelections [ 'parryChoice' ] || genState . choiceSelections [ 'weaponStyle' ];
const parryChoiceGroup = result . choiceGroups [ 'parryChoice' ] || result . choiceGroups [ 'weaponStyle' ] || [];
let parryBonus = 0 ;
if ( parryChoiceKey === 'parry:shield' ) {
const found = parryChoiceGroup . find (( o ) => o . key === 'parry:shield' );
parryBonus = found ? found . bonus : 0 ;
}
const pry = Math . max ( 0 , base + parryMod + parryBonus );
rows . push ({ name : w , attack : null , parry : pry , note : 'shield' });
}
if ( ! rows . length ) return ;
const tbl = el ( `<table style="border-collapse:collapse;font-size:0.9em;width:100%"></table>` );
tbl . innerHTML = `<thead><tr><th style="text-align:left;padding:0.1rem 0.75rem 0.1rem 0">Weapon</th><th style="text-align:right;padding:0.1rem 0.5rem 0.1rem 0">Attack %</th><th style="text-align:right;padding:0.1rem 0">Parry %</th></tr></thead>` ;
const tbody = el ( `<tbody></tbody>` );
rows . forEach (({ name , attack , parry }) => {
tbody . appendChild ( el ( `<tr>
<td style="padding:0.1rem 0.75rem 0.1rem 0"> ${ escapeHtml ( name ) } </td>
<td style="text-align:right;padding:0.1rem 0.5rem 0.1rem 0"> ${ attack != null ? attack + '%' : '—' } </td>
<td style="text-align:right;padding:0.1rem 0"> ${ parry != null ? parry + '%' : '—' } </td>
</tr>` ));
});
tbl . appendChild ( tbody );
weaponTableWrap . appendChild ( tbl );
}
updateWeaponTable ();
occResultWrap . appendChild ( weaponCard );
// --- Ritual skills ---
if ( Object . keys ( result . ritualBonuses ). length ) {
const ritCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Ritual Skills</h4></div>` );
Object . entries ( result . ritualBonuses ). forEach (([ key , bonus ]) => {
ritCard . appendChild ( el ( `<p style="margin:0.1rem 0"> ${ escapeHtml ( key ) } : + ${ bonus } % ( ${ years } × ${ bonus / years } )</p>` ));
});
occResultWrap . appendChild ( ritCard );
}
// --- Craft bonuses ---
if ( Object . keys ( result . craftBonuses ). length ) {
const craftCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Craft Skills</h4></div>` );
Object . entries ( result . craftBonuses ). forEach (([ key , bonus ]) => {
craftCard . appendChild ( el ( `<p style="margin:0.1rem 0"> ${ escapeHtml ( key ) } : + ${ bonus } % ( ${ years } × ${ bonus / years } )</p>` ));
});
occResultWrap . appendChild ( craftCard );
}
// --- Magic ---
if ( occ . magic ) {
const magicCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Magic</h4></div>` );
const m = occ . magic ;
let magicText = `Type: ${ m . type } . Starting points: ${ m . basePoints || 0 } ` ;
if ( m . extraPerYears ) magicText += ` + 1 per ${ m . extraPerYears } year(s) (total + ${ Math . floor ( years / m . extraPerYears ) } )` ;
magicCard . appendChild ( el ( `<p style="margin:0"> ${ escapeHtml ( magicText ) } </p>` ));
occResultWrap . appendChild ( magicCard );
}
// --- Equipment ---
if ( occ . equipment ) {
const eqCard = el ( `<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Starting Equipment</h4></div>` );
eqCard . appendChild ( el ( `<p style="margin:0"> ${ escapeHtml ( occ . equipment ) } </p>` ));
occResultWrap . appendChild ( eqCard );
}
}
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
// Helper: derive attack/parry modifiers from chars without a server call
function deriveModsFromChars ( chars ) {
if ( ! chars ) return null ;
const { str , con , siz , int : INT , pow , dex , app } = chars ;
function primary ( c ) { return c - 10 ; }
function secondary ( c ) { const d = c - 10 ; if ( ! d ) return 0 ; const r = Math . sign ( d ) * Math . ceil ( Math . abs ( d ) / 2 ); return Math . min ( r , 10 ); }
function negative ( c ) { return 10 - c ; }
const agility = primary ( dex ) + secondary ( str ) + negative ( siz );
const manipulation = primary ( INT ) + primary ( dex ) + secondary ( str );
return { attack : manipulation , parry : agility };
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
// ======== STEP 4 — Review & Save ========
function renderStep4 () {
const msg = el ( `<div></div>` );
const years = genState . age - 15 ;
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
const reviewCard = el ( `<div></div>` );
reviewCard . appendChild ( el ( `<h3 style="margin:0 0 0.5rem"> ${ escapeHtml ( genState . name ) } </h3>` ));
// Identity summary
reviewCard . appendChild ( el ( `<p><strong>Age:</strong> ${ genState . age } <strong>Gender:</strong> ${ genState . gender } <strong>Years exp:</strong> ${ years } </p>` ));
// Characteristics summary
if ( genState . chars ) {
const row = el ( `<div class="field-row" style="flex-wrap:wrap;gap:0.75rem;margin-top:0.5rem"></div>` );
CHAR_KEYS . forEach (( k ) => row . appendChild ( el ( `<span><strong> ${ CHAR_LABELS [ k ] } :</strong> ${ genState . chars [ k ] } </span>` )));
reviewCard . appendChild ( row );
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
if ( genState . derived ) reviewCard . appendChild ( renderDerivedStats ( genState . derived ));
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
// Culture / Occupation
reviewCard . appendChild ( el ( `<p style="margin-top:0.5rem"><strong>Culture:</strong> ${ escapeHtml ( genState . culture || '—' ) } <strong>Occupation:</strong> ${ escapeHtml ( genState . occupation ? (( OCCUPATION_LABELS [ genState . culture ] || { } )[genState.occupation] || genState.occupation) : '—')}</p>` ));
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
// Choices
if ( Object . keys ( genState . choiceSelections ). length ) {
const choiceP = el ( `<p style="margin:0.25rem 0;font-size:0.9em"><strong>Choices:</strong> ${ Object . entries ( genState . choiceSelections ). map (([ g , k ]) => ` ${ g } : ${ k } ` ). join ( '; ' ) } </p>` );
reviewCard . appendChild ( choiceP );
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
stepContent . appendChild ( reviewCard );
stepContent . appendChild ( msg );
const saveBtn = el ( `<button class="button primary" style="margin-top:0.75rem">Save Character</button>` );
stepContent . appendChild ( saveBtn );
stepContent . appendChild ( navRow (() => goToStep ( 3 ), null ));
saveBtn . addEventListener ( 'click' , async () => {
if ( ! genState . chars ) { msg . innerHTML = `<p class="error-message">Missing characteristics.</p>` ; return ; }
// Build weapon list
const weapons = [];
const mods = deriveModsFromChars ( genState . chars );
const attackMod = mods ? mods . attack : 0 ;
const parryMod = mods ? mods . parry : 0 ;
const wb = ( genState . occResult && genState . occResult . weaponBonuses ) || {};
const cw = CULTURAL_WEAPONS [ genState . culture ] || { attackParry : [], attackOnly : [], parryOnly : [] };
function getCulturalBase ( weaponName ) {
if ( cw . attackParry . includes ( weaponName )) {
if ( genState . culture === 'Primitive' ) return 25 ;
if ( genState . culture === 'Nomad' ) return 20 ;
if ( genState . culture === 'Barbarian' ) return ( weaponName . includes ( '2H' ) || weaponName . includes ( 'Greatsword' ) || weaponName . includes ( 'Bastard' )) ? 15 : 25 ;
if ( genState . culture === 'Civilized' ) return ( weaponName . includes ( '2H' ) || weaponName . includes ( 'Greatsword' ) || weaponName . includes ( 'Bastard' )) ? 15 : 25 ;
}
if ( cw . attackOnly . includes ( weaponName )) {
if ( genState . culture === 'Primitive' ) return weaponName . includes ( 'Sling' ) || weaponName . includes ( 'Bow' ) ? 25 : 20 ;
if ( genState . culture === 'Nomad' ) return weaponName . includes ( 'Lance' ) ? 30 : 20 ;
return 25 ;
}
if ( cw . parryOnly . includes ( weaponName )) return 25 ;
return 0 ;
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
// Fist
weapons . push ({ weapon_name : 'Fist' , category : 'Fist' , skill_percent : Math . max ( 0 , 25 + attackMod + ( wb [ 'attack:fist' ] || 0 )), mode : 'melee' });
// Dagger
weapons . push ({ weapon_name : 'Dagger' , category : 'Dagger' , skill_percent : Math . max ( 0 , 15 + attackMod + ( wb [ 'attack:dagger' ] || 0 )), mode : 'melee' });
if ( genState . primaryWeapon ) {
const base = getCulturalBase ( genState . primaryWeapon );
weapons . push ({ weapon_name : genState . primaryWeapon , category : 'primary' , skill_percent : Math . max ( 0 , base + attackMod + ( wb [ 'attack:primary' ] || 0 )), mode : 'melee' });
}
if ( genState . missileWeapon ) {
const base = getCulturalBase ( genState . missileWeapon );
weapons . push ({ weapon_name : genState . missileWeapon , category : 'missile' , skill_percent : Math . max ( 0 , base + attackMod + ( wb [ 'attack:missile' ] || 0 )), mode : 'missile' });
}
if ( genState . shieldWeapon ) {
const base = getCulturalBase ( genState . shieldWeapon );
const parryChoiceKey = genState . choiceSelections [ 'parryChoice' ] || genState . choiceSelections [ 'weaponStyle' ];
let parryBonus = 0 ;
if ( genState . occResult ) {
const parryChoiceGroup = ( genState . occResult . choiceGroups || {})[ 'parryChoice' ] || ( genState . occResult . choiceGroups || {})[ 'weaponStyle' ] || [];
if ( parryChoiceKey === 'parry:shield' ) {
const found = parryChoiceGroup . find (( o ) => o . key === 'parry:shield' );
parryBonus = found ? found . bonus : 0 ;
}
}
weapons . push ({ weapon_name : genState . shieldWeapon , category : 'shield' , skill_percent : Math . max ( 0 , base + parryMod + parryBonus ), mode : 'parry' });
2026-07-03 16:00:54 +10:00
}
2026-07-03 21:44:11 +10:00
try {
await api ( 'POST' , '/api/characters' , {
name : genState . name ,
generation_method : genState . method ,
chars : genState . chars ,
age : genState . age ,
culture : genState . culture ,
occupation : genState . occupation ,
weapons ,
});
await loadPlayerCharacters ();
msg . innerHTML = `<p style="color:var(--success,green)">Saved " ${ escapeHtml ( genState . name ) } "! Starting a new character...</p>` ;
renderSidebar ();
// Reset wizard
setTimeout (() => {
Object . assign ( genState , { step : 1 , name : '' , age : 21 , gender : 'M' , method : 'random' , chars : null , derived : null , culture : null , occupation : null , occResult : null , choiceSelections : {}, primaryWeapon : null , missileWeapon : null , shieldWeapon : null });
goToStep ( 1 );
}, 1500 );
} catch ( err ) {
msg . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
}
2026-07-03 16:00:54 +10:00
2026-07-03 21:44:11 +10:00
// Start wizard
updateProgress ();
renderStep1 ();
2026-07-03 16:00:54 +10:00
// --- Selected PC view ---
const pc = state . playerCharacters . find (( c ) => c . id === state . selectedCharacterId );
if ( pc ) {
2026-07-03 21:44:11 +10:00
wrap . appendChild ( renderCharacterSheet ( pc ));
2026-07-03 16:00:54 +10:00
}
return wrap ;
}
2026-07-03 21:44:11 +10:00
// ---------- Shared trait editor ----------
let _knownTraits = []; // populated from /api/rules/personality-traits on init
async function loadPersonalityTraits () {
try {
const res = await api ( 'GET' , '/api/rules/personality-traits' );
_knownTraits = res . traits || [];
} catch ( _ ) {}
}
// saveFn: async (traits) → void
function renderTraitsEditor ( traits , saveFn ) {
const wrap = el ( `<div></div>` );
const listWrap = el ( `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;margin-bottom:0.4rem"></div>` );
let current = [...( traits || [])];
function redraw () {
listWrap . innerHTML = '' ;
current . forEach (( t , i ) => {
const pill = el ( `<span style="display:inline-flex;align-items:center;gap:0.25rem;background:var(--tag-bg,#e8e8e8);padding:0.1rem 0.5rem;border-radius:12px;font-size:0.85em"></span>` );
const nameSpan = el ( `<strong> ${ escapeHtml ( t . name ) } </strong>` );
const ratingIn = el ( `<input type="number" min="1" max="100" value=" ${ t . rating } " style="width:3.2rem;font-size:0.8em;padding:0.05rem 0.2rem;border:none;background:transparent;text-align:right">` );
ratingIn . addEventListener ( 'change' , async () => {
current [ i ] = { ... current [ i ], rating : Math . min ( 100 , Math . max ( 1 , Number ( ratingIn . value ) || current [ i ]. rating )) };
await saveFn ( current );
});
const removeBtn = el ( `<button style="background:none;border:none;cursor:pointer;padding:0;line-height:1;color:var(--muted,#888)" title="Remove">✕</button>` );
removeBtn . addEventListener ( 'click' , async () => {
current . splice ( i , 1 );
await saveFn ( current );
redraw ();
});
pill . append ( nameSpan , el ( `<span style="color:var(--muted,#888)">:</span>` ), ratingIn , el ( `<span style="font-size:0.75em;color:var(--muted,#888)">%</span>` ), removeBtn );
listWrap . appendChild ( pill );
});
}
// Add trait form
const suggestions = _knownTraits . filter ( n => ! current . find ( t => t . name === n ));
const traitSel = el ( `<select style="font-size:0.85em">
<option value="">— pick or type —</option>
${ suggestions . map ( n => `<option> ${ escapeHtml ( n ) } </option>` ). join ( '' ) }
</select>` );
const customIn = el ( `<input placeholder="Custom trait" style="font-size:0.85em;width:8rem">` );
traitSel . addEventListener ( 'change' , () => { if ( traitSel . value ) customIn . value = traitSel . value ; });
const ratingIn = el ( `<input type="number" value="60" min="1" max="100" style="width:3.5rem;font-size:0.85em" title="Rating %">` );
const addBtn = el ( `<button class="button" style="font-size:0.85em;padding:0.15rem 0.5rem">Add</button>` );
addBtn . addEventListener ( 'click' , async () => {
const name = ( customIn . value . trim () || traitSel . value ). trim ();
if ( ! name ) return ;
if ( current . find ( t => t . name . toLowerCase () === name . toLowerCase ())) return ;
current . push ({ name , rating : Math . min ( 100 , Math . max ( 1 , Number ( ratingIn . value ) || 60 )) });
await saveFn ( current );
customIn . value = '' ; traitSel . value = '' ;
redraw ();
});
const formRow = el ( `<div style="display:flex;gap:0.3rem;flex-wrap:wrap;align-items:center"></div>` );
formRow . append ( traitSel , customIn , ratingIn , el ( `<span style="font-size:0.8em;color:var(--muted,#888)">%</span>` ), addBtn );
redraw ();
wrap . append ( listWrap , formRow );
return wrap ;
}
function renderCharacterSheet ( pc ) {
const frag = el ( `<div></div>` );
const sb = pc . stat_block ;
const derived = pc . derived || {};
const sm = derived . skillModifiers || {};
const skills = pc . skills || {};
function sign ( n ) { return n >= 0 ? `+ ${ n } ` : ` ${ n } ` ; }
function pct ( n ) { return n != null ? ` ${ n } %` : '—' ; }
// ── Identity & location ──────────────────────────────────────────
frag . appendChild ( el ( `<h2> ${ escapeHtml ( pc . name ) } </h2>` ));
const identCard = el ( `<div class="card"></div>` );
const identRow = el ( `<div style="display:flex;flex-wrap:wrap;gap:0.75rem;font-size:0.9em"></div>` );
if ( pc . age ) identRow . appendChild ( el ( `<span><strong>Age:</strong> ${ pc . age } </span>` ));
if ( pc . culture ) identRow . appendChild ( el ( `<span><strong>Culture:</strong> ${ escapeHtml ( pc . culture ) } </span>` ));
if ( pc . occupation_label || pc . occupation )
identRow . appendChild ( el ( `<span><strong>Occupation:</strong> ${ escapeHtml ( pc . occupation_label || pc . occupation ) } </span>` ));
identCard . appendChild ( identRow );
// Location tracker
const locRow = el ( `<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.6rem;align-items:center"></div>` );
const locInput = el ( `<input placeholder="Current location" style="flex:1;min-width:10rem" value=" ${ escapeHtml ( pc . current_location || '' ) } ">` );
const destInput = el ( `<input placeholder="Heading to" style="flex:1;min-width:10rem" value=" ${ escapeHtml ( pc . destination || '' ) } ">` );
async function saveLocation () {
await api ( 'PATCH' , `/api/characters/ ${ pc . id } /location` , {
current_location : locInput . value . trim () || null ,
destination : destInput . value . trim () || null ,
});
pc . current_location = locInput . value . trim () || null ;
pc . destination = destInput . value . trim () || null ;
}
locInput . addEventListener ( 'blur' , saveLocation );
destInput . addEventListener ( 'blur' , saveLocation );
locRow . append ( el ( `<span style="font-size:0.85em;color:var(--muted,#888)">Location:</span>` ), locInput ,
el ( `<span style="font-size:0.85em;color:var(--muted,#888)">→</span>` ), destInput );
identCard . appendChild ( locRow );
frag . appendChild ( identCard );
// ── Characteristics & derived ────────────────────────────────────
const statsCard = el ( `<div class="card" style="margin-top:0.5rem"></div>` );
const charRow = el ( `<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:0.4rem;text-align:center"></div>` );
CHAR_KEYS . forEach (( k ) => {
charRow . appendChild ( el ( `<div><div style="font-size:0.75em;font-weight:bold"> ${ CHAR_LABELS [ k ] } </div><div style="font-size:1.2em"> ${ sb [ k ] } </div></div>` ));
});
statsCard . appendChild ( charRow );
const derivedRow = el ( `<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-top:0.6rem;font-size:0.9em"></div>` );
[
[ 'HP' , ` ${ sb . current_hp } / ${ sb . max_hp } ` ],
[ 'FP' , ` ${ derived . fatigue ?? sb . str + sb . con } ` ],
[ 'MP' , ` ${ sb . magic_points_current } / ${ sb . magic_points_max } ` ],
[ 'DB' , escapeHtml ( derived . damageBonus || '0' )],
[ 'SR' , derived . strikeRank ?? '—' ],
]. forEach (([ label , val ]) => derivedRow . appendChild ( el ( `<span><strong> ${ label } :</strong> ${ val } </span>` )));
statsCard . appendChild ( derivedRow );
const modRow = el ( `<div style="display:flex;flex-wrap:wrap;gap:0.6rem;margin-top:0.6rem;font-size:0.8em"></div>` );
[[ 'Agility' , sm . agility ], [ 'Comm' , sm . communication ], [ 'Know' , sm . knowledge ],
[ 'Magic' , sm . magic ], [ 'Manip' , sm . manipulation ], [ 'Percep' , sm . perception ], [ 'Stealth' , sm . stealth ]]. forEach (([ label , v ]) => {
if ( v == null ) return ;
modRow . appendChild ( el ( `<span style="background:var(--tag-bg,#eee);padding:0.1rem 0.4rem;border-radius:3px"> ${ label } ${ sign ( v ) } %</span>` ));
});
statsCard . appendChild ( modRow );
frag . appendChild ( statsCard );
// ── Skills ───────────────────────────────────────────────────────
const SKILL_DISPLAY = {
agility : { label : 'Agility' , keys : [ 'boat' , 'climb' , 'dodge' , 'jump' , 'ride' , 'swim' , 'throw' ] },
communication : { label : 'Communication' , keys : [ 'fastTalk' , 'orate' , 'sing' , 'speakOwnLanguage' , 'speakOtherLanguage' ] },
knowledge : { label : 'Knowledge' , keys : [ 'firstAid' , 'animalLore' , 'humanLore' , 'mineralLore' , 'plantLore' , 'worldLore' , 'evaluate' ] },
manipulation : { label : 'Manipulation' , keys : [ 'conceal' , 'sleight' , 'devise' ] },
perception : { label : 'Perception' , keys : [ 'listen' , 'scan' , 'search' , 'track' ] },
stealth : { label : 'Stealth' , keys : [ 'hide' , 'sneak' ] },
};
const SKILL_NAMES = {
boat : 'Boat' , climb : 'Climb' , dodge : 'Dodge' , jump : 'Jump' , ride : 'Ride' , swim : 'Swim' , throw : 'Throw' ,
fastTalk : 'Fast Talk' , orate : 'Orate' , sing : 'Sing' , speakOwnLanguage : 'Speak (own)' , speakOtherLanguage : 'Speak (other)' ,
firstAid : 'First Aid' , animalLore : 'Animal Lore' , humanLore : 'Human Lore' , mineralLore : 'Mineral Lore' ,
plantLore : 'Plant Lore' , worldLore : 'World Lore' , evaluate : 'Evaluate' ,
conceal : 'Conceal' , sleight : 'Sleight' , devise : 'Devise' ,
listen : 'Listen' , scan : 'Scan' , search : 'Search' , track : 'Track' ,
hide : 'Hide' , sneak : 'Sneak' ,
};
if ( Object . keys ( skills ). length ) {
const skillsCard = el ( `<div class="card" style="margin-top:0.5rem"></div>` );
skillsCard . appendChild ( el ( `<strong>Skills</strong>` ));
const grid = el ( `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:0.5rem 1.5rem;margin-top:0.4rem;font-size:0.85em"></div>` );
for ( const [, { label , keys }] of Object . entries ( SKILL_DISPLAY )) {
const section = el ( `<div></div>` );
section . appendChild ( el ( `<div style="font-size:0.75em;font-weight:bold;color:var(--muted,#888);margin-bottom:0.2rem"> ${ label } </div>` ));
keys . forEach (( k ) => {
const val = skills [ k ];
if ( ! val && val !== 0 ) return ;
section . appendChild ( el ( `<div style="display:flex;justify-content:space-between"><span> ${ SKILL_NAMES [ k ] || k } </span><span> ${ pct ( val ) } </span></div>` ));
});
// Ritual skills
if ( pc . ritualBonuses ) {
Object . entries ( pc . ritualBonuses ). forEach (([ rk , bonus ]) => {
const name = rk . replace ( 'ritual:' , '' ). replace ( /^\w/ , c => c . toUpperCase ());
section . appendChild ( el ( `<div style="display:flex;justify-content:space-between"><span> ${ name } </span><span> ${ pct ( 5 + bonus ) } </span></div>` ));
});
}
if ( section . children . length > 1 ) grid . appendChild ( section );
}
skillsCard . appendChild ( grid );
frag . appendChild ( skillsCard );
}
// ── Weapons ──────────────────────────────────────────────────────
if ( sb . weapons && sb . weapons . length ) {
const weapCard = el ( `<div class="card" style="margin-top:0.5rem"></div>` );
weapCard . appendChild ( el ( `<strong>Weapons</strong>` ));
const tbl = el ( `<table style="border-collapse:collapse;width:100%;font-size:0.85em;margin-top:0.4rem"></table>` );
tbl . innerHTML = `<thead><tr>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Weapon</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Atk%</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Par%</th>
<th style="text-align:left;padding:0 0 0.25rem 0.5rem">Mode</th>
</tr></thead>` ;
const tbody = el ( `<tbody></tbody>` );
sb . weapons . forEach (( w ) => {
const row = el ( `<tr>
<td style="padding:0.1rem 0.5rem 0.1rem 0"> ${ escapeHtml ( w . weapon_name ) } </td>
<td style="text-align:right;padding:0.1rem 0.5rem"> ${ w . skill_percent } %</td>
<td style="text-align:right;padding:0.1rem 0.5rem"> ${ w . parry_percent ? w . parry_percent + '%' : '—' } </td>
<td style="padding:0.1rem 0 0.1rem 0.5rem;color:var(--muted,#888);font-size:0.85em"> ${ escapeHtml ( w . mode || w . category || '' ) } </td>
</tr>` );
tbody . appendChild ( row );
});
tbl . appendChild ( tbody );
weapCard . appendChild ( tbl );
frag . appendChild ( weapCard );
}
// ── Hit Locations ─────────────────────────────────────────────────
if ( sb . hit_locations && sb . hit_locations . length ) {
const locCard = el ( `<div class="card" style="margin-top:0.5rem"></div>` );
locCard . appendChild ( el ( `<strong>Hit Locations</strong>` ));
const tbl = el ( `<table style="border-collapse:collapse;width:100%;font-size:0.85em;margin-top:0.4rem"></table>` );
tbl . innerHTML = `<thead><tr>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Location</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">HP</th>
<th style="text-align:right;padding:0 0 0.25rem 0.5rem">Armour AP</th>
</tr></thead>` ;
const tbody = el ( `<tbody></tbody>` );
sb . hit_locations . forEach (( l ) => {
tbody . appendChild ( el ( `<tr>
<td style="padding:0.1rem 0.5rem 0.1rem 0"> ${ escapeHtml ( l . location_name ) } </td>
<td style="text-align:right;padding:0.1rem 0.5rem"> ${ l . current_hp } / ${ l . max_hp } </td>
<td style="text-align:right;padding:0.1rem 0 0.1rem 0.5rem"> ${ l . armor_ap || 0 } </td>
</tr>` ));
});
tbl . appendChild ( tbody );
locCard . appendChild ( tbl );
frag . appendChild ( locCard );
}
// ── Personality Traits ───────────────────────────────────────────
const traitCard = el ( `<div class="card" style="margin-top:0.5rem"></div>` );
traitCard . appendChild ( el ( `<strong>Personality Traits</strong>` ));
traitCard . appendChild ( renderTraitsEditor ( pc . traits || [], async ( traits ) => {
await api ( 'PATCH' , `/api/characters/ ${ pc . id } /traits` , { traits });
pc . traits = traits ;
await loadPlayerCharacters ();
}));
frag . appendChild ( traitCard );
// ── Delete ────────────────────────────────────────────────────────
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 ();
});
frag . appendChild ( delBtn );
// ── Inventory ────────────────────────────────────────────────────
frag . appendChild ( el ( `<h2 style="margin-top:1rem">Inventory</h2>` ));
frag . appendChild ( renderInventory ( pc ));
return frag ;
}
2026-07-03 16:00:54 +10:00
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' ) {
2026-07-03 21:44:11 +10:00
const personalityHint = el ( `<p style="font-size:0.85em;color:var(--muted,#888);margin-bottom:0.4rem"></p>` );
choiceBox . appendChild ( personalityHint );
let suggestedId = null ;
function renderChoiceButtons ( highlightId ) {
// remove old choice buttons (everything after the hint p)
[... choiceBox . children ]. filter ( c => c !== personalityHint ). forEach ( c => c . remove ());
scene . choices . forEach (( ch ) => {
const label = ch . skillPercent != null
? ` ${ ch . label } ( ${ ch . skill } ${ ch . skillPercent } %)`
: ch . label ;
const isHint = ch . id === highlightId ;
const btn = el ( `<button class="button ${ isHint ? ' primary' : '' } "> ${ escapeHtml ( label ) }${ isHint ? ' ★' : '' } </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 );
});
}
renderChoiceButtons ( null );
// Personality roll button — only shown if the adventuring PC has traits
const pc = state . playerCharacters . find ( c => c . id === ( state . adventure && state . adventure . characterId ));
if ( pc && pc . traits && pc . traits . length ) {
const rollTraitBtn = el ( `<button class="button" style="margin-top:0.4rem;font-size:0.85em">Roll Personality</button>` );
rollTraitBtn . addEventListener ( 'click' , async () => {
2026-07-03 16:00:54 +10:00
try {
2026-07-03 21:44:11 +10:00
const res = await api ( 'POST' , '/api/adventure/personality-roll' );
suggestedId = res . suggested ;
const fired = res . firedTraits . map ( t => ` ${ t . name } (rolled ${ t . roll } / ${ t . rating } %)` ). join ( ', ' );
if ( res . suggested ) {
const choiceLabel = scene . choices . find ( c => c . id === res . suggested ) ? . label || res . suggested ;
personalityHint . textContent = `Personality suggests: ${ choiceLabel }${ fired ? ` — ${ fired } ` : '' } ` ;
} else {
personalityHint . textContent = fired ? `Traits fired ( ${ fired } ) but no clear action.` : 'No traits fired — act freely.' ;
}
renderChoiceButtons ( suggestedId );
2026-07-03 16:00:54 +10:00
} catch ( err ) {
errBox . innerHTML = `<p class="error-message"> ${ escapeHtml ( err . message ) } </p>` ;
}
});
2026-07-03 21:44:11 +10:00
choiceBox . appendChild ( rollTraitBtn );
}
2026-07-03 16:00:54 +10:00
}
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 );
2026-07-03 21:44:11 +10:00
qs ( '#clear-all-btn' ). addEventListener ( 'click' , async () => {
if ( ! confirm ( 'Clear all characters, NPCs, enemies, combat, adventure, and log entries?\nTables and spell mappings are kept.\nThis cannot be undone.' )) return ;
await api ( 'POST' , '/api/clear-all' );
await Promise . all ([ loadNpcs (), loadEnemies (), loadCombat (), loadLog (), loadPlayerCharacters (), loadAdventure ()]);
state . selectedCharacterId = null ;
state . selectedNpcId = null ;
state . selectedEnemyId = null ;
state . characterInventory = [];
state . dirty = false ;
setView ( 'tables' );
});
2026-06-30 09:10:05 +10:00
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-07-03 21:44:11 +10:00
loadPersonalityTraits (),
2026-06-30 09:10:05 +10:00
]);
setView ( 'tables' );
renderLog ();
}
init ();