Files
story_builder/public/app.js
stefwill 8bccf6f484 feat: inventory system, adventure scene engine, and PC generator fixes
- Inventory: items linked to PCs with name, qty, ENC, category; effective
  FP = STR+CON minus total ENC carried; CRUD API + inline UI on Characters tab
- Adventure scene engine: new tab with procedural CYOA choices (Fight, Sneak,
  Talk, Investigate, Flee) resolved via skill checks; scenes generated from
  any rollable table with automatic L2 cascade; scene state persisted in DB
- Fix total HP formula: was CON+SIZ, now correctly ceil((CON+SIZ)/2) per RQ3
- Fix hit location HPs: were computed from wrong total HP, now correct
- Add fatigue points (STR+CON) to derived stats
- Add all seven skill category modifiers computed from characteristics
  (primary/secondary/negative influences per rulebook), shown in generator UI
- Add base skill computation (computeBaseSkills) for use in scene engine
- Add RQ3 Players Book to docs/ as reference
2026-07-03 16:00:54 +10:00

1433 lines
57 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function md(text) {
if (!text) return '';
// parseInline (not parse) - these are short flavor-text fragments rendered inside our
// own wrapper elements; parse()'s block-level <p> wrapping causes nested-<p> auto-close
// bugs when our wrapper is also a <p>.
return window.marked ? window.marked.parseInline(text) : escapeHtml(text);
}
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 204) return null;
let data = null;
try { data = await res.json(); } catch (e) { /* no body */ }
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
state.dirty = true;
return data;
}
function tierTag(tier) {
return `<span class="tag tier-${tier}">${tier}</span>`;
}
// ---------- load ----------
async function loadTablesTree() { state.tablesTree = await api('GET', '/api/tables/tree'); }
async function loadNpcs() { state.npcs = await api('GET', '/api/npcs'); }
async function loadEnemies() { state.enemies = await api('GET', '/api/enemies'); }
async function loadCombat() { state.combat = await api('GET', '/api/combat'); }
async function loadSpellMappings() { state.spellMappings = await api('GET', '/api/spell-mappings'); }
async function 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(`<div class="tree-node"></div>`);
const heading = el(`
<div class="tree-heading ${node.table && state.selectedTableId === node.table.id ? 'selected' : ''}" data-node-id="${node.id}">
<span class="tree-toggle">${hasChildren ? (expanded ? '▾' : '▸') : ''}</span>
<span>${escapeHtml(node.heading_text)}</span>
${node.table ? `<span class="tree-table-icon">[${node.table.dice_notation || '?'}]</span>` : ''}
</div>
`);
heading.addEventListener('click', () => {
if (node.table) selectTable(node.table.id);
if (hasChildren) {
if (expanded) state.expandedNodes.delete(node.id); else state.expandedNodes.add(node.id);
renderSidebar();
}
});
wrap.appendChild(heading);
if (hasChildren && expanded) {
const childWrap = el(`<div class="tree-children"></div>`);
node.children.forEach((c) => childWrap.appendChild(renderTreeNode(c)));
wrap.appendChild(childWrap);
}
return wrap;
}
function renderTablesSidebar() {
const wrap = el(`<div></div>`);
if (!state.tablesTree.length) {
wrap.appendChild(el(`<p class="empty-state">No tables imported.</p>`));
return wrap;
}
state.tablesTree.forEach((root) => wrap.appendChild(renderTreeNode(root)));
return wrap;
}
async function selectTable(tableId) {
state.selectedTableId = tableId;
state.selectedTable = await api('GET', `/api/tables/${tableId}`);
state.lastRoll = null;
state.manualSelectOpen = false;
renderSidebar();
renderMain();
}
async function rollSelectedTable() {
if (!state.selectedTableId) return;
const result = await api('POST', `/api/tables/${state.selectedTableId}/roll`);
state.lastRoll = result;
state.manualSelectOpen = false;
await loadLog();
renderMain();
renderLog();
}
async function manualSelectRow(rowId) {
const table = state.selectedTable;
const row = table.rows.find((r) => r.id === rowId);
if (!row) return;
await api('POST', '/api/log', {
type: 'roll',
summary: `Manually selected on "${table.name}": ${row.cells.join(' / ')}`,
details: { table_id: table.id, row },
});
state.lastRoll = { table, row, links: [] };
state.manualSelectOpen = false;
await loadLog();
renderMain();
renderLog();
}
function renderRollResult(result) {
const box = el(`<div class="roll-result"></div>`);
box.appendChild(el(`<div class="roll-table-name">${escapeHtml(result.table.name)}</div>`));
const cellsWrap = el(`<div class="roll-cells"></div>`);
result.row.cells.forEach((cell, idx) => {
const colName = result.table.columns[idx] ? result.table.columns[idx].column_name : '';
cellsWrap.appendChild(el(`<p>${colName ? `<strong>${escapeHtml(colName)}:</strong> ` : ''}${md(cell)}</p>`));
});
box.appendChild(cellsWrap);
if (result.links && result.links.length) {
const linkWrap = el(`<div class="button-row"></div>`);
result.links.forEach((link) => {
const btn = el(`<button class="button">Roll Linked Table</button>`);
btn.addEventListener('click', () => selectTable(link.target_table_id).then(rollSelectedTable));
linkWrap.appendChild(btn);
});
box.appendChild(linkWrap);
}
return box;
}
function renderTablesMain() {
const wrap = el(`<div></div>`);
if (!state.selectedTable) {
wrap.appendChild(el(`<p class="empty-state">Select a table from the tree to roll on it.</p>`));
return wrap;
}
const table = state.selectedTable;
wrap.appendChild(el(`<h2>${escapeHtml(table.name)}</h2>`));
const actions = el(`<div class="button-row"></div>`);
const rollBtn = el(`<button class="button primary">Roll</button>`);
rollBtn.addEventListener('click', rollSelectedTable);
const rerollBtn = el(`<button class="button">Re-roll</button>`);
rerollBtn.addEventListener('click', rollSelectedTable);
const manualBtn = el(`<button class="button">Select Manually</button>`);
manualBtn.addEventListener('click', () => { state.manualSelectOpen = !state.manualSelectOpen; renderMain(); });
actions.append(rollBtn, rerollBtn, manualBtn);
wrap.appendChild(actions);
if (state.lastRoll) wrap.appendChild(renderRollResult(state.lastRoll));
if (state.manualSelectOpen) {
const list = el(`<div class="card"><h3>All entries</h3></div>`);
table.rows.forEach((row) => {
const item = el(`<div class="weapon-row" style="cursor:pointer"><span>${escapeHtml(row.roll_min === row.roll_max ? String(row.roll_min) : `${row.roll_min}-${row.roll_max}`)}</span><span>${md(row.cells.join(' / '))}</span></div>`);
item.addEventListener('click', () => manualSelectRow(row.id));
list.appendChild(item);
});
wrap.appendChild(list);
}
return wrap;
}
// ======================================================================
// NPCS
// ======================================================================
function renderNpcsSidebar() {
const wrap = el(`<div></div>`);
const actions = el(`<div class="button-row"></div>`);
const fullBtn = el(`<button class="button primary">+ Full NPC</button>`);
fullBtn.addEventListener('click', () => generateNpc('full'));
const fillerBtn = el(`<button class="button">+ Filler NPC</button>`);
fillerBtn.addEventListener('click', () => generateNpc('filler'));
actions.append(fullBtn, fillerBtn);
wrap.appendChild(actions);
const list = el(`<ul class="entity-list"></ul>`);
state.npcs.forEach((npc) => {
const name = `${npc.first_name || '(unnamed)'} ${npc.last_name || ''}`.trim();
const li = el(`<li class="${state.selectedNpcId === npc.id ? 'selected' : ''} status-${npc.status}"><span>${escapeHtml(name)}</span><span class="tag">${npc.status}</span></li>`);
li.addEventListener('click', () => { state.selectedNpcId = npc.id; renderSidebar(); renderMain(); });
list.appendChild(li);
});
wrap.appendChild(list);
return wrap;
}
async function generateNpc(npcType) {
const npc = await api('POST', '/api/npcs/generate', { npc_type: npcType });
await loadNpcs();
state.selectedNpcId = npc.id;
await loadLog();
renderSidebar();
renderMain();
renderLog();
}
async function rerollNpcField(field) {
await api('POST', `/api/npcs/${state.selectedNpcId}/reroll-field`, { field });
await loadNpcs();
await loadLog();
renderSidebar();
renderMain();
renderLog();
}
async function updateNpcField(field, value) {
await api('PUT', `/api/npcs/${state.selectedNpcId}`, { [field]: value });
await loadNpcs();
renderSidebar();
}
async function setNpcStatus(status) {
await api('PUT', `/api/npcs/${state.selectedNpcId}`, { status });
await loadNpcs();
renderSidebar();
renderMain();
}
async function deleteSelectedNpc() {
if (!confirm('Delete this NPC? This cannot be undone.')) return;
await api('DELETE', `/api/npcs/${state.selectedNpcId}`);
state.selectedNpcId = null;
await loadNpcs();
await loadLog();
renderSidebar();
renderMain();
renderLog();
}
async function attachNpcStatBlock() {
await api('POST', `/api/npcs/${state.selectedNpcId}/generate-stat-block`);
await loadNpcs();
renderMain();
}
const NPC_CORE_FIELDS = [
['first_name', 'First Name'], ['last_name', 'Last Name'], ['brief_description', 'Brief Description'],
['wants_needs', 'Wants and Needs'], ['secret_obstacle', 'Secret or Obstacle'], ['also_carrying', 'Also Carrying'],
];
const NPC_ATTR_FIELDS = [
['race', 'Race'], ['pronouns', 'Pronouns'], ['age', 'Age'], ['intelligence', 'Intelligence'], ['hair', 'Hair'], ['build', 'Build'],
];
function renderNpcField(npc, key, label, rerollable) {
const row = el(`<div class="field-row"><label>${label}</label></div>`);
const valueWrap = el(`<div class="field-value"></div>`);
const span = el(`<span>${md(npc[key]) || '<em>—</em>'}</span>`);
span.contentEditable = 'true';
span.addEventListener('blur', () => {
const text = span.innerText.trim();
if (text !== (npc[key] || '')) updateNpcField(key, text);
});
valueWrap.appendChild(span);
if (rerollable) {
const btn = el(`<button class="button" title="Re-roll">🎲</button>`);
btn.addEventListener('click', () => rerollNpcField(key));
valueWrap.appendChild(btn);
}
row.appendChild(valueWrap);
return row;
}
function renderNpcsMain() {
const wrap = el(`<div></div>`);
const npc = state.npcs.find((n) => n.id === state.selectedNpcId);
if (!npc) {
wrap.appendChild(el(`<p class="empty-state">Generate or select an NPC.</p>`));
return wrap;
}
const card = el(`<div class="card"></div>`);
card.appendChild(el(`<h2>${escapeHtml(`${npc.first_name || ''} ${npc.last_name || ''}`.trim() || '(unnamed)')} <span class="tag">${npc.npc_type}</span></h2>`));
const statusRow = el(`<div class="button-row"></div>`);
['active', 'dead', 'inactive'].forEach((s) => {
const b = el(`<button class="button ${npc.status === s ? 'primary' : ''}">${s}</button>`);
b.addEventListener('click', () => setNpcStatus(s));
statusRow.appendChild(b);
});
const delBtn = el(`<button class="button danger">Delete</button>`);
delBtn.addEventListener('click', deleteSelectedNpc);
statusRow.appendChild(delBtn);
card.appendChild(statusRow);
NPC_CORE_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true)));
if (npc.npc_type === 'full') {
NPC_ATTR_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true)));
}
wrap.appendChild(card);
const sbCard = el(`<div class="card"><h3>Stat Block</h3></div>`);
if (!npc.stat_block) {
const attachBtn = el(`<button class="button">Attach Random Stat Block</button>`);
attachBtn.addEventListener('click', attachNpcStatBlock);
sbCard.appendChild(attachBtn);
} else {
sbCard.appendChild(renderStatBlockReadout(npc.stat_block));
}
wrap.appendChild(sbCard);
return wrap;
}
// ======================================================================
// shared stat block readout (used by NPC + enemy detail views)
// ======================================================================
function renderStatBlockReadout(sb) {
const wrap = el(`<div></div>`);
const grid = el(`<div class="char-grid"></div>`);
['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((c) => {
grid.appendChild(el(`<div><div class="char-label">${c}</div><div class="char-value">${sb[c]}</div></div>`));
});
wrap.appendChild(grid);
wrap.appendChild(el(`<p>HP ${sb.current_hp}/${sb.max_hp} &nbsp; MP ${sb.magic_points_current}/${sb.magic_points_max} &nbsp; Move ${sb.move}${sb.culture ? ` &nbsp; <span class="tag">${escapeHtml(sb.culture)}</span>` : ''}</p>`));
const table = el(`<table class="hit-loc-table"><thead><tr><th>Location</th><th>HP</th><th>AP</th></tr></thead></table>`);
const tbody = el(`<tbody></tbody>`);
(sb.hit_locations || []).forEach((loc) => {
tbody.appendChild(el(`<tr class="${loc.disabled ? 'disabled' : ''}"><td>${loc.location_name}</td><td>${loc.current_hp}/${loc.max_hp}</td><td>${loc.armor_ap}</td></tr>`));
});
table.appendChild(tbody);
wrap.appendChild(table);
if ((sb.weapons || []).length) {
sb.weapons.forEach((w) => {
wrap.appendChild(el(`<div class="weapon-row"><span>${escapeHtml(w.weapon_name)}</span><span class="tag">${w.skill_percent}%</span>${w.mode ? `<span class="tag">${w.mode}</span>` : ''}</div>`));
});
}
if ((sb.spells || []).length) {
sb.spells.forEach((s) => {
wrap.appendChild(el(`<div class="spell-row"><span>${escapeHtml(s.custom_name)}</span><span class="tag">${s.mechanic_id}</span></div>`));
});
}
return wrap;
}
// ======================================================================
// ENEMIES
// ======================================================================
function renderEnemiesSidebar() {
const wrap = el(`<div></div>`);
const genBtn = el(`<button class="button primary">+ Generate Enemy</button>`);
genBtn.addEventListener('click', async () => {
const name = prompt('Enemy name?', 'New Enemy');
if (name === null) return;
const enemy = await api('POST', '/api/enemies/generate', { name, category: 'Humanoid' });
await loadEnemies();
state.selectedEnemyId = enemy.id;
await loadLog();
renderSidebar(); renderMain(); renderLog();
});
wrap.appendChild(genBtn);
const list = el(`<ul class="entity-list"></ul>`);
state.enemies.forEach((enemy) => {
const li = el(`<li class="${state.selectedEnemyId === enemy.id ? 'selected' : ''} status-${enemy.status}"><span>${escapeHtml(enemy.name)}</span><span class="tag">${enemy.stat_block.current_hp}/${enemy.stat_block.max_hp}</span></li>`);
li.addEventListener('click', () => { state.selectedEnemyId = enemy.id; renderSidebar(); renderMain(); });
list.appendChild(li);
});
wrap.appendChild(list);
return wrap;
}
async function updateEnemyStatBlock(patch) {
await api('PUT', `/api/enemies/${state.selectedEnemyId}`, { stat_block: patch });
await loadEnemies();
renderSidebar();
renderMain();
}
async function deleteSelectedEnemy() {
if (!confirm('Delete this enemy? This cannot be undone.')) return;
await api('DELETE', `/api/enemies/${state.selectedEnemyId}`);
state.selectedEnemyId = null;
await loadEnemies();
await loadLog();
renderSidebar(); renderMain(); renderLog();
}
function renderEnemiesMain() {
const wrap = el(`<div></div>`);
const enemy = state.enemies.find((e) => e.id === state.selectedEnemyId);
if (!enemy) {
wrap.appendChild(el(`<p class="empty-state">Generate or select an enemy.</p>`));
return wrap;
}
const card = el(`<div class="card"></div>`);
card.appendChild(el(`<h2>${escapeHtml(enemy.name)} <span class="tag">${enemy.category || ''}</span></h2>`));
const delBtn = el(`<button class="button danger">Delete</button>`);
delBtn.addEventListener('click', deleteSelectedEnemy);
card.appendChild(delBtn);
card.appendChild(renderStatBlockReadout(enemy.stat_block));
const weaponForm = el(`<div class="card"><h3>Add Weapon</h3></div>`);
const wName = el(`<input placeholder="Weapon name (e.g. Broadsword)">`);
const wCat = el(`<input placeholder="Category (e.g. Sword, 1H)">`);
const wSkill = el(`<input type="number" placeholder="Skill %" value="50">`);
const wAdd = el(`<button class="button">Add</button>`);
const wBonusBtn = el(`<button class="button">Check Cultural Bonus</button>`);
const wBonusResult = el(`<span class="tag"></span>`);
wBonusBtn.addEventListener('click', async () => {
if (!enemy.stat_block.culture || !wCat.value.trim()) {
wBonusResult.textContent = 'need culture + category';
return;
}
const bonus = await api('GET', `/api/rules/cultural-bonus?culture=${encodeURIComponent(enemy.stat_block.culture)}&category=${encodeURIComponent(wCat.value.trim())}&weapon=${encodeURIComponent(wName.value.trim())}`);
wBonusResult.textContent = `+${bonus.attack} atk / +${bonus.parry} parry (${enemy.stat_block.culture})`;
});
wAdd.addEventListener('click', async () => {
if (!wName.value.trim()) return;
const weapons = enemy.stat_block.weapons.map((w) => ({ weapon_name: w.weapon_name, category: w.category, skill_percent: w.skill_percent, mode: w.mode }));
weapons.push({ weapon_name: wName.value.trim(), category: wCat.value.trim() || null, skill_percent: Number(wSkill.value) || 0 });
await updateEnemyStatBlock({ weapons });
});
weaponForm.append(wName, wCat, wSkill, wAdd, wBonusBtn, wBonusResult);
wrap.append(card, weaponForm);
const armorForm = el(`<div class="card"><h3>Apply Armor Type</h3></div>`);
const armorSelect = el(`<select>${state.armorTable.map((a) => `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)} (AP ${a.ap})</option>`).join('')}</select>`);
const armorApply = el(`<button class="button">Apply to All Locations</button>`);
armorApply.addEventListener('click', async () => {
const armor = state.armorTable.find((a) => a.name === armorSelect.value);
if (!armor) return;
const hit_locations = enemy.stat_block.hit_locations.map((loc) => ({ ...loc, armor_ap: armor.ap }));
await updateEnemyStatBlock({ hit_locations });
});
armorForm.append(armorSelect, armorApply);
if (state.armorTable.length) wrap.appendChild(armorForm);
const spellForm = el(`<div class="card"><h3>Add Known Spell</h3></div>`);
const spellSelect = el(`<select></select>`);
state.spellMappings.forEach((sm) => spellSelect.appendChild(el(`<option value="${sm.id}">${escapeHtml(sm.custom_name)} (${sm.mechanic_id})</option>`)));
const spellAdd = el(`<button class="button">Add</button>`);
spellAdd.addEventListener('click', async () => {
if (!state.spellMappings.length) return;
const spells = enemy.stat_block.spells.map((s) => ({ spell_mapping_id: s.id }));
spells.push({ spell_mapping_id: Number(spellSelect.value) });
await updateEnemyStatBlock({ spells });
});
spellForm.append(spellSelect, spellAdd);
if (state.spellMappings.length) wrap.appendChild(spellForm);
return wrap;
}
// ======================================================================
// COMBAT
// ======================================================================
function combatants() { return (state.combat && state.combat.state.combatants) || []; }
function renderCombatSidebar() {
const wrap = el(`<div></div>`);
const addWrap = el(`<div class="field-row"><label>Add Combatant</label></div>`);
const select = el(`<select></select>`);
select.appendChild(el(`<option value="">Choose...</option>`));
state.npcs.filter((n) => n.stat_block).forEach((n) => select.appendChild(el(`<option value="npc:${n.id}">${escapeHtml(`${n.first_name || ''} ${n.last_name || ''}`.trim())}</option>`)));
state.enemies.forEach((e) => select.appendChild(el(`<option value="enemy:${e.id}">${escapeHtml(e.name)}</option>`)));
const addBtn = el(`<button class="button">Add</button>`);
addBtn.addEventListener('click', async () => {
if (!select.value) return;
const [type, id] = select.value.split(':');
await api('POST', '/api/combat/add-combatant', { type, id: Number(id) });
await loadCombat();
await loadLog();
renderSidebar(); renderMain(); renderLog();
});
addWrap.append(select, addBtn);
wrap.appendChild(addWrap);
const list = el(`<ul class="entity-list"></ul>`);
combatants().forEach((c) => {
list.appendChild(el(`<li><span>${escapeHtml(c.name)} (SR ${c.strikeRank})</span><span class="tag">${c.currentHp}/${c.maxHp} ${c.status}</span></li>`));
});
wrap.appendChild(list);
if (combatants().length) {
const endBtn = el(`<button class="button danger">End Combat</button>`);
endBtn.addEventListener('click', async () => {
if (!confirm('End combat?')) return;
await api('DELETE', '/api/combat');
await loadCombat();
await loadLog();
renderSidebar(); renderMain(); renderLog();
});
wrap.appendChild(endBtn);
}
return wrap;
}
function combatantOptions(selectedId) {
return combatants().map((c) => `<option value="${c.id}" ${c.id === selectedId ? 'selected' : ''}>${escapeHtml(c.name)}</option>`).join('');
}
function renderStuckWeaponFollowUp({ attackerCombatantId, defenderCombatantId, weaponName, kind }) {
const wrap = el(`<div class="card" style="margin-top:0.75rem"></div>`);
wrap.appendChild(el(`<p><strong>Weapon stuck (${kind}).</strong> Choose removal attempt:</p>`));
const removalResult = el(`<div></div>`);
async function doRemoval(removalType, extra = {}) {
try {
const res = await api('POST', '/api/combat/stuck-weapon-removal', {
attackerCombatantId, defenderCombatantId, weaponName, kind, removalType, ...extra,
});
state.combat = res.combatState;
const r = res.result;
const outcome = r.weaponBreaks ? 'Weapon breaks!' : r.success ? 'Weapon removed successfully.' : 'Weapon stays stuck.';
removalResult.innerHTML = `<p>${escapeHtml(outcome)}</p>`;
await loadLog();
renderSidebar();
renderLog();
} catch (err) {
removalResult.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
}
const attackerBtn = el(`<button class="button">Attacker removes weapon</button>`);
attackerBtn.addEventListener('click', () => doRemoval('attacker'));
const selfBtn = el(`<button class="button">Target removes from self</button>`);
selfBtn.addEventListener('click', () => doRemoval('self'));
const faSkill = el(`<input type="number" placeholder="First Aid %" style="width:7rem">`);
const faBtn = el(`<button class="button">Remove with First Aid</button>`);
faBtn.addEventListener('click', () => doRemoval('first-aid', { firstAidSkillPercent: Number(faSkill.value) || 0 }));
const btnRow = el(`<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-top:0.5rem"></div>`);
btnRow.append(attackerBtn, selfBtn, faSkill, faBtn);
wrap.appendChild(btnRow);
wrap.appendChild(removalResult);
return wrap;
}
function renderCombatMain() {
const wrap = el(`<div></div>`);
if (combatants().length < 1) {
wrap.appendChild(el(`<p class="empty-state">Add combatants from the sidebar to begin.</p>`));
return wrap;
}
wrap.appendChild(el(`<h2>Attack</h2>`));
const form = el(`<div class="card"></div>`);
const attackerSel = el(`<select>${combatantOptions()}</select>`);
const defenderSel = el(`<select>${combatantOptions()}</select>`);
const weaponSel = el(`<select></select>`);
const modeSel = el(`<select><option value="">(mode if dual)</option><option value="impale">impale</option><option value="slash">slash</option></select>`);
const kindSel = el(`<select><option value="melee">melee</option><option value="missile">missile</option></select>`);
const thrownChk = el(`<span class="checkbox-field"><input type="checkbox"> thrown</span>`);
function refreshWeapons() {
const attacker = combatants().find((c) => c.id === attackerSel.value) || combatants()[0];
weaponSel.innerHTML = '';
(attacker ? attacker.weapons : []).forEach((w) => weaponSel.appendChild(el(`<option>${escapeHtml(w.weapon_name)}</option>`)));
}
attackerSel.addEventListener('change', refreshWeapons);
refreshWeapons();
const reactionType = el(`<select><option value="">no reaction</option><option value="parry">parry</option><option value="dodge">dodge</option></select>`);
const reactionSkill = el(`<input type="number" placeholder="Reaction skill %">`);
const reactionWeapon = el(`<input placeholder="Parrying weapon name (for parry)">`);
const modifiersWrap = el(`<div class="field-row"><label>Situational Modifiers</label></div>`);
const modifierChecks = state.attackModifiers.map((mod) => el(`
<div class="checkbox-field-row">
<span class="checkbox-field"><input type="checkbox" data-mod-id="${mod.id}"> ${escapeHtml(mod.description)} (${mod.modifier > 0 ? '+' : ''}${mod.modifier}${mod.perSiz ? ` per ${mod.perSiz} SIZ` : ''})</span>
</div>
`));
modifierChecks.forEach((f) => modifiersWrap.appendChild(f));
const resolveBtn = el(`<button class="button primary">Resolve Attack</button>`);
const resultBox = el(`<div></div>`);
resolveBtn.addEventListener('click', async () => {
const modifierIds = modifierChecks
.filter((f) => f.querySelector('input').checked)
.map((f) => f.querySelector('input').dataset.modId);
const body = {
attackerCombatantId: attackerSel.value,
defenderCombatantId: defenderSel.value,
weaponName: weaponSel.value,
declaredMode: modeSel.value || undefined,
attackKind: kindSel.value,
thrown: thrownChk.querySelector('input').checked,
modifierIds,
};
if (reactionType.value) {
body.reaction = { type: reactionType.value, skillPercent: Number(reactionSkill.value) || 0, weaponName: reactionWeapon.value };
}
try {
const res = await api('POST', '/api/combat/attack', body);
state.combat = res.combatState;
resultBox.innerHTML = '';
const summary = el(`<div>
<p>${tierTag(res.result.attackCheck.tier)} roll ${res.result.attackCheck.roll} vs ${res.result.effectiveSkillPercent}% (base ${res.result.effectiveSkillPercent - res.result.modifierTotal}${res.result.modifierTotal ? `, modifiers ${res.result.modifierTotal > 0 ? '+' : ''}${res.result.modifierTotal}` : ''})</p>
${res.result.hitLocationRoll ? `<p>Hit location: ${res.result.hitLocationRoll.location}</p>` : ''}
${res.result.damageThrough != null ? `<p>Damage through: ${res.result.damageThrough}</p>` : ''}
${res.result.fumble ? `<p>Fumble: ${res.result.fumble.results.map((r) => r.effect).join('; ')}</p>` : ''}
</div>`);
resultBox.appendChild(summary);
const special = res.result.damageResult?.special;
const weaponStuck = (special === 'impale' || special === 'slash') && res.result.damageThrough > 0;
if (weaponStuck) {
const stuckBox = renderStuckWeaponFollowUp({
attackerCombatantId: body.attackerCombatantId,
defenderCombatantId: body.defenderCombatantId,
weaponName: body.weaponName,
kind: special,
});
resultBox.appendChild(stuckBox);
}
await loadLog();
renderSidebar();
renderLog();
} catch (err) {
resultBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
[
['Attacker', attackerSel], ['Defender', defenderSel], ['Weapon', weaponSel], ['Declared Mode', modeSel],
['Attack Kind', kindSel], [null, thrownChk], ['Defender Reaction', reactionType], ['Reaction Skill %', reactionSkill], [null, reactionWeapon],
].forEach(([label, input]) => {
const row = el(`<div class="field-row"></div>`);
if (label) row.appendChild(el(`<label>${label}</label>`));
row.appendChild(input);
form.appendChild(row);
});
form.appendChild(modifiersWrap);
form.appendChild(resolveBtn);
form.appendChild(resultBox);
wrap.appendChild(form);
wrap.appendChild(el(`<h2>Cast Spell</h2>`));
const spellForm = el(`<div class="card"></div>`);
const casterSel = el(`<select>${combatantOptions()}</select>`);
const targetSel = el(`<select><option value="">(none)</option>${combatantOptions()}</select>`);
const mechanicSel = el(`<select>${SPELL_MECHANIC_IDS.map((m) => `<option value="${m}">${m}</option>`).join('')}</select>`);
const mpInput = el(`<input type="number" value="1" min="1">`);
const castBtn = el(`<button class="button primary">Cast</button>`);
const castResult = el(`<div></div>`);
castBtn.addEventListener('click', async () => {
try {
const res = await api('POST', '/api/combat/cast-spell', {
casterCombatantId: casterSel.value,
targetCombatantId: targetSel.value || undefined,
mechanicId: mechanicSel.value,
mpSpent: Number(mpInput.value) || 1,
});
state.combat = res.combatState;
castResult.innerHTML = `<p>${escapeHtml(JSON.stringify(res.result))}</p>`;
await loadLog();
renderSidebar();
renderLog();
} catch (err) {
castResult.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
[['Caster', casterSel], ['Target', targetSel], ['Spell Mechanic', mechanicSel], ['MP Spent', mpInput]].forEach(([label, input]) => {
const row = el(`<div class="field-row"><label>${label}</label></div>`);
row.appendChild(input);
spellForm.appendChild(row);
});
spellForm.append(castBtn, castResult);
wrap.appendChild(spellForm);
return wrap;
}
// ======================================================================
// LOG
// ======================================================================
function renderLog() {
const root = qs('#log-entries');
root.innerHTML = '';
[...state.logEntries].forEach((entry) => {
root.appendChild(el(`
<div class="log-entry">
<div class="log-meta"><span class="log-type">${entry.type}</span> &middot; ${entry.created_at}</div>
<div>${escapeHtml(entry.summary)}</div>
</div>
`));
});
}
async function searchLogAndRender(q) {
await loadLog(q);
renderLog();
}
async function addNote() {
const input = qs('#note-input');
const text = input.value.trim();
if (!text) return;
await api('POST', '/api/log', { type: 'note', summary: text });
input.value = '';
await loadLog();
renderLog();
}
// ======================================================================
// IMPORT / EXPORT
// ======================================================================
async function exportAllData() {
const res = await fetch('/api/export');
const blob = await res.blob();
downloadBlob(blob, `story-tool-export-${Date.now()}.json`);
state.dirty = false;
}
async function exportLogData() {
const res = await fetch('/api/export/log');
const blob = await res.blob();
downloadBlob(blob, `session-log-${Date.now()}.md`);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function importFile(file) {
const text = await file.text();
const dump = JSON.parse(text);
await api('POST', '/api/import', dump);
await Promise.all([loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings()]);
renderSidebar();
renderMain();
renderLog();
state.dirty = false;
}
// ---------- Player Characters ----------
const CHAR_KEYS = ['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'];
const CHAR_LABELS = { str: 'STR', con: 'CON', siz: 'SIZ', int: 'INT', pow: 'POW', dex: 'DEX', app: 'APP' };
function renderCharactersSidebar() {
const wrap = el(`<div></div>`);
wrap.appendChild(el(`<h3>Saved Characters</h3>`));
if (!state.playerCharacters.length) {
wrap.appendChild(el(`<p class="empty-state">No characters yet.</p>`));
return wrap;
}
state.playerCharacters.forEach((pc) => {
const btn = el(`<button class="sidebar-item${state.selectedCharacterId === pc.id ? ' active' : ''}">${escapeHtml(pc.name)}</button>`);
btn.addEventListener('click', () => {
state.selectedCharacterId = pc.id;
renderSidebar();
renderMain();
});
wrap.appendChild(btn);
});
return wrap;
}
function renderDerivedStats(derived) {
const locs = derived.hitLocations.map((l) =>
`<tr><td>${escapeHtml(l.location_name)}</td><td>${l.max_hp}</td></tr>`
).join('');
const sm = derived.skillModifiers || {};
function sign(n) { return n > 0 ? `+${n}` : `${n}`; }
const modRows = [
['Agility', sm.agility], ['Communication', sm.communication], ['Knowledge', sm.knowledge],
['Magic', sm.magic], ['Manipulation', sm.manipulation], ['Perception', sm.perception],
['Stealth', sm.stealth],
].map(([name, v]) => `<tr><td style="padding-right:1rem">${name}</td><td>${v != null ? sign(v) : '—'}%</td></tr>`).join('');
return el(`<div class="card" style="margin-top:0.75rem">
<p>
<strong>HP:</strong> ${derived.totalHp} &nbsp;
<strong>FP:</strong> ${derived.fatigue ?? '—'} &nbsp;
<strong>MP:</strong> ${derived.magicPoints} &nbsp;
<strong>DB:</strong> ${escapeHtml(derived.damageBonus)} &nbsp;
<strong>SR:</strong> ${derived.strikeRank}
</p>
${sm.attack != null ? `<p><strong>Attack bonus:</strong> ${sign(sm.attack)}% &nbsp; <strong>Parry bonus:</strong> ${sign(sm.parry)}%</p>` : ''}
<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-top:0.5rem">
<div>
<strong>Hit Locations</strong>
<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.25rem">
<thead><tr><th style="text-align:left;padding-right:0.75rem">Location</th><th style="text-align:left">HP</th></tr></thead>
<tbody>${locs}</tbody>
</table>
</div>
${modRows ? `<div>
<strong>Skill Modifiers</strong>
<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.25rem">
<tbody>${modRows}</tbody>
</table>
</div>` : ''}
</div>
</div>`);
}
function renderCharactersMain() {
const wrap = el(`<div></div>`);
// --- Generator section ---
wrap.appendChild(el(`<h2>Character Generator</h2>`));
const genCard = el(`<div class="card"></div>`);
// Method picker
const methodRow = el(`<div class="field-row"><label>Method</label></div>`);
const methodSel = el(`<select>
<option value="random">Random (3D6 / 2D6+6)</option>
<option value="deliberate">Deliberate (80 points)</option>
<option value="combined">Combined (roll + 6 bonus points)</option>
</select>`);
methodRow.appendChild(methodSel);
genCard.appendChild(methodRow);
const statInputsWrap = el(`<div></div>`);
const derivedWrap = el(`<div></div>`);
const saveWrap = el(`<div style="display:none"></div>`);
const genMsg = el(`<div></div>`);
// Budget tracker for deliberate
const budgetDisplay = el(`<p style="display:none"></p>`);
let currentChars = null;
function buildStatInputs(readOnly, chars) {
statInputsWrap.innerHTML = '';
budgetDisplay.style.display = 'none';
if (readOnly) {
const row = el(`<div class="field-row" style="flex-wrap:wrap;gap:0.75rem"></div>`);
CHAR_KEYS.forEach((k) => {
row.appendChild(el(`<span><strong>${CHAR_LABELS[k]}:</strong> ${chars[k]}</span>`));
});
statInputsWrap.appendChild(row);
return null;
}
// Editable inputs
const inputs = {};
const grid = el(`<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(8rem,1fr));gap:0.5rem;margin:0.5rem 0"></div>`);
CHAR_KEYS.forEach((k) => {
const cell = el(`<div><label style="font-size:0.8em;display:block">${CHAR_LABELS[k]}</label></div>`);
const inp = el(`<input type="number" min="1" max="18" value="${chars ? chars[k] : (k === 'siz' || k === 'int' ? 8 : 6)}" style="width:100%">`);
inputs[k] = inp;
cell.appendChild(inp);
grid.appendChild(cell);
});
statInputsWrap.appendChild(grid);
if (methodSel.value === 'deliberate') {
budgetDisplay.style.display = '';
function updateBudget() {
const total = CHAR_KEYS.reduce((s, k) => s + (Number(inputs[k].value) || 0), 0);
const rem = 80 - total;
budgetDisplay.textContent = `Points used: ${total}/80 (${rem >= 0 ? rem + ' remaining' : Math.abs(rem) + ' over'})`;
budgetDisplay.style.color = rem === 0 ? 'var(--success,green)' : rem < 0 ? 'red' : 'inherit';
}
CHAR_KEYS.forEach((k) => inputs[k].addEventListener('input', updateBudget));
updateBudget();
}
if (methodSel.value === 'combined' && chars) {
// Show +/- buttons with bonus point tracking
let bonusLeft = 6;
const bonusLabel = el(`<p>Bonus points remaining: <strong id="bonus-left">6</strong></p>`);
statInputsWrap.appendChild(bonusLabel);
CHAR_KEYS.forEach((k) => {
const base = chars[k];
inputs[k].readOnly = true;
inputs[k].style.background = 'var(--input-disabled-bg, #f0f0f0)';
const plusBtn = el(`<button class="button" style="padding:0 0.4rem">+</button>`);
const minusBtn = el(`<button class="button" style="padding:0 0.4rem"></button>`);
plusBtn.addEventListener('click', () => {
if (bonusLeft <= 0 || Number(inputs[k].value) >= 18) return;
inputs[k].value = Number(inputs[k].value) + 1;
bonusLeft--;
bonusLabel.querySelector('#bonus-left').textContent = bonusLeft;
});
minusBtn.addEventListener('click', () => {
if (Number(inputs[k].value) <= base) return;
inputs[k].value = Number(inputs[k].value) - 1;
bonusLeft++;
bonusLabel.querySelector('#bonus-left').textContent = bonusLeft;
});
// attach buttons next to each input
const cell = inputs[k].parentElement;
const btnRow = el(`<div style="display:flex;gap:2px;margin-top:2px"></div>`);
btnRow.append(minusBtn, plusBtn);
cell.appendChild(btnRow);
});
}
return inputs;
}
let activeInputs = null;
function showRolledResult(chars, derived, bonusPoints) {
currentChars = chars;
if (bonusPoints > 0) {
activeInputs = buildStatInputs(false, chars);
} else {
buildStatInputs(true, chars);
activeInputs = null;
}
derivedWrap.innerHTML = '';
derivedWrap.appendChild(renderDerivedStats(derived));
saveWrap.style.display = '';
genMsg.innerHTML = '';
}
methodSel.addEventListener('change', () => {
statInputsWrap.innerHTML = '';
derivedWrap.innerHTML = '';
saveWrap.style.display = 'none';
genMsg.innerHTML = '';
budgetDisplay.style.display = 'none';
currentChars = null;
activeInputs = null;
if (methodSel.value === 'deliberate') {
activeInputs = buildStatInputs(false, null);
}
});
const rollBtn = el(`<button class="button primary" id="gen-roll-btn">Roll</button>`);
const calcBtn = el(`<button class="button primary" id="gen-calc-btn" style="display:none">Calculate</button>`);
methodSel.addEventListener('change', () => {
rollBtn.style.display = methodSel.value === 'deliberate' ? 'none' : '';
calcBtn.style.display = methodSel.value === 'deliberate' ? '' : 'none';
});
rollBtn.addEventListener('click', async () => {
try {
const res = await api('POST', '/api/characters/roll', { method: methodSel.value });
showRolledResult(res.chars, res.derived, res.bonusPoints);
} catch (err) {
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
calcBtn.addEventListener('click', async () => {
const chars = {};
CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; });
try {
const val = await api('POST', '/api/characters/validate', { method: 'deliberate', chars });
if (!val.valid) {
genMsg.innerHTML = `<p class="error-message">${val.errors.map(escapeHtml).join('<br>')}</p>`;
return;
}
const res = await api('POST', '/api/characters/derive', chars);
currentChars = chars;
derivedWrap.innerHTML = '';
derivedWrap.appendChild(renderDerivedStats(res.derived));
saveWrap.style.display = '';
genMsg.innerHTML = '';
} catch (err) {
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
// Apply combined bonus button
const applyBonusBtn = el(`<button class="button" id="gen-apply-btn" style="display:none">Apply Bonus Points</button>`);
methodSel.addEventListener('change', () => {
applyBonusBtn.style.display = methodSel.value === 'combined' ? '' : 'none';
});
applyBonusBtn.addEventListener('click', async () => {
if (!activeInputs) return;
const chars = {};
CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; });
try {
const val = await api('POST', '/api/characters/validate', { method: 'combined', chars });
if (!val.valid) {
genMsg.innerHTML = `<p class="error-message">${val.errors.map(escapeHtml).join('<br>')}</p>`;
return;
}
const res = await api('POST', '/api/characters/derive', chars);
currentChars = chars;
derivedWrap.innerHTML = '';
derivedWrap.appendChild(renderDerivedStats(res.derived));
saveWrap.style.display = '';
genMsg.innerHTML = '';
} catch (err) {
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
// Save section
const nameInput = el(`<input placeholder="Character name" style="margin-right:0.5rem">`);
const saveBtn = el(`<button class="button primary">Save Character</button>`);
saveWrap.append(nameInput, saveBtn);
saveBtn.addEventListener('click', async () => {
const name = nameInput.value.trim();
if (!name) { genMsg.innerHTML = `<p class="error-message">Name is required.</p>`; return; }
if (!currentChars) return;
const chars = activeInputs
? Object.fromEntries(CHAR_KEYS.map((k) => [k, Number(activeInputs[k].value) || 0]))
: currentChars;
try {
await api('POST', '/api/characters', { name, generation_method: methodSel.value, chars });
await loadPlayerCharacters();
genMsg.innerHTML = `<p style="color:var(--success,green)">Saved "${escapeHtml(name)}".</p>`;
nameInput.value = '';
renderSidebar();
} catch (err) {
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
const btnRow = el(`<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap"></div>`);
btnRow.append(rollBtn, calcBtn, applyBonusBtn);
genCard.append(statInputsWrap, budgetDisplay, btnRow, derivedWrap, saveWrap, genMsg);
wrap.appendChild(genCard);
// --- Selected PC view ---
const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId);
if (pc) {
wrap.appendChild(el(`<h2>${escapeHtml(pc.name)}</h2>`));
const pcCard = el(`<div class="card"></div>`);
const sb = pc.stat_block;
const statsRow = el(`<div class="field-row" style="flex-wrap:wrap;gap:0.75rem"></div>`);
['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((k) => {
statsRow.appendChild(el(`<span><strong>${CHAR_LABELS[k]}:</strong> ${sb[k]}</span>`));
});
pcCard.appendChild(statsRow);
pcCard.appendChild(el(`<p style="margin-top:0.5rem"><strong>HP:</strong> ${sb.current_hp}/${sb.max_hp} &nbsp; <strong>MP:</strong> ${sb.magic_points_current}/${sb.magic_points_max}</p>`));
if (sb.hit_locations && sb.hit_locations.length) {
const locs = sb.hit_locations.map((l) => `<tr><td>${escapeHtml(l.location_name)}</td><td>${l.current_hp}/${l.max_hp}</td></tr>`).join('');
pcCard.appendChild(el(`<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.5rem">
<thead><tr><th style="text-align:left;padding-right:1rem">Location</th><th style="text-align:left">HP</th></tr></thead>
<tbody>${locs}</tbody>
</table>`));
}
const delBtn = el(`<button class="button" style="margin-top:0.75rem;color:red">Delete Character</button>`);
delBtn.addEventListener('click', async () => {
if (!confirm(`Delete "${pc.name}"?`)) return;
await api('DELETE', `/api/characters/${pc.id}`);
state.selectedCharacterId = null;
await loadPlayerCharacters();
renderSidebar();
renderMain();
});
pcCard.appendChild(delBtn);
wrap.appendChild(pcCard);
// --- Inventory ---
wrap.appendChild(el(`<h2>Inventory</h2>`));
wrap.appendChild(renderInventory(pc));
}
return wrap;
}
function renderInventory(pc) {
const wrap = el(`<div class="card"></div>`);
const listWrap = el(`<div></div>`);
const encDisplay = el(`<p></p>`);
const errMsg = el(`<div></div>`);
async function refresh() {
const res = await loadInventory(pc.id);
listWrap.innerHTML = '';
if (!state.characterInventory.length) {
listWrap.appendChild(el(`<p class="empty-state">No items.</p>`));
} else {
const tbl = el(`<table style="border-collapse:collapse;width:100%;font-size:0.9em"></table>`);
tbl.innerHTML = `<thead><tr>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Item</th>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Category</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Qty</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">ENC</th>
<th></th>
</tr></thead>`;
const tbody = el(`<tbody></tbody>`);
state.characterInventory.forEach((item) => {
const row = el(`<tr></tr>`);
const notesTip = item.notes ? ` — ${escapeHtml(item.notes)}` : '';
row.innerHTML = `
<td style="padding:0.15rem 0.5rem 0.15rem 0">${escapeHtml(item.name)}${notesTip ? `<span style="color:var(--muted,#888);font-size:0.8em">${notesTip}</span>` : ''}</td>
<td style="padding:0.15rem 0.5rem 0.15rem 0">${escapeHtml(item.category)}</td>
<td style="text-align:right;padding:0.15rem 0.5rem 0.15rem 0">${item.quantity}</td>
<td style="text-align:right;padding:0.15rem 0.5rem 0.15rem 0">${(item.enc * item.quantity).toFixed(1)}</td>
`;
const removeBtn = el(`<button class="button" style="padding:0.1rem 0.4rem;font-size:0.8em">✕</button>`);
removeBtn.addEventListener('click', async () => {
await api('DELETE', `/api/characters/${pc.id}/inventory/${item.id}`);
await refresh();
});
const td = el(`<td style="padding:0.15rem 0"></td>`);
td.appendChild(removeBtn);
row.appendChild(td);
tbody.appendChild(row);
});
tbl.appendChild(tbody);
listWrap.appendChild(tbl);
}
const totalEnc = res ? res.totalEnc : 0;
const fp = pc.stat_block.str + pc.stat_block.con;
const effectiveFp = fp - totalEnc;
encDisplay.innerHTML = `<strong>Total ENC carried:</strong> ${totalEnc.toFixed(1)} &nbsp; <strong>Effective FP:</strong> ${effectiveFp.toFixed(1)} / ${fp}`;
}
// Add item form
const nameIn = el(`<input placeholder="Item name" style="flex:1;min-width:8rem">`);
const qtyIn = el(`<input type="number" value="1" min="1" style="width:4rem">`);
const encIn = el(`<input type="number" value="0" min="0" step="0.1" style="width:4.5rem" placeholder="ENC">`);
const catSel = el(`<select>
<option value="equipment">equipment</option>
<option value="weapon">weapon</option>
<option value="armor">armor</option>
<option value="money">money</option>
<option value="provisions">provisions</option>
</select>`);
const notesIn = el(`<input placeholder="Notes (optional)" style="flex:1;min-width:8rem">`);
const addBtn = el(`<button class="button primary">Add</button>`);
addBtn.addEventListener('click', async () => {
const name = nameIn.value.trim();
if (!name) return;
try {
await api('POST', `/api/characters/${pc.id}/inventory`, {
name,
quantity: Number(qtyIn.value) || 1,
enc: Number(encIn.value) || 0,
category: catSel.value,
notes: notesIn.value.trim() || null,
});
nameIn.value = ''; notesIn.value = ''; qtyIn.value = '1'; encIn.value = '0';
await refresh();
} catch (err) {
errMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
const formRow = el(`<div style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;margin-top:0.5rem"></div>`);
formRow.append(nameIn, qtyIn, encIn, catSel, notesIn, addBtn);
wrap.append(encDisplay, listWrap, formRow, errMsg);
refresh();
return wrap;
}
// ---------- Adventure ----------
function renderAdventureSidebar() {
const wrap = el(`<div></div>`);
wrap.appendChild(el(`<h3>Characters</h3>`));
if (!state.playerCharacters.length) {
wrap.appendChild(el(`<p class="empty-state">Create a character first.</p>`));
return wrap;
}
state.playerCharacters.forEach((pc) => {
const active = state.adventure && state.adventure.characterId === pc.id;
const btn = el(`<button class="sidebar-item${active ? ' active' : ''}">${escapeHtml(pc.name)}</button>`);
btn.addEventListener('click', () => {
state.selectedCharacterId = pc.id;
renderSidebar();
renderMain();
});
wrap.appendChild(btn);
});
return wrap;
}
function renderAdventureMain() {
const wrap = el(`<div></div>`);
wrap.appendChild(el(`<h2>Adventure</h2>`));
const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId)
|| state.playerCharacters[0];
if (!pc) {
wrap.appendChild(el(`<p class="empty-state">Create and select a character to begin adventuring.</p>`));
return wrap;
}
const sceneCard = el(`<div class="card"></div>`);
const sceneBox = el(`<div></div>`);
const choiceBox = el(`<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem"></div>`);
const outcomeBox = el(`<div style="margin-top:0.75rem"></div>`);
const errBox = el(`<div></div>`);
// Table selector for scene generation
const tableOptions = state.tablesTree.flatMap(function collect(node) {
return node.table ? [{ id: node.table.id, name: node.heading_text }] : (node.children || []).flatMap(collect);
});
const tableSel = el(`<select style="flex:1"><option value="">— no table (narrative only) —</option>${
tableOptions.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('')
}</select>`);
const startBtn = el(`<button class="button primary">New Scene</button>`);
const clearBtn = el(`<button class="button">Clear</button>`);
function renderScene(adventureState) {
sceneBox.innerHTML = '';
choiceBox.innerHTML = '';
outcomeBox.innerHTML = '';
if (!adventureState) return;
const scene = adventureState.scene;
sceneBox.appendChild(el(`<p style="font-style:italic;font-size:1.05em">${escapeHtml(scene.description)}</p>`));
if (scene.effectiveFp != null) {
sceneBox.appendChild(el(`<p style="font-size:0.85em;color:var(--muted,#888)">Effective FP: ${scene.effectiveFp}</p>`));
}
if (scene.status === 'active') {
scene.choices.forEach((ch) => {
const label = ch.skillPercent != null
? `${ch.label} (${ch.skill} ${ch.skillPercent}%)`
: ch.label;
const btn = el(`<button class="button">${escapeHtml(label)}</button>`);
btn.addEventListener('click', async () => {
try {
const res = await api('POST', '/api/adventure/choose', { choiceId: ch.id });
state.adventure = res;
renderScene(res);
await loadLog(); renderLog();
} catch (err) {
errBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
choiceBox.appendChild(btn);
});
}
if (scene.resolution) {
const r = scene.resolution;
let html = `<p><strong>${escapeHtml(scene.choices.find(c => c.id === scene.selectedChoice)?.label || scene.selectedChoice)}</strong>`;
if (r.roll != null) html += ` — rolled ${r.roll} vs ${r.skillPercent}% (${r.tier})`;
html += `</p><p>${escapeHtml(r.outcome)}</p>`;
if (scene.status === 'combat') {
html += `<p><strong>Set up the combat encounter in the Combat tab.</strong></p>`;
}
if (scene.status === 'escalated') {
html += `<p>The situation is now hostile — fight or flee!</p>`;
// Offer fight button
const fightBtn = el(`<button class="button primary">Engage in Combat</button>`);
fightBtn.addEventListener('click', async () => {
await api('POST', '/api/adventure/choose', { choiceId: 'fight' });
setView('combat');
});
outcomeBox.appendChild(el(`<div>${html}</div>`));
outcomeBox.appendChild(fightBtn);
return;
}
outcomeBox.innerHTML = html;
if (scene.status === 'resolved' || scene.status === 'combat') {
const nextBtn = el(`<button class="button" style="margin-top:0.5rem">Next Scene</button>`);
nextBtn.addEventListener('click', () => startBtn.click());
outcomeBox.appendChild(nextBtn);
}
}
}
startBtn.addEventListener('click', async () => {
try {
const res = await api('POST', '/api/adventure/start', {
characterId: pc.id,
tableId: tableSel.value ? Number(tableSel.value) : undefined,
});
state.adventure = res;
renderScene(res);
await loadLog(); renderLog();
} catch (err) {
errBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
clearBtn.addEventListener('click', async () => {
await api('DELETE', '/api/adventure');
state.adventure = null;
renderScene(null);
sceneBox.innerHTML = '';
});
const ctrlRow = el(`<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem"></div>`);
ctrlRow.append(tableSel, startBtn, clearBtn);
sceneCard.append(ctrlRow, sceneBox, choiceBox, outcomeBox, errBox);
wrap.appendChild(sceneCard);
// Render active scene on load
if (state.adventure && state.adventure.characterId === pc.id) {
renderScene(state.adventure);
}
return wrap;
}
// ---------- export reminder modal ----------
let pendingLeaveConfirmed = false;
window.addEventListener('beforeunload', (e) => {
if (state.dirty && !pendingLeaveConfirmed) {
e.preventDefault();
e.returnValue = '';
}
});
// ---------- init ----------
async function init() {
document.querySelectorAll('.tab-btn').forEach((btn) => btn.addEventListener('click', () => setView(btn.dataset.view)));
qs('#export-all-btn').addEventListener('click', exportAllData);
qs('#export-log-btn').addEventListener('click', exportLogData);
qs('#import-file').addEventListener('change', (e) => {
if (e.target.files[0]) importFile(e.target.files[0]);
e.target.value = '';
});
qs('#log-search').addEventListener('input', (e) => searchLogAndRender(e.target.value));
qs('#note-submit').addEventListener('click', addNote);
await Promise.all([
loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(),
loadAttackModifiers(), loadArmorTable(), loadPlayerCharacters(), loadAdventure(),
]);
setView('tables');
renderLog();
}
init();