// Frontend logic only. No RQ3 rules calculations happen here - everything goes through /api/*. const SPELL_MECHANIC_IDS = ['bladesharp', 'protection', 'heal', 'disruption', 'demoralize', 'coordination']; const state = { view: 'tables', tablesTree: [], expandedNodes: new Set(), selectedTableId: null, selectedTable: null, lastRoll: null, manualSelectOpen: false, npcs: [], selectedNpcId: null, enemies: [], selectedEnemyId: null, combat: null, reactionType: 'none', spellMappings: [], logEntries: [], dirty: false, attackModifiers: [], armorTable: [], selectedModifierIds: new Set(), }; // ---------- low-level helpers ---------- function qs(sel, root = document) { return root.querySelector(sel); } function el(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function md(text) { if (!text) return ''; // parseInline (not parse) - these are short flavor-text fragments rendered inside our // own wrapper elements; parse()'s block-level

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 loadLog(search = '') { state.logEntries = await api('GET', `/api/log${search ? `?search=${encodeURIComponent(search)}` : ''}`); } async function loadAttackModifiers() { state.attackModifiers = await api('GET', '/api/rules/attack-modifiers'); } async function loadArmorTable() { state.armorTable = await api('GET', '/api/rules/armor-table'); } // ---------- top-level render ---------- function setView(view) { state.view = view; document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view)); renderSidebar(); renderMain(); } function renderSidebar() { const root = qs('#sidebar-content'); root.innerHTML = ''; if (state.view === 'tables') root.appendChild(renderTablesSidebar()); if (state.view === 'npcs') root.appendChild(renderNpcsSidebar()); if (state.view === 'enemies') root.appendChild(renderEnemiesSidebar()); if (state.view === 'combat') root.appendChild(renderCombatSidebar()); } function renderMain() { const root = qs('#main-panel'); root.innerHTML = ''; if (state.view === 'tables') root.appendChild(renderTablesMain()); if (state.view === 'npcs') root.appendChild(renderNpcsMain()); if (state.view === 'enemies') root.appendChild(renderEnemiesMain()); if (state.view === 'combat') root.appendChild(renderCombatMain()); } // ====================================================================== // TABLES // ====================================================================== function renderTreeNode(node) { const hasChildren = node.children && node.children.length > 0; const expanded = state.expandedNodes.has(node.id); const wrap = el(`

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