// 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(``));
});
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(``);
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)}%
` : ''}
`);
}
// ======================================================================
// 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 = `| Skill | Category | Total % |
`;
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 = `| Weapon | Attack % | 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();