Files
story_builder/public/app.js
T
stefwill 5e83fe1ef2 feat: full character creation, personality traits, and session quality-of-life
Character creation:
- Full RQ3 Previous Experience wizard (4-step: identity, characteristics,
  culture/occupation/skills, review+save) with all 29 occupations across
  4 cultures; skills computed as base + category modifier + years × multiplier
- Character sheet expanded to show culture, occupation, derived stats (HP/FP/MP/
  DB/SR), all 7 skill category modifier badges, computed skill percentages grouped
  by category, weapon attack/parry %, hit locations with armour AP
- Location/destination tracker on character sheet (auto-saves on blur)
- parry_percent stored on combatant_weapons

Personality traits:
- 24 predefined traits (Brave, Greedy, Cautious, etc.) each rated 0-100%
- Trait → action bias map drives scene choice weighting
- rollPersonalityAction() rolls d100 per trait, sums biases, returns suggestion
- Trait editor (pill UI) on both character sheet and NPC view
- Adventure tab: Roll Personality button fires traits, highlights suggested choice

Tables and data:
- Import RQ3 character creation tables (Culture d8, Occupation d100 ×4, Craft
  sub-tables, Language Proficiency, Dropped Oil Lamp, Aging, Armor Points)
- Cross-table links: Culture → Occupation, Barbarian/Civilized Crafter → Craft
- Fix rollOnTable to use actual dice notation instead of flat random row index
- Remove unused resolveHeadInjury function

Session tools:
- Clear All button wipes all session data (characters, NPCs, enemies, combat,
  adventure, log) while keeping tables and spell mappings
- PATCH /api/characters/:id/traits and /api/npcs/:id/traits endpoints
- GET /api/rules/personality-traits reference endpoint
- POST /api/adventure/personality-roll endpoint
2026-07-03 21:44:11 +10:00

2293 lines
102 KiB
JavaScript
Raw 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)));
}
const traitsRow = el(`<div class="field-row"><label>Personality Traits</label></div>`);
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(`<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>`);
}
// ======================================================================
// 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(`<div></div>`);
// ---- 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(`<h2>Character Generator</h2>`));
const wizardWrap = el(`<div class="card"></div>`);
wrap.appendChild(wizardWrap);
// Progress bar
const progressEl = el(`<p style="font-size:0.85em;color:var(--muted,#888);margin-bottom:0.75rem"></p>`);
wizardWrap.appendChild(progressEl);
const stepContent = el(`<div></div>`);
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 `<span style="font-weight:${active ? 'bold' : 'normal'};${active ? 'color:var(--accent,#0077cc)' : ''}">${n}. ${l}</span>`;
}).join(' &nbsp;|&nbsp; ');
}
// ---- Navigation helpers ----
function navRow(backFn, nextFn, nextLabel) {
const row = el(`<div style="display:flex;gap:0.5rem;margin-top:1rem"></div>`);
if (backFn) {
const b = el(`<button class="button">← Back</button>`);
b.addEventListener('click', backFn);
row.appendChild(b);
}
if (nextFn) {
const n = el(`<button class="button primary">${nextLabel || 'Next →'}</button>`);
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(`<div></div>`);
const nameIn = el(`<input placeholder="Character name" value="${escapeHtml(genState.name)}" style="width:16rem">`);
const genderRow = el(`<div style="display:flex;gap:1rem;align-items:center;margin:0.5rem 0"></div>`);
['M', 'F'].forEach((g) => {
const lbl = el(`<label style="display:flex;gap:0.3rem;align-items:center"><input type="radio" name="wiz-gender" value="${g}" ${genState.gender === g ? 'checked' : ''}> ${g === 'M' ? 'Male' : 'Female'}</label>`);
genderRow.appendChild(lbl);
});
const ageIn = el(`<input type="number" min="15" value="${genState.age}" style="width:5rem">`);
const rollAgeBtn = el(`<button class="button">Roll (2D6+15)</button>`);
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 = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
const fields = [
['Name', nameIn],
['Gender', genderRow],
['Age', el(`<span></span>`)],
];
fields.forEach(([label, inp]) => {
const row = el(`<div class="field-row"><label>${label}</label></div>`);
row.appendChild(inp);
stepContent.appendChild(row);
});
// replace the placeholder age row with proper age row
stepContent.lastChild.remove();
const ageRow = el(`<div class="field-row"><label>Age</label></div>`);
const ageInputWrap = el(`<div style="display:flex;gap:0.5rem;align-items:center"></div>`);
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 = `<p class="error-message">Name is required.</p>`; return; }
goToStep(2);
}));
}
// ======== STEP 2 — Characteristics ========
function renderStep2() {
const msg = el(`<div></div>`);
// Method picker
const methodRow = el(`<div class="field-row"><label>Method</label></div>`);
const methodSel = el(`<select>
<option value="random" ${genState.method === 'random' ? 'selected' : ''}>Random (3D6 / 2D6+6)</option>
<option value="deliberate" ${genState.method === 'deliberate' ? 'selected' : ''}>Deliberate (80 points)</option>
<option value="combined" ${genState.method === 'combined' ? 'selected' : ''}>Combined (roll + 6 bonus points)</option>
</select>`);
methodRow.appendChild(methodSel);
stepContent.appendChild(methodRow);
const statInputsWrap = el(`<div></div>`);
const derivedWrap = el(`<div></div>`);
const budgetDisplay = el(`<p style="display:none"></p>`);
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(`<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;
}
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) {
let bonusLeft = 6;
const bonusLabel = el(`<p>Bonus points remaining: <strong id="wiz-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('#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(`<div style="display:flex;gap:2px;margin-top:2px"></div>`);
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(`<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap"></div>`);
const rollBtn = el(`<button class="button primary">Roll</button>`);
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 = `<p class="error-message">${escapeHtml(err.message)}</p>`; }
});
const calcBtn = el(`<button class="button primary">Calculate</button>`);
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 = `<p class="error-message">${val.errors.map(escapeHtml).join('<br>')}</p>`; 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 = `<p class="error-message">${escapeHtml(err.message)}</p>`; }
});
const applyBtn = el(`<button class="button">Apply Bonus Points</button>`);
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 = `<p class="error-message">${val.errors.map(escapeHtml).join('<br>')}</p>`; 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 = `<p class="error-message">${escapeHtml(err.message)}</p>`; }
});
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 = `<p class="error-message">Roll or calculate characteristics first.</p>`; return; }
goToStep(3);
}
));
}
// ======== STEP 3 — Previous Experience ========
async function renderStep3() {
const years = genState.age - 15;
stepContent.appendChild(el(`<p><strong>Years of Experience:</strong> ${years} (age ${genState.age} 15)</p>`));
const msg = el(`<div></div>`);
const occResultWrap = el(`<div></div>`);
// Culture picker
const cultureSel = el(`<select><option value="">— select culture —</option>${CULTURES.map((c) => `<option value="${c}" ${genState.culture === c ? 'selected' : ''}>${c}</option>`).join('')}</select>`);
const rollCultureBtn = el(`<button class="button">Roll d8</button>`);
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(`<div class="field-row"><label>Culture</label></div>`);
const cultureCtrl = el(`<div style="display:flex;gap:0.5rem;align-items:center"></div>`);
cultureCtrl.append(rollCultureBtn, cultureSel);
cultureRow.appendChild(cultureCtrl);
stepContent.appendChild(cultureRow);
// Occupation picker
const occSel = el(`<select><option value="">— select occupation —</option></select>`);
const rollOccBtn = el(`<button class="button">Roll</button>`);
const occRow = el(`<div class="field-row"><label>Occupation</label></div>`);
const occCtrl = el(`<div style="display:flex;gap:0.5rem;align-items:center"></div>`);
occCtrl.append(rollOccBtn, occSel);
occRow.appendChild(occCtrl);
stepContent.appendChild(occRow);
function refreshOccupations() {
const culture = cultureSel.value;
occSel.innerHTML = '<option value="">— select occupation —</option>';
if (!culture || !OCCUPATION_LABELS[culture]) return;
Object.entries(OCCUPATION_LABELS[culture]).forEach(([key, label]) => {
occSel.appendChild(el(`<option value="${key}" ${genState.occupation === key ? 'selected' : ''}>${label}</option>`));
});
}
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 = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
}
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 = `<p class="error-message">Please select culture and occupation.</p>`; 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(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.5rem">Choices Required</h4></div>`);
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(`<div style="margin-bottom:0.5rem"><label style="font-size:0.9em"><strong>${escapeHtml(groupLabel)}:</strong></label></div>`);
const sel = el(`<select style="margin-left:0.5rem"><option value="">— pick one —</option></select>`);
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(`<option value="${opt.key}" ${genState.choiceSelections[groupName] === opt.key ? 'selected' : ''}>${escapeHtml(label)}</option>`);
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(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.5rem">Occupation Skills</h4></div>`);
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(`<table style="border-collapse:collapse;font-size:0.9em;width:100%"></table>`);
tbl.innerHTML = `<thead><tr><th style="text-align:left;padding:0.1rem 0.75rem 0.1rem 0">Skill</th><th style="text-align:left;padding:0.1rem 0.75rem 0.1rem 0">Category</th><th style="text-align:right;padding:0.1rem 0">Total %</th></tr></thead>`;
const tbody = el(`<tbody></tbody>`);
Object.keys(grouped).sort().forEach((cat) => {
grouped[cat].sort((a, b) => a.key.localeCompare(b.key)).forEach(({ key, val }) => {
tbody.appendChild(el(`<tr><td style="padding:0.1rem 0.75rem 0.1rem 0">${escapeHtml(SKILL_DISPLAY_NAMES[key] || key)}</td><td style="padding:0.1rem 0.75rem 0.1rem 0;color:var(--muted,#888);font-size:0.85em">${cat}</td><td style="text-align:right;padding:0.1rem 0">${val}%</td></tr>`));
});
});
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(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.5rem">Weapon Skills</h4></div>`);
weaponCard.appendChild(el(`<p style="font-size:0.85em;color:var(--muted,#888)">Attack modifier: ${sign(attackMod)}% &nbsp; Parry modifier: ${sign(parryMod)}%</p>`));
// 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(`<select style="flex:1"><option value="">— none —</option>${allPrimary.map((w) => `<option value="${w}" ${genState.primaryWeapon === w ? 'selected' : ''}>${escapeHtml(w)}</option>`).join('')}</select>`);
const missileSel = el(`<select style="flex:1"><option value="">— none —</option>${allMissile.map((w) => `<option value="${w}" ${genState.missileWeapon === w ? 'selected' : ''}>${escapeHtml(w)}</option>`).join('')}</select>`);
const shieldSel = el(`<select style="flex:1"><option value="">— none —</option>${allShield.map((w) => `<option value="${w}" ${genState.shieldWeapon === w ? 'selected' : ''}>${escapeHtml(w)}</option>`).join('')}</select>`);
[['Primary weapon', primarySel], ['Missile weapon', missileSel], ['Parry weapon / shield', shieldSel]].forEach(([label, sel]) => {
const row = el(`<div class="field-row" style="margin-bottom:0.25rem"><label style="min-width:10rem">${label}</label></div>`);
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(`<div style="margin-top:0.5rem"></div>`);
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(`<table style="border-collapse:collapse;font-size:0.9em;width:100%"></table>`);
tbl.innerHTML = `<thead><tr><th style="text-align:left;padding:0.1rem 0.75rem 0.1rem 0">Weapon</th><th style="text-align:right;padding:0.1rem 0.5rem 0.1rem 0">Attack %</th><th style="text-align:right;padding:0.1rem 0">Parry %</th></tr></thead>`;
const tbody = el(`<tbody></tbody>`);
rows.forEach(({ name, attack, parry }) => {
tbody.appendChild(el(`<tr>
<td style="padding:0.1rem 0.75rem 0.1rem 0">${escapeHtml(name)}</td>
<td style="text-align:right;padding:0.1rem 0.5rem 0.1rem 0">${attack != null ? attack + '%' : '—'}</td>
<td style="text-align:right;padding:0.1rem 0">${parry != null ? parry + '%' : '—'}</td>
</tr>`));
});
tbl.appendChild(tbody);
weaponTableWrap.appendChild(tbl);
}
updateWeaponTable();
occResultWrap.appendChild(weaponCard);
// --- Ritual skills ---
if (Object.keys(result.ritualBonuses).length) {
const ritCard = el(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Ritual Skills</h4></div>`);
Object.entries(result.ritualBonuses).forEach(([key, bonus]) => {
ritCard.appendChild(el(`<p style="margin:0.1rem 0">${escapeHtml(key)}: +${bonus}% (${years} × ${bonus / years})</p>`));
});
occResultWrap.appendChild(ritCard);
}
// --- Craft bonuses ---
if (Object.keys(result.craftBonuses).length) {
const craftCard = el(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Craft Skills</h4></div>`);
Object.entries(result.craftBonuses).forEach(([key, bonus]) => {
craftCard.appendChild(el(`<p style="margin:0.1rem 0">${escapeHtml(key)}: +${bonus}% (${years} × ${bonus / years})</p>`));
});
occResultWrap.appendChild(craftCard);
}
// --- Magic ---
if (occ.magic) {
const magicCard = el(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Magic</h4></div>`);
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(`<p style="margin:0">${escapeHtml(magicText)}</p>`));
occResultWrap.appendChild(magicCard);
}
// --- Equipment ---
if (occ.equipment) {
const eqCard = el(`<div style="margin-top:0.75rem"><h4 style="margin:0 0 0.25rem">Starting Equipment</h4></div>`);
eqCard.appendChild(el(`<p style="margin:0">${escapeHtml(occ.equipment)}</p>`));
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(`<div></div>`);
const years = genState.age - 15;
const reviewCard = el(`<div></div>`);
reviewCard.appendChild(el(`<h3 style="margin:0 0 0.5rem">${escapeHtml(genState.name)}</h3>`));
// Identity summary
reviewCard.appendChild(el(`<p><strong>Age:</strong> ${genState.age} &nbsp; <strong>Gender:</strong> ${genState.gender} &nbsp; <strong>Years exp:</strong> ${years}</p>`));
// Characteristics summary
if (genState.chars) {
const row = el(`<div class="field-row" style="flex-wrap:wrap;gap:0.75rem;margin-top:0.5rem"></div>`);
CHAR_KEYS.forEach((k) => row.appendChild(el(`<span><strong>${CHAR_LABELS[k]}:</strong> ${genState.chars[k]}</span>`)));
reviewCard.appendChild(row);
}
if (genState.derived) reviewCard.appendChild(renderDerivedStats(genState.derived));
// Culture / Occupation
reviewCard.appendChild(el(`<p style="margin-top:0.5rem"><strong>Culture:</strong> ${escapeHtml(genState.culture || '—')} &nbsp; <strong>Occupation:</strong> ${escapeHtml(genState.occupation ? ((OCCUPATION_LABELS[genState.culture] || {})[genState.occupation] || genState.occupation) : '—')}</p>`));
// Choices
if (Object.keys(genState.choiceSelections).length) {
const choiceP = el(`<p style="margin:0.25rem 0;font-size:0.9em"><strong>Choices:</strong> ${Object.entries(genState.choiceSelections).map(([g, k]) => `${g}: ${k}`).join('; ')}</p>`);
reviewCard.appendChild(choiceP);
}
stepContent.appendChild(reviewCard);
stepContent.appendChild(msg);
const saveBtn = el(`<button class="button primary" style="margin-top:0.75rem">Save Character</button>`);
stepContent.appendChild(saveBtn);
stepContent.appendChild(navRow(() => goToStep(3), null));
saveBtn.addEventListener('click', async () => {
if (!genState.chars) { msg.innerHTML = `<p class="error-message">Missing characteristics.</p>`; 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 = `<p style="color:var(--success,green)">Saved "${escapeHtml(genState.name)}"! Starting a new character...</p>`;
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 = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
}
// 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(`<div></div>`);
const listWrap = el(`<div style="display:flex;flex-wrap:wrap;gap:0.4rem;margin-bottom:0.4rem"></div>`);
let current = [...(traits || [])];
function redraw() {
listWrap.innerHTML = '';
current.forEach((t, i) => {
const pill = el(`<span style="display:inline-flex;align-items:center;gap:0.25rem;background:var(--tag-bg,#e8e8e8);padding:0.1rem 0.5rem;border-radius:12px;font-size:0.85em"></span>`);
const nameSpan = el(`<strong>${escapeHtml(t.name)}</strong>`);
const ratingIn = el(`<input type="number" min="1" max="100" value="${t.rating}" style="width:3.2rem;font-size:0.8em;padding:0.05rem 0.2rem;border:none;background:transparent;text-align:right">`);
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(`<button style="background:none;border:none;cursor:pointer;padding:0;line-height:1;color:var(--muted,#888)" title="Remove">✕</button>`);
removeBtn.addEventListener('click', async () => {
current.splice(i, 1);
await saveFn(current);
redraw();
});
pill.append(nameSpan, el(`<span style="color:var(--muted,#888)">:</span>`), ratingIn, el(`<span style="font-size:0.75em;color:var(--muted,#888)">%</span>`), removeBtn);
listWrap.appendChild(pill);
});
}
// Add trait form
const suggestions = _knownTraits.filter(n => !current.find(t => t.name === n));
const traitSel = el(`<select style="font-size:0.85em">
<option value="">— pick or type —</option>
${suggestions.map(n => `<option>${escapeHtml(n)}</option>`).join('')}
</select>`);
const customIn = el(`<input placeholder="Custom trait" style="font-size:0.85em;width:8rem">`);
traitSel.addEventListener('change', () => { if (traitSel.value) customIn.value = traitSel.value; });
const ratingIn = el(`<input type="number" value="60" min="1" max="100" style="width:3.5rem;font-size:0.85em" title="Rating %">`);
const addBtn = el(`<button class="button" style="font-size:0.85em;padding:0.15rem 0.5rem">Add</button>`);
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(`<div style="display:flex;gap:0.3rem;flex-wrap:wrap;align-items:center"></div>`);
formRow.append(traitSel, customIn, ratingIn, el(`<span style="font-size:0.8em;color:var(--muted,#888)">%</span>`), addBtn);
redraw();
wrap.append(listWrap, formRow);
return wrap;
}
function renderCharacterSheet(pc) {
const frag = el(`<div></div>`);
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(`<h2>${escapeHtml(pc.name)}</h2>`));
const identCard = el(`<div class="card"></div>`);
const identRow = el(`<div style="display:flex;flex-wrap:wrap;gap:0.75rem;font-size:0.9em"></div>`);
if (pc.age) identRow.appendChild(el(`<span><strong>Age:</strong> ${pc.age}</span>`));
if (pc.culture) identRow.appendChild(el(`<span><strong>Culture:</strong> ${escapeHtml(pc.culture)}</span>`));
if (pc.occupation_label || pc.occupation)
identRow.appendChild(el(`<span><strong>Occupation:</strong> ${escapeHtml(pc.occupation_label || pc.occupation)}</span>`));
identCard.appendChild(identRow);
// Location tracker
const locRow = el(`<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.6rem;align-items:center"></div>`);
const locInput = el(`<input placeholder="Current location" style="flex:1;min-width:10rem" value="${escapeHtml(pc.current_location || '')}">`);
const destInput = el(`<input placeholder="Heading to" style="flex:1;min-width:10rem" value="${escapeHtml(pc.destination || '')}">`);
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(`<span style="font-size:0.85em;color:var(--muted,#888)">Location:</span>`), locInput,
el(`<span style="font-size:0.85em;color:var(--muted,#888)">→</span>`), destInput);
identCard.appendChild(locRow);
frag.appendChild(identCard);
// ── Characteristics & derived ────────────────────────────────────
const statsCard = el(`<div class="card" style="margin-top:0.5rem"></div>`);
const charRow = el(`<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:0.4rem;text-align:center"></div>`);
CHAR_KEYS.forEach((k) => {
charRow.appendChild(el(`<div><div style="font-size:0.75em;font-weight:bold">${CHAR_LABELS[k]}</div><div style="font-size:1.2em">${sb[k]}</div></div>`));
});
statsCard.appendChild(charRow);
const derivedRow = el(`<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-top:0.6rem;font-size:0.9em"></div>`);
[
['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(`<span><strong>${label}:</strong> ${val}</span>`)));
statsCard.appendChild(derivedRow);
const modRow = el(`<div style="display:flex;flex-wrap:wrap;gap:0.6rem;margin-top:0.6rem;font-size:0.8em"></div>`);
[['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(`<span style="background:var(--tag-bg,#eee);padding:0.1rem 0.4rem;border-radius:3px">${label} ${sign(v)}%</span>`));
});
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(`<div class="card" style="margin-top:0.5rem"></div>`);
skillsCard.appendChild(el(`<strong>Skills</strong>`));
const grid = el(`<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:0.5rem 1.5rem;margin-top:0.4rem;font-size:0.85em"></div>`);
for (const [, { label, keys }] of Object.entries(SKILL_DISPLAY)) {
const section = el(`<div></div>`);
section.appendChild(el(`<div style="font-size:0.75em;font-weight:bold;color:var(--muted,#888);margin-bottom:0.2rem">${label}</div>`));
keys.forEach((k) => {
const val = skills[k];
if (!val && val !== 0) return;
section.appendChild(el(`<div style="display:flex;justify-content:space-between"><span>${SKILL_NAMES[k] || k}</span><span>${pct(val)}</span></div>`));
});
// 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(`<div style="display:flex;justify-content:space-between"><span>${name}</span><span>${pct(5 + bonus)}</span></div>`));
});
}
if (section.children.length > 1) grid.appendChild(section);
}
skillsCard.appendChild(grid);
frag.appendChild(skillsCard);
}
// ── Weapons ──────────────────────────────────────────────────────
if (sb.weapons && sb.weapons.length) {
const weapCard = el(`<div class="card" style="margin-top:0.5rem"></div>`);
weapCard.appendChild(el(`<strong>Weapons</strong>`));
const tbl = el(`<table style="border-collapse:collapse;width:100%;font-size:0.85em;margin-top:0.4rem"></table>`);
tbl.innerHTML = `<thead><tr>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Weapon</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Atk%</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Par%</th>
<th style="text-align:left;padding:0 0 0.25rem 0.5rem">Mode</th>
</tr></thead>`;
const tbody = el(`<tbody></tbody>`);
sb.weapons.forEach((w) => {
const row = el(`<tr>
<td style="padding:0.1rem 0.5rem 0.1rem 0">${escapeHtml(w.weapon_name)}</td>
<td style="text-align:right;padding:0.1rem 0.5rem">${w.skill_percent}%</td>
<td style="text-align:right;padding:0.1rem 0.5rem">${w.parry_percent ? w.parry_percent + '%' : '—'}</td>
<td style="padding:0.1rem 0 0.1rem 0.5rem;color:var(--muted,#888);font-size:0.85em">${escapeHtml(w.mode || w.category || '')}</td>
</tr>`);
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(`<div class="card" style="margin-top:0.5rem"></div>`);
locCard.appendChild(el(`<strong>Hit Locations</strong>`));
const tbl = el(`<table style="border-collapse:collapse;width:100%;font-size:0.85em;margin-top:0.4rem"></table>`);
tbl.innerHTML = `<thead><tr>
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Location</th>
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">HP</th>
<th style="text-align:right;padding:0 0 0.25rem 0.5rem">Armour AP</th>
</tr></thead>`;
const tbody = el(`<tbody></tbody>`);
sb.hit_locations.forEach((l) => {
tbody.appendChild(el(`<tr>
<td style="padding:0.1rem 0.5rem 0.1rem 0">${escapeHtml(l.location_name)}</td>
<td style="text-align:right;padding:0.1rem 0.5rem">${l.current_hp}/${l.max_hp}</td>
<td style="text-align:right;padding:0.1rem 0 0.1rem 0.5rem">${l.armor_ap || 0}</td>
</tr>`));
});
tbl.appendChild(tbody);
locCard.appendChild(tbl);
frag.appendChild(locCard);
}
// ── Personality Traits ───────────────────────────────────────────
const traitCard = el(`<div class="card" style="margin-top:0.5rem"></div>`);
traitCard.appendChild(el(`<strong>Personality Traits</strong>`));
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(`<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();
});
frag.appendChild(delBtn);
// ── Inventory ────────────────────────────────────────────────────
frag.appendChild(el(`<h2 style="margin-top:1rem">Inventory</h2>`));
frag.appendChild(renderInventory(pc));
return frag;
}
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') {
const personalityHint = el(`<p style="font-size:0.85em;color:var(--muted,#888);margin-bottom:0.4rem"></p>`);
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(`<button class="button${isHint ? ' primary' : ''}">${escapeHtml(label)}${isHint ? ' ★' : ''}</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);
});
}
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(`<button class="button" style="margin-top:0.4rem;font-size:0.85em">Roll Personality</button>`);
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 = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
});
choiceBox.appendChild(rollTraitBtn);
}
}
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('#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();