// 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: [], playerCharacters: [], selectedCharacterId: null, characterInventory: [], adventure: null, 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

wrapping causes nested-

auto-close // bugs when our wrapper is also a

. 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 `${tier}`; } // ---------- load ---------- async function loadTablesTree() { state.tablesTree = await api('GET', '/api/tables/tree'); } async function loadNpcs() { state.npcs = await api('GET', '/api/npcs'); } async function loadEnemies() { state.enemies = await api('GET', '/api/enemies'); } async function loadCombat() { state.combat = await api('GET', '/api/combat'); } async function loadSpellMappings() { state.spellMappings = await api('GET', '/api/spell-mappings'); } async function 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'); } 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()); if (state.view === 'characters') root.appendChild(renderCharactersSidebar()); if (state.view === 'adventure') root.appendChild(renderAdventureSidebar()); } 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()); if (state.view === 'characters') root.appendChild(renderCharactersMain()); if (state.view === 'adventure') root.appendChild(renderAdventureMain()); } // ====================================================================== // TABLES // ====================================================================== function renderTreeNode(node) { const hasChildren = node.children && node.children.length > 0; const expanded = state.expandedNodes.has(node.id); const wrap = el(`

`); const heading = el(`
${hasChildren ? (expanded ? '▾' : '▸') : ''} ${escapeHtml(node.heading_text)} ${node.table ? `[${node.table.dice_notation || '?'}]` : ''}
`); 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(`
`); node.children.forEach((c) => childWrap.appendChild(renderTreeNode(c))); wrap.appendChild(childWrap); } return wrap; } function renderTablesSidebar() { const wrap = el(`
`); if (!state.tablesTree.length) { wrap.appendChild(el(`

No tables imported.

`)); 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(`
`); box.appendChild(el(`
${escapeHtml(result.table.name)}
`)); const cellsWrap = el(`
`); result.row.cells.forEach((cell, idx) => { const colName = result.table.columns[idx] ? result.table.columns[idx].column_name : ''; cellsWrap.appendChild(el(`

${colName ? `${escapeHtml(colName)}: ` : ''}${md(cell)}

`)); }); box.appendChild(cellsWrap); if (result.links && result.links.length) { const linkWrap = el(`
`); result.links.forEach((link) => { const btn = el(``); btn.addEventListener('click', () => selectTable(link.target_table_id).then(rollSelectedTable)); linkWrap.appendChild(btn); }); box.appendChild(linkWrap); } return box; } function renderTablesMain() { const wrap = el(`
`); if (!state.selectedTable) { wrap.appendChild(el(`

Select a table from the tree to roll on it.

`)); return wrap; } const table = state.selectedTable; wrap.appendChild(el(`

${escapeHtml(table.name)}

`)); const actions = el(`
`); const rollBtn = el(``); rollBtn.addEventListener('click', rollSelectedTable); const rerollBtn = el(``); rerollBtn.addEventListener('click', rollSelectedTable); const manualBtn = el(``); 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(`

All entries

`); table.rows.forEach((row) => { const item = el(`
${escapeHtml(row.roll_min === row.roll_max ? String(row.roll_min) : `${row.roll_min}-${row.roll_max}`)}${md(row.cells.join(' / '))}
`); item.addEventListener('click', () => manualSelectRow(row.id)); list.appendChild(item); }); wrap.appendChild(list); } return wrap; } // ====================================================================== // NPCS // ====================================================================== function renderNpcsSidebar() { const wrap = el(`
`); const actions = el(`
`); const fullBtn = el(``); fullBtn.addEventListener('click', () => generateNpc('full')); const fillerBtn = el(``); fillerBtn.addEventListener('click', () => generateNpc('filler')); actions.append(fullBtn, fillerBtn); wrap.appendChild(actions); const list = el(``); state.npcs.forEach((npc) => { const name = `${npc.first_name || '(unnamed)'} ${npc.last_name || ''}`.trim(); const li = el(`
  • ${escapeHtml(name)}${npc.status}
  • `); 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(`
    `); const valueWrap = el(`
    `); const span = el(`${md(npc[key]) || ''}`); 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(``); btn.addEventListener('click', () => rerollNpcField(key)); valueWrap.appendChild(btn); } row.appendChild(valueWrap); return row; } function renderNpcsMain() { const wrap = el(`
    `); const npc = state.npcs.find((n) => n.id === state.selectedNpcId); if (!npc) { wrap.appendChild(el(`

    Generate or select an NPC.

    `)); return wrap; } const card = el(`
    `); card.appendChild(el(`

    ${escapeHtml(`${npc.first_name || ''} ${npc.last_name || ''}`.trim() || '(unnamed)')} ${npc.npc_type}

    `)); const statusRow = el(`
    `); ['active', 'dead', 'inactive'].forEach((s) => { const b = el(``); b.addEventListener('click', () => setNpcStatus(s)); statusRow.appendChild(b); }); const delBtn = el(``); delBtn.addEventListener('click', deleteSelectedNpc); statusRow.appendChild(delBtn); card.appendChild(statusRow); NPC_CORE_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true))); if (npc.npc_type === 'full') { NPC_ATTR_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true))); } wrap.appendChild(card); const sbCard = el(`

    Stat Block

    `); if (!npc.stat_block) { const attachBtn = el(``); 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(`
    `); const grid = el(`
    `); ['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((c) => { grid.appendChild(el(`
    ${c}
    ${sb[c]}
    `)); }); wrap.appendChild(grid); wrap.appendChild(el(`

    HP ${sb.current_hp}/${sb.max_hp}   MP ${sb.magic_points_current}/${sb.magic_points_max}   Move ${sb.move}${sb.culture ? `   ${escapeHtml(sb.culture)}` : ''}

    `)); const table = el(`
    LocationHPAP
    `); const tbody = el(``); (sb.hit_locations || []).forEach((loc) => { tbody.appendChild(el(`${loc.location_name}${loc.current_hp}/${loc.max_hp}${loc.armor_ap}`)); }); table.appendChild(tbody); wrap.appendChild(table); if ((sb.weapons || []).length) { sb.weapons.forEach((w) => { wrap.appendChild(el(`
    ${escapeHtml(w.weapon_name)}${w.skill_percent}%${w.mode ? `${w.mode}` : ''}
    `)); }); } if ((sb.spells || []).length) { sb.spells.forEach((s) => { wrap.appendChild(el(`
    ${escapeHtml(s.custom_name)}${s.mechanic_id}
    `)); }); } return wrap; } // ====================================================================== // ENEMIES // ====================================================================== function renderEnemiesSidebar() { const wrap = el(`
    `); const genBtn = el(``); 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(``); state.enemies.forEach((enemy) => { const li = el(`
  • ${escapeHtml(enemy.name)}${enemy.stat_block.current_hp}/${enemy.stat_block.max_hp}
  • `); 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(`
    `); const enemy = state.enemies.find((e) => e.id === state.selectedEnemyId); if (!enemy) { wrap.appendChild(el(`

    Generate or select an enemy.

    `)); return wrap; } const card = el(`
    `); card.appendChild(el(`

    ${escapeHtml(enemy.name)} ${enemy.category || ''}

    `)); const delBtn = el(``); delBtn.addEventListener('click', deleteSelectedEnemy); card.appendChild(delBtn); card.appendChild(renderStatBlockReadout(enemy.stat_block)); const weaponForm = el(`

    Add Weapon

    `); const wName = el(``); const wCat = el(``); const wSkill = el(``); const wAdd = el(``); const wBonusBtn = el(``); const wBonusResult = el(``); 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(`

    Apply Armor Type

    `); const armorSelect = el(``); const armorApply = el(``); 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(`

    Add Known Spell

    `); const spellSelect = el(``); state.spellMappings.forEach((sm) => spellSelect.appendChild(el(``))); const spellAdd = el(``); 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(`
    `); const addWrap = el(`
    `); const select = el(``); select.appendChild(el(``)); state.npcs.filter((n) => n.stat_block).forEach((n) => select.appendChild(el(``))); state.enemies.forEach((e) => select.appendChild(el(``))); const addBtn = el(``); 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(``); combatants().forEach((c) => { list.appendChild(el(`
  • ${escapeHtml(c.name)} (SR ${c.strikeRank})${c.currentHp}/${c.maxHp} ${c.status}
  • `)); }); wrap.appendChild(list); if (combatants().length) { const endBtn = el(``); 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) => ``).join(''); } function renderStuckWeaponFollowUp({ attackerCombatantId, defenderCombatantId, weaponName, kind }) { const wrap = el(`
    `); wrap.appendChild(el(`

    Weapon stuck (${kind}). Choose removal attempt:

    `)); const removalResult = el(`
    `); 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 = `

    ${escapeHtml(outcome)}

    `; await loadLog(); renderSidebar(); renderLog(); } catch (err) { removalResult.innerHTML = `

    ${escapeHtml(err.message)}

    `; } } const attackerBtn = el(``); attackerBtn.addEventListener('click', () => doRemoval('attacker')); const selfBtn = el(``); selfBtn.addEventListener('click', () => doRemoval('self')); const faSkill = el(``); const faBtn = el(``); faBtn.addEventListener('click', () => doRemoval('first-aid', { firstAidSkillPercent: Number(faSkill.value) || 0 })); const btnRow = el(`
    `); btnRow.append(attackerBtn, selfBtn, faSkill, faBtn); wrap.appendChild(btnRow); wrap.appendChild(removalResult); return wrap; } function renderCombatMain() { const wrap = el(`
    `); if (combatants().length < 1) { wrap.appendChild(el(`

    Add combatants from the sidebar to begin.

    `)); return wrap; } wrap.appendChild(el(`

    Attack

    `)); const form = el(`
    `); const attackerSel = el(``); const defenderSel = el(``); const weaponSel = el(``); const modeSel = el(``); const kindSel = el(``); const thrownChk = el(` thrown`); function refreshWeapons() { const attacker = combatants().find((c) => c.id === attackerSel.value) || combatants()[0]; weaponSel.innerHTML = ''; (attacker ? attacker.weapons : []).forEach((w) => weaponSel.appendChild(el(``))); } attackerSel.addEventListener('change', refreshWeapons); refreshWeapons(); const reactionType = el(``); const reactionSkill = el(``); const reactionWeapon = el(``); const modifiersWrap = el(`
    `); const modifierChecks = state.attackModifiers.map((mod) => el(`
    ${escapeHtml(mod.description)} (${mod.modifier > 0 ? '+' : ''}${mod.modifier}${mod.perSiz ? ` per ${mod.perSiz} SIZ` : ''})
    `)); modifierChecks.forEach((f) => modifiersWrap.appendChild(f)); const resolveBtn = el(``); const resultBox = el(`
    `); 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; resultBox.innerHTML = ''; const summary = el(`

    ${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}` : ''})

    ${res.result.hitLocationRoll ? `

    Hit location: ${res.result.hitLocationRoll.location}

    ` : ''} ${res.result.damageThrough != null ? `

    Damage through: ${res.result.damageThrough}

    ` : ''} ${res.result.fumble ? `

    Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}

    ` : ''}
    `); 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); } await loadLog(); renderSidebar(); renderLog(); } catch (err) { resultBox.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); [ ['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(`
    `); if (label) row.appendChild(el(``)); row.appendChild(input); form.appendChild(row); }); form.appendChild(modifiersWrap); form.appendChild(resolveBtn); form.appendChild(resultBox); wrap.appendChild(form); wrap.appendChild(el(`

    Cast Spell

    `)); const spellForm = el(`
    `); const casterSel = el(``); const targetSel = el(``); const mechanicSel = el(``); const mpInput = el(``); const castBtn = el(``); const castResult = el(`
    `); 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 = `

    ${escapeHtml(JSON.stringify(res.result))}

    `; await loadLog(); renderSidebar(); renderLog(); } catch (err) { castResult.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); [['Caster', casterSel], ['Target', targetSel], ['Spell Mechanic', mechanicSel], ['MP Spent', mpInput]].forEach(([label, input]) => { const row = el(`
    `); 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(`
    ${entry.type} · ${entry.created_at}
    ${escapeHtml(entry.summary)}
    `)); }); } 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; } // ---------- 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(`
    `); wrap.appendChild(el(`

    Saved Characters

    `)); if (!state.playerCharacters.length) { wrap.appendChild(el(`

    No characters yet.

    `)); return wrap; } state.playerCharacters.forEach((pc) => { const btn = el(``); btn.addEventListener('click', () => { state.selectedCharacterId = pc.id; renderSidebar(); renderMain(); }); wrap.appendChild(btn); }); return wrap; } function renderDerivedStats(derived) { const locs = derived.hitLocations.map((l) => `${escapeHtml(l.location_name)}${l.max_hp}` ).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]) => `${name}${v != null ? sign(v) : '—'}%`).join(''); return el(`

    HP: ${derived.totalHp}   FP: ${derived.fatigue ?? '—'}   MP: ${derived.magicPoints}   DB: ${escapeHtml(derived.damageBonus)}   SR: ${derived.strikeRank}

    ${sm.attack != null ? `

    Attack bonus: ${sign(sm.attack)}%   Parry bonus: ${sign(sm.parry)}%

    ` : ''}
    Hit Locations ${locs}
    LocationHP
    ${modRows ? `
    Skill Modifiers ${modRows}
    ` : ''}
    `); } function renderCharactersMain() { const wrap = el(`
    `); // --- Generator section --- wrap.appendChild(el(`

    Character Generator

    `)); const genCard = el(`
    `); // Method picker const methodRow = el(`
    `); const methodSel = el(``); methodRow.appendChild(methodSel); genCard.appendChild(methodRow); const statInputsWrap = el(`
    `); const derivedWrap = el(`
    `); const saveWrap = el(`
    `); const genMsg = el(`
    `); // Budget tracker for deliberate const budgetDisplay = el(`

    `); let currentChars = null; function buildStatInputs(readOnly, chars) { statInputsWrap.innerHTML = ''; budgetDisplay.style.display = 'none'; if (readOnly) { const row = el(`
    `); CHAR_KEYS.forEach((k) => { row.appendChild(el(`${CHAR_LABELS[k]}: ${chars[k]}`)); }); statInputsWrap.appendChild(row); return null; } // Editable inputs const inputs = {}; const grid = el(`
    `); CHAR_KEYS.forEach((k) => { const cell = el(`
    `); const inp = el(``); inputs[k] = inp; cell.appendChild(inp); grid.appendChild(cell); }); statInputsWrap.appendChild(grid); if (methodSel.value === 'deliberate') { budgetDisplay.style.display = ''; function updateBudget() { const total = CHAR_KEYS.reduce((s, k) => s + (Number(inputs[k].value) || 0), 0); const rem = 80 - total; budgetDisplay.textContent = `Points used: ${total}/80 (${rem >= 0 ? rem + ' remaining' : Math.abs(rem) + ' over'})`; budgetDisplay.style.color = rem === 0 ? 'var(--success,green)' : rem < 0 ? 'red' : 'inherit'; } CHAR_KEYS.forEach((k) => inputs[k].addEventListener('input', updateBudget)); updateBudget(); } if (methodSel.value === 'combined' && chars) { // Show +/- buttons with bonus point tracking let bonusLeft = 6; const bonusLabel = el(`

    Bonus points remaining: 6

    `); 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(``); const minusBtn = el(``); plusBtn.addEventListener('click', () => { if (bonusLeft <= 0 || Number(inputs[k].value) >= 18) return; inputs[k].value = Number(inputs[k].value) + 1; bonusLeft--; bonusLabel.querySelector('#bonus-left').textContent = bonusLeft; }); minusBtn.addEventListener('click', () => { if (Number(inputs[k].value) <= base) return; inputs[k].value = Number(inputs[k].value) - 1; bonusLeft++; bonusLabel.querySelector('#bonus-left').textContent = bonusLeft; }); // attach buttons next to each input const cell = inputs[k].parentElement; const btnRow = el(`
    `); btnRow.append(minusBtn, plusBtn); cell.appendChild(btnRow); }); } return inputs; } let activeInputs = null; function showRolledResult(chars, derived, bonusPoints) { currentChars = chars; if (bonusPoints > 0) { activeInputs = buildStatInputs(false, chars); } else { buildStatInputs(true, chars); activeInputs = null; } derivedWrap.innerHTML = ''; derivedWrap.appendChild(renderDerivedStats(derived)); saveWrap.style.display = ''; genMsg.innerHTML = ''; } methodSel.addEventListener('change', () => { statInputsWrap.innerHTML = ''; derivedWrap.innerHTML = ''; saveWrap.style.display = 'none'; genMsg.innerHTML = ''; budgetDisplay.style.display = 'none'; currentChars = null; activeInputs = null; if (methodSel.value === 'deliberate') { activeInputs = buildStatInputs(false, null); } }); const rollBtn = el(``); const calcBtn = el(``); methodSel.addEventListener('change', () => { rollBtn.style.display = methodSel.value === 'deliberate' ? 'none' : ''; calcBtn.style.display = methodSel.value === 'deliberate' ? '' : 'none'; }); rollBtn.addEventListener('click', async () => { try { const res = await api('POST', '/api/characters/roll', { method: methodSel.value }); showRolledResult(res.chars, res.derived, res.bonusPoints); } catch (err) { genMsg.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); calcBtn.addEventListener('click', async () => { const chars = {}; CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; }); try { const val = await api('POST', '/api/characters/validate', { method: 'deliberate', chars }); if (!val.valid) { genMsg.innerHTML = `

    ${val.errors.map(escapeHtml).join('
    ')}

    `; return; } const res = await api('POST', '/api/characters/derive', chars); currentChars = chars; derivedWrap.innerHTML = ''; derivedWrap.appendChild(renderDerivedStats(res.derived)); saveWrap.style.display = ''; genMsg.innerHTML = ''; } catch (err) { genMsg.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); // Apply combined bonus button const applyBonusBtn = el(``); methodSel.addEventListener('change', () => { applyBonusBtn.style.display = methodSel.value === 'combined' ? '' : 'none'; }); applyBonusBtn.addEventListener('click', async () => { if (!activeInputs) return; const chars = {}; CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; }); try { const val = await api('POST', '/api/characters/validate', { method: 'combined', chars }); if (!val.valid) { genMsg.innerHTML = `

    ${val.errors.map(escapeHtml).join('
    ')}

    `; return; } const res = await api('POST', '/api/characters/derive', chars); currentChars = chars; derivedWrap.innerHTML = ''; derivedWrap.appendChild(renderDerivedStats(res.derived)); saveWrap.style.display = ''; genMsg.innerHTML = ''; } catch (err) { genMsg.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); // Save section const nameInput = el(``); const saveBtn = el(``); saveWrap.append(nameInput, saveBtn); saveBtn.addEventListener('click', async () => { const name = nameInput.value.trim(); if (!name) { genMsg.innerHTML = `

    Name is required.

    `; return; } if (!currentChars) return; const chars = activeInputs ? Object.fromEntries(CHAR_KEYS.map((k) => [k, Number(activeInputs[k].value) || 0])) : currentChars; try { await api('POST', '/api/characters', { name, generation_method: methodSel.value, chars }); await loadPlayerCharacters(); genMsg.innerHTML = `

    Saved "${escapeHtml(name)}".

    `; nameInput.value = ''; renderSidebar(); } catch (err) { genMsg.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); const btnRow = el(`
    `); btnRow.append(rollBtn, calcBtn, applyBonusBtn); genCard.append(statInputsWrap, budgetDisplay, btnRow, derivedWrap, saveWrap, genMsg); wrap.appendChild(genCard); // --- Selected PC view --- const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId); if (pc) { wrap.appendChild(el(`

    ${escapeHtml(pc.name)}

    `)); const pcCard = el(`
    `); const sb = pc.stat_block; const statsRow = el(`
    `); ['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((k) => { statsRow.appendChild(el(`${CHAR_LABELS[k]}: ${sb[k]}`)); }); pcCard.appendChild(statsRow); pcCard.appendChild(el(`

    HP: ${sb.current_hp}/${sb.max_hp}   MP: ${sb.magic_points_current}/${sb.magic_points_max}

    `)); if (sb.hit_locations && sb.hit_locations.length) { const locs = sb.hit_locations.map((l) => `${escapeHtml(l.location_name)}${l.current_hp}/${l.max_hp}`).join(''); pcCard.appendChild(el(`${locs}
    LocationHP
    `)); } const delBtn = el(``); delBtn.addEventListener('click', async () => { if (!confirm(`Delete "${pc.name}"?`)) return; await api('DELETE', `/api/characters/${pc.id}`); state.selectedCharacterId = null; await loadPlayerCharacters(); renderSidebar(); renderMain(); }); pcCard.appendChild(delBtn); wrap.appendChild(pcCard); // --- Inventory --- wrap.appendChild(el(`

    Inventory

    `)); wrap.appendChild(renderInventory(pc)); } return wrap; } function renderInventory(pc) { const wrap = el(`
    `); const listWrap = el(`
    `); const encDisplay = el(`

    `); const errMsg = el(`
    `); async function refresh() { const res = await loadInventory(pc.id); listWrap.innerHTML = ''; if (!state.characterInventory.length) { listWrap.appendChild(el(`

    No items.

    `)); } else { const tbl = el(`
    `); tbl.innerHTML = ` Item Category Qty ENC `; const tbody = el(``); state.characterInventory.forEach((item) => { const row = el(``); const notesTip = item.notes ? ` — ${escapeHtml(item.notes)}` : ''; row.innerHTML = ` ${escapeHtml(item.name)}${notesTip ? `${notesTip}` : ''} ${escapeHtml(item.category)} ${item.quantity} ${(item.enc * item.quantity).toFixed(1)} `; const removeBtn = el(``); removeBtn.addEventListener('click', async () => { await api('DELETE', `/api/characters/${pc.id}/inventory/${item.id}`); await refresh(); }); const td = el(``); 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 = `Total ENC carried: ${totalEnc.toFixed(1)}   Effective FP: ${effectiveFp.toFixed(1)} / ${fp}`; } // Add item form const nameIn = el(``); const qtyIn = el(``); const encIn = el(``); const catSel = el(``); const notesIn = el(``); const addBtn = el(``); 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 = `

    ${escapeHtml(err.message)}

    `; } }); const formRow = el(`
    `); formRow.append(nameIn, qtyIn, encIn, catSel, notesIn, addBtn); wrap.append(encDisplay, listWrap, formRow, errMsg); refresh(); return wrap; } // ---------- Adventure ---------- function renderAdventureSidebar() { const wrap = el(`
    `); wrap.appendChild(el(`

    Characters

    `)); if (!state.playerCharacters.length) { wrap.appendChild(el(`

    Create a character first.

    `)); return wrap; } state.playerCharacters.forEach((pc) => { const active = state.adventure && state.adventure.characterId === pc.id; const btn = el(``); btn.addEventListener('click', () => { state.selectedCharacterId = pc.id; renderSidebar(); renderMain(); }); wrap.appendChild(btn); }); return wrap; } function renderAdventureMain() { const wrap = el(`
    `); wrap.appendChild(el(`

    Adventure

    `)); const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId) || state.playerCharacters[0]; if (!pc) { wrap.appendChild(el(`

    Create and select a character to begin adventuring.

    `)); return wrap; } const sceneCard = el(`
    `); const sceneBox = el(`
    `); const choiceBox = el(`
    `); const outcomeBox = el(`
    `); const errBox = el(`
    `); // 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(``); const startBtn = el(``); const clearBtn = el(``); function renderScene(adventureState) { sceneBox.innerHTML = ''; choiceBox.innerHTML = ''; outcomeBox.innerHTML = ''; if (!adventureState) return; const scene = adventureState.scene; sceneBox.appendChild(el(`

    ${escapeHtml(scene.description)}

    `)); if (scene.effectiveFp != null) { sceneBox.appendChild(el(`

    Effective FP: ${scene.effectiveFp}

    `)); } if (scene.status === 'active') { scene.choices.forEach((ch) => { const label = ch.skillPercent != null ? `${ch.label} (${ch.skill} ${ch.skillPercent}%)` : ch.label; const btn = el(``); 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 = `

    ${escapeHtml(err.message)}

    `; } }); choiceBox.appendChild(btn); }); } if (scene.resolution) { const r = scene.resolution; let html = `

    ${escapeHtml(scene.choices.find(c => c.id === scene.selectedChoice)?.label || scene.selectedChoice)}`; if (r.roll != null) html += ` — rolled ${r.roll} vs ${r.skillPercent}% (${r.tier})`; html += `

    ${escapeHtml(r.outcome)}

    `; if (scene.status === 'combat') { html += `

    Set up the combat encounter in the Combat tab.

    `; } if (scene.status === 'escalated') { html += `

    The situation is now hostile — fight or flee!

    `; // Offer fight button const fightBtn = el(``); fightBtn.addEventListener('click', async () => { await api('POST', '/api/adventure/choose', { choiceId: 'fight' }); setView('combat'); }); outcomeBox.appendChild(el(`
    ${html}
    `)); outcomeBox.appendChild(fightBtn); return; } outcomeBox.innerHTML = html; if (scene.status === 'resolved' || scene.status === 'combat') { const nextBtn = el(``); 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 = `

    ${escapeHtml(err.message)}

    `; } }); clearBtn.addEventListener('click', async () => { await api('DELETE', '/api/adventure'); state.adventure = null; renderScene(null); sceneBox.innerHTML = ''; }); const ctrlRow = el(`
    `); 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; } // ---------- export reminder modal ---------- let pendingLeaveConfirmed = false; window.addEventListener('beforeunload', (e) => { if (state.dirty && !pendingLeaveConfirmed) { e.preventDefault(); e.returnValue = ''; } }); // ---------- init ---------- async function init() { document.querySelectorAll('.tab-btn').forEach((btn) => btn.addEventListener('click', () => setView(btn.dataset.view))); qs('#export-all-btn').addEventListener('click', exportAllData); qs('#export-log-btn').addEventListener('click', exportLogData); qs('#import-file').addEventListener('change', (e) => { if (e.target.files[0]) importFile(e.target.files[0]); e.target.value = ''; }); qs('#log-search').addEventListener('input', (e) => searchLogAndRender(e.target.value)); qs('#note-submit').addEventListener('click', addNote); await Promise.all([ loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(), loadAttackModifiers(), loadArmorTable(), loadPlayerCharacters(), loadAdventure(), ]); setView('tables'); renderLog(); } init();