// 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))); } const traitsRow = el(`
    `); traitsRow.appendChild(renderTraitsEditor(npc.traits || [], async (traits) => { await api('PATCH', `/api/npcs/${npc.id}/traits`, { traits }); npc.traits = traits; })); card.appendChild(traitsRow); 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}
    ` : ''}
    `); } // ====================================================================== // 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 // ====================================================================== function renderCharactersMain() { const wrap = el(`
    `); // ---- 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 wrap.appendChild(el(`

    Character Generator

    `)); const wizardWrap = el(`
    `); wrap.appendChild(wizardWrap); // Progress bar const progressEl = el(`

    `); wizardWrap.appendChild(progressEl); const stepContent = el(`
    `); wizardWrap.appendChild(stepContent); 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 `${n}. ${l}`; }).join('  |  '); } // ---- Navigation helpers ---- function navRow(backFn, nextFn, nextLabel) { const row = el(`
    `); if (backFn) { const b = el(``); b.addEventListener('click', backFn); row.appendChild(b); } if (nextFn) { const n = el(``); n.addEventListener('click', nextFn); row.appendChild(n); } return row; } 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(`
    `); const nameIn = el(``); const genderRow = el(`
    `); ['M', 'F'].forEach((g) => { const lbl = el(``); genderRow.appendChild(lbl); }); const ageIn = el(``); const rollAgeBtn = el(``); 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 = `

    ${escapeHtml(err.message)}

    `; } }); const fields = [ ['Name', nameIn], ['Gender', genderRow], ['Age', el(``)], ]; fields.forEach(([label, inp]) => { const row = el(`
    `); row.appendChild(inp); stepContent.appendChild(row); }); // replace the placeholder age row with proper age row stepContent.lastChild.remove(); const ageRow = el(`
    `); const ageInputWrap = el(`
    `); 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 = `

    Name is required.

    `; return; } goToStep(2); })); } // ======== STEP 2 — Characteristics ======== function renderStep2() { const msg = el(`
    `); // Method picker const methodRow = el(`
    `); const methodSel = el(``); methodRow.appendChild(methodSel); stepContent.appendChild(methodRow); const statInputsWrap = el(`
    `); const derivedWrap = el(`
    `); const budgetDisplay = el(`

    `); 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(`
    `); CHAR_KEYS.forEach((k) => row.appendChild(el(`${CHAR_LABELS[k]}: ${chars[k]}`))); statInputsWrap.appendChild(row); return null; } 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) { 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('#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(`
    `); btnRow.append(minusBtn, plusBtn); cell.appendChild(btnRow); }); } return inputs; } 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)); } 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 = ''; } }); const btnRow = el(`
    `); const rollBtn = el(``); 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 = `

    ${escapeHtml(err.message)}

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

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

    `; 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 = `

    ${escapeHtml(err.message)}

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

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

    `; 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 = `

    ${escapeHtml(err.message)}

    `; } }); 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 = `

    Roll or calculate characteristics first.

    `; return; } goToStep(3); } )); } // ======== STEP 3 — Previous Experience ======== async function renderStep3() { const years = genState.age - 15; stepContent.appendChild(el(`

    Years of Experience: ${years} (age ${genState.age} − 15)

    `)); const msg = el(`
    `); const occResultWrap = el(`
    `); // Culture picker const cultureSel = el(``); const rollCultureBtn = el(``); 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(`
    `); const cultureCtrl = el(`
    `); cultureCtrl.append(rollCultureBtn, cultureSel); cultureRow.appendChild(cultureCtrl); stepContent.appendChild(cultureRow); // Occupation picker const occSel = el(``); const rollOccBtn = el(``); const occRow = el(`
    `); const occCtrl = el(`
    `); occCtrl.append(rollOccBtn, occSel); occRow.appendChild(occCtrl); stepContent.appendChild(occRow); function refreshOccupations() { const culture = cultureSel.value; occSel.innerHTML = ''; if (!culture || !OCCUPATION_LABELS[culture]) return; Object.entries(OCCUPATION_LABELS[culture]).forEach(([key, label]) => { occSel.appendChild(el(``)); }); } 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 = `

    ${escapeHtml(err.message)}

    `; } } 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 = `

    Please select culture and occupation.

    `; 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(`

    Choices Required

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

    Occupation Skills

    `); 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(`
    `); tbl.innerHTML = `SkillCategoryTotal %`; const tbody = el(``); Object.keys(grouped).sort().forEach((cat) => { grouped[cat].sort((a, b) => a.key.localeCompare(b.key)).forEach(({ key, val }) => { tbody.appendChild(el(`${escapeHtml(SKILL_DISPLAY_NAMES[key] || key)}${cat}${val}%`)); }); }); 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(`

    Weapon Skills

    `); weaponCard.appendChild(el(`

    Attack modifier: ${sign(attackMod)}%   Parry modifier: ${sign(parryMod)}%

    `)); // 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(``); const missileSel = el(``); const shieldSel = el(``); [['Primary weapon', primarySel], ['Missile weapon', missileSel], ['Parry weapon / shield', shieldSel]].forEach(([label, sel]) => { const row = el(`
    `); 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(`
    `); 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(`
    `); tbl.innerHTML = `WeaponAttack %Parry %`; const tbody = el(``); rows.forEach(({ name, attack, parry }) => { tbody.appendChild(el(` ${escapeHtml(name)} ${attack != null ? attack + '%' : '—'} ${parry != null ? parry + '%' : '—'} `)); }); tbl.appendChild(tbody); weaponTableWrap.appendChild(tbl); } updateWeaponTable(); occResultWrap.appendChild(weaponCard); // --- Ritual skills --- if (Object.keys(result.ritualBonuses).length) { const ritCard = el(`

    Ritual Skills

    `); Object.entries(result.ritualBonuses).forEach(([key, bonus]) => { ritCard.appendChild(el(`

    ${escapeHtml(key)}: +${bonus}% (${years} × ${bonus / years})

    `)); }); occResultWrap.appendChild(ritCard); } // --- Craft bonuses --- if (Object.keys(result.craftBonuses).length) { const craftCard = el(`

    Craft Skills

    `); Object.entries(result.craftBonuses).forEach(([key, bonus]) => { craftCard.appendChild(el(`

    ${escapeHtml(key)}: +${bonus}% (${years} × ${bonus / years})

    `)); }); occResultWrap.appendChild(craftCard); } // --- Magic --- if (occ.magic) { const magicCard = el(`

    Magic

    `); 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(`

    ${escapeHtml(magicText)}

    `)); occResultWrap.appendChild(magicCard); } // --- Equipment --- if (occ.equipment) { const eqCard = el(`

    Starting Equipment

    `); eqCard.appendChild(el(`

    ${escapeHtml(occ.equipment)}

    `)); occResultWrap.appendChild(eqCard); } } } // 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 }; } // ======== STEP 4 — Review & Save ======== function renderStep4() { const msg = el(`
    `); const years = genState.age - 15; const reviewCard = el(`
    `); reviewCard.appendChild(el(`

    ${escapeHtml(genState.name)}

    `)); // Identity summary reviewCard.appendChild(el(`

    Age: ${genState.age}   Gender: ${genState.gender}   Years exp: ${years}

    `)); // Characteristics summary if (genState.chars) { const row = el(`
    `); CHAR_KEYS.forEach((k) => row.appendChild(el(`${CHAR_LABELS[k]}: ${genState.chars[k]}`))); reviewCard.appendChild(row); } if (genState.derived) reviewCard.appendChild(renderDerivedStats(genState.derived)); // Culture / Occupation reviewCard.appendChild(el(`

    Culture: ${escapeHtml(genState.culture || '—')}   Occupation: ${escapeHtml(genState.occupation ? ((OCCUPATION_LABELS[genState.culture] || {})[genState.occupation] || genState.occupation) : '—')}

    `)); // Choices if (Object.keys(genState.choiceSelections).length) { const choiceP = el(`

    Choices: ${Object.entries(genState.choiceSelections).map(([g, k]) => `${g}: ${k}`).join('; ')}

    `); reviewCard.appendChild(choiceP); } stepContent.appendChild(reviewCard); stepContent.appendChild(msg); const saveBtn = el(``); stepContent.appendChild(saveBtn); stepContent.appendChild(navRow(() => goToStep(3), null)); saveBtn.addEventListener('click', async () => { if (!genState.chars) { msg.innerHTML = `

    Missing characteristics.

    `; 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; } // 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' }); } 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 = `

    Saved "${escapeHtml(genState.name)}"! Starting a new character...

    `; 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 = `

    ${escapeHtml(err.message)}

    `; } }); } // Start wizard updateProgress(); renderStep1(); // --- Selected PC view --- const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId); if (pc) { wrap.appendChild(renderCharacterSheet(pc)); } return wrap; } // ---------- 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(`
    `); const listWrap = el(`
    `); let current = [...(traits || [])]; function redraw() { listWrap.innerHTML = ''; current.forEach((t, i) => { const pill = el(``); const nameSpan = el(`${escapeHtml(t.name)}`); const ratingIn = el(``); 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(``); removeBtn.addEventListener('click', async () => { current.splice(i, 1); await saveFn(current); redraw(); }); pill.append(nameSpan, el(`:`), ratingIn, el(`%`), removeBtn); listWrap.appendChild(pill); }); } // Add trait form const suggestions = _knownTraits.filter(n => !current.find(t => t.name === n)); const traitSel = el(``); const customIn = el(``); traitSel.addEventListener('change', () => { if (traitSel.value) customIn.value = traitSel.value; }); const ratingIn = el(``); const addBtn = el(``); 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(`
    `); formRow.append(traitSel, customIn, ratingIn, el(`%`), addBtn); redraw(); wrap.append(listWrap, formRow); return wrap; } function renderCharacterSheet(pc) { const frag = el(`
    `); 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(`

    ${escapeHtml(pc.name)}

    `)); const identCard = el(`
    `); const identRow = el(`
    `); if (pc.age) identRow.appendChild(el(`Age: ${pc.age}`)); if (pc.culture) identRow.appendChild(el(`Culture: ${escapeHtml(pc.culture)}`)); if (pc.occupation_label || pc.occupation) identRow.appendChild(el(`Occupation: ${escapeHtml(pc.occupation_label || pc.occupation)}`)); identCard.appendChild(identRow); // Location tracker const locRow = el(`
    `); const locInput = el(``); const destInput = el(``); 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(`Location:`), locInput, el(``), destInput); identCard.appendChild(locRow); frag.appendChild(identCard); // ── Characteristics & derived ──────────────────────────────────── const statsCard = el(`
    `); const charRow = el(`
    `); CHAR_KEYS.forEach((k) => { charRow.appendChild(el(`
    ${CHAR_LABELS[k]}
    ${sb[k]}
    `)); }); statsCard.appendChild(charRow); const derivedRow = el(`
    `); [ ['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(`${label}: ${val}`))); statsCard.appendChild(derivedRow); const modRow = el(`
    `); [['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(`${label} ${sign(v)}%`)); }); 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(`
    `); skillsCard.appendChild(el(`Skills`)); const grid = el(`
    `); for (const [, { label, keys }] of Object.entries(SKILL_DISPLAY)) { const section = el(`
    `); section.appendChild(el(`
    ${label}
    `)); keys.forEach((k) => { const val = skills[k]; if (!val && val !== 0) return; section.appendChild(el(`
    ${SKILL_NAMES[k] || k}${pct(val)}
    `)); }); // 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(`
    ${name}${pct(5 + bonus)}
    `)); }); } if (section.children.length > 1) grid.appendChild(section); } skillsCard.appendChild(grid); frag.appendChild(skillsCard); } // ── Weapons ────────────────────────────────────────────────────── if (sb.weapons && sb.weapons.length) { const weapCard = el(`
    `); weapCard.appendChild(el(`Weapons`)); const tbl = el(`
    `); tbl.innerHTML = ` Weapon Atk% Par% Mode `; const tbody = el(``); sb.weapons.forEach((w) => { const row = el(` ${escapeHtml(w.weapon_name)} ${w.skill_percent}% ${w.parry_percent ? w.parry_percent + '%' : '—'} ${escapeHtml(w.mode || w.category || '')} `); 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(`
    `); locCard.appendChild(el(`Hit Locations`)); const tbl = el(`
    `); tbl.innerHTML = ` Location HP Armour AP `; const tbody = el(``); sb.hit_locations.forEach((l) => { tbody.appendChild(el(` ${escapeHtml(l.location_name)} ${l.current_hp}/${l.max_hp} ${l.armor_ap || 0} `)); }); tbl.appendChild(tbody); locCard.appendChild(tbl); frag.appendChild(locCard); } // ── Personality Traits ─────────────────────────────────────────── const traitCard = el(`
    `); traitCard.appendChild(el(`Personality Traits`)); 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(``); 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(`

    Inventory

    `)); frag.appendChild(renderInventory(pc)); return frag; } 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') { const personalityHint = el(`

    `); 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(``); 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); }); } 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(``); rollTraitBtn.addEventListener('click', async () => { try { 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); } catch (err) { errBox.innerHTML = `

    ${escapeHtml(err.message)}

    `; } }); choiceBox.appendChild(rollTraitBtn); } } 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('#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'); }); 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(), loadPersonalityTraits(), ]); setView('tables'); renderLog(); } init();