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
2293 lines
102 KiB
JavaScript
2293 lines
102 KiB
JavaScript
// Frontend logic only. No RQ3 rules calculations happen here - everything goes through /api/*.
|
||
|
||
const SPELL_MECHANIC_IDS = ['bladesharp', 'protection', 'heal', 'disruption', 'demoralize', 'coordination'];
|
||
|
||
const state = {
|
||
view: 'tables',
|
||
tablesTree: [],
|
||
expandedNodes: new Set(),
|
||
selectedTableId: null,
|
||
selectedTable: null,
|
||
lastRoll: null,
|
||
manualSelectOpen: false,
|
||
|
||
npcs: [],
|
||
selectedNpcId: null,
|
||
|
||
enemies: [],
|
||
selectedEnemyId: null,
|
||
|
||
combat: null,
|
||
reactionType: 'none',
|
||
|
||
spellMappings: [],
|
||
logEntries: [],
|
||
playerCharacters: [],
|
||
selectedCharacterId: null,
|
||
characterInventory: [],
|
||
adventure: null,
|
||
dirty: false,
|
||
|
||
attackModifiers: [],
|
||
armorTable: [],
|
||
selectedModifierIds: new Set(),
|
||
};
|
||
|
||
// ---------- low-level helpers ----------
|
||
|
||
function qs(sel, root = document) { return root.querySelector(sel); }
|
||
|
||
function el(html) {
|
||
const t = document.createElement('template');
|
||
t.innerHTML = html.trim();
|
||
return t.content.firstElementChild;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
function md(text) {
|
||
if (!text) return '';
|
||
// parseInline (not parse) - these are short flavor-text fragments rendered inside our
|
||
// own wrapper elements; parse()'s block-level <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} MP ${sb.magic_points_current}/${sb.magic_points_max} Move ${sb.move}${sb.culture ? ` <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> · ${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}
|
||
<strong>FP:</strong> ${derived.fatigue ?? '—'}
|
||
<strong>MP:</strong> ${derived.magicPoints}
|
||
<strong>DB:</strong> ${escapeHtml(derived.damageBonus)}
|
||
<strong>SR:</strong> ${derived.strikeRank}
|
||
</p>
|
||
${sm.attack != null ? `<p><strong>Attack bonus:</strong> ${sign(sm.attack)}% <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(' | ');
|
||
}
|
||
|
||
// ---- 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)}% 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} <strong>Gender:</strong> ${genState.gender} <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 || '—')} <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)} <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();
|