feat: inventory system, adventure scene engine, and PC generator fixes
- Inventory: items linked to PCs with name, qty, ENC, category; effective FP = STR+CON minus total ENC carried; CRUD API + inline UI on Characters tab - Adventure scene engine: new tab with procedural CYOA choices (Fight, Sneak, Talk, Investigate, Flee) resolved via skill checks; scenes generated from any rollable table with automatic L2 cascade; scene state persisted in DB - Fix total HP formula: was CON+SIZ, now correctly ceil((CON+SIZ)/2) per RQ3 - Fix hit location HPs: were computed from wrong total HP, now correct - Add fatigue points (STR+CON) to derived stats - Add all seven skill category modifiers computed from characteristics (primary/secondary/negative influences per rulebook), shown in generator UI - Add base skill computation (computeBaseSkills) for use in scene engine - Add RQ3 Players Book to docs/ as reference
This commit is contained in:
+576
-1
@@ -22,6 +22,10 @@ const state = {
|
||||
|
||||
spellMappings: [],
|
||||
logEntries: [],
|
||||
playerCharacters: [],
|
||||
selectedCharacterId: null,
|
||||
characterInventory: [],
|
||||
adventure: null,
|
||||
dirty: false,
|
||||
|
||||
attackModifiers: [],
|
||||
@@ -77,6 +81,13 @@ 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)}` : ''}`);
|
||||
}
|
||||
@@ -99,6 +110,8 @@ function renderSidebar() {
|
||||
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() {
|
||||
@@ -108,6 +121,8 @@ function renderMain() {
|
||||
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());
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
@@ -821,6 +836,566 @@ async function importFile(file) {
|
||||
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>`);
|
||||
}
|
||||
|
||||
function renderCharactersMain() {
|
||||
const wrap = el(`<div></div>`);
|
||||
|
||||
// --- Generator section ---
|
||||
wrap.appendChild(el(`<h2>Character Generator</h2>`));
|
||||
const genCard = el(`<div class="card"></div>`);
|
||||
|
||||
// Method picker
|
||||
const methodRow = el(`<div class="field-row"><label>Method</label></div>`);
|
||||
const methodSel = el(`<select>
|
||||
<option value="random">Random (3D6 / 2D6+6)</option>
|
||||
<option value="deliberate">Deliberate (80 points)</option>
|
||||
<option value="combined">Combined (roll + 6 bonus points)</option>
|
||||
</select>`);
|
||||
methodRow.appendChild(methodSel);
|
||||
genCard.appendChild(methodRow);
|
||||
|
||||
const statInputsWrap = el(`<div></div>`);
|
||||
const derivedWrap = el(`<div></div>`);
|
||||
const saveWrap = el(`<div style="display:none"></div>`);
|
||||
const genMsg = el(`<div></div>`);
|
||||
|
||||
// Budget tracker for deliberate
|
||||
const budgetDisplay = el(`<p style="display:none"></p>`);
|
||||
|
||||
let currentChars = null;
|
||||
|
||||
function buildStatInputs(readOnly, chars) {
|
||||
statInputsWrap.innerHTML = '';
|
||||
budgetDisplay.style.display = 'none';
|
||||
|
||||
if (readOnly) {
|
||||
const row = el(`<div class="field-row" style="flex-wrap:wrap;gap:0.75rem"></div>`);
|
||||
CHAR_KEYS.forEach((k) => {
|
||||
row.appendChild(el(`<span><strong>${CHAR_LABELS[k]}:</strong> ${chars[k]}</span>`));
|
||||
});
|
||||
statInputsWrap.appendChild(row);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Editable inputs
|
||||
const inputs = {};
|
||||
const grid = el(`<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(8rem,1fr));gap:0.5rem;margin:0.5rem 0"></div>`);
|
||||
CHAR_KEYS.forEach((k) => {
|
||||
const cell = el(`<div><label style="font-size:0.8em;display:block">${CHAR_LABELS[k]}</label></div>`);
|
||||
const inp = el(`<input type="number" min="1" max="18" value="${chars ? chars[k] : (k === 'siz' || k === 'int' ? 8 : 6)}" style="width:100%">`);
|
||||
inputs[k] = inp;
|
||||
cell.appendChild(inp);
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
statInputsWrap.appendChild(grid);
|
||||
|
||||
if (methodSel.value === 'deliberate') {
|
||||
budgetDisplay.style.display = '';
|
||||
function updateBudget() {
|
||||
const total = CHAR_KEYS.reduce((s, k) => s + (Number(inputs[k].value) || 0), 0);
|
||||
const rem = 80 - total;
|
||||
budgetDisplay.textContent = `Points used: ${total}/80 (${rem >= 0 ? rem + ' remaining' : Math.abs(rem) + ' over'})`;
|
||||
budgetDisplay.style.color = rem === 0 ? 'var(--success,green)' : rem < 0 ? 'red' : 'inherit';
|
||||
}
|
||||
CHAR_KEYS.forEach((k) => inputs[k].addEventListener('input', updateBudget));
|
||||
updateBudget();
|
||||
}
|
||||
|
||||
if (methodSel.value === 'combined' && chars) {
|
||||
// Show +/- buttons with bonus point tracking
|
||||
let bonusLeft = 6;
|
||||
const bonusLabel = el(`<p>Bonus points remaining: <strong id="bonus-left">6</strong></p>`);
|
||||
statInputsWrap.appendChild(bonusLabel);
|
||||
|
||||
CHAR_KEYS.forEach((k) => {
|
||||
const base = chars[k];
|
||||
inputs[k].readOnly = true;
|
||||
inputs[k].style.background = 'var(--input-disabled-bg, #f0f0f0)';
|
||||
|
||||
const plusBtn = el(`<button class="button" style="padding:0 0.4rem">+</button>`);
|
||||
const minusBtn = el(`<button class="button" style="padding:0 0.4rem">–</button>`);
|
||||
plusBtn.addEventListener('click', () => {
|
||||
if (bonusLeft <= 0 || Number(inputs[k].value) >= 18) return;
|
||||
inputs[k].value = Number(inputs[k].value) + 1;
|
||||
bonusLeft--;
|
||||
bonusLabel.querySelector('#bonus-left').textContent = bonusLeft;
|
||||
});
|
||||
minusBtn.addEventListener('click', () => {
|
||||
if (Number(inputs[k].value) <= base) return;
|
||||
inputs[k].value = Number(inputs[k].value) - 1;
|
||||
bonusLeft++;
|
||||
bonusLabel.querySelector('#bonus-left').textContent = bonusLeft;
|
||||
});
|
||||
// attach buttons next to each input
|
||||
const cell = inputs[k].parentElement;
|
||||
const btnRow = el(`<div style="display:flex;gap:2px;margin-top:2px"></div>`);
|
||||
btnRow.append(minusBtn, plusBtn);
|
||||
cell.appendChild(btnRow);
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
let activeInputs = null;
|
||||
|
||||
function showRolledResult(chars, derived, bonusPoints) {
|
||||
currentChars = chars;
|
||||
if (bonusPoints > 0) {
|
||||
activeInputs = buildStatInputs(false, chars);
|
||||
} else {
|
||||
buildStatInputs(true, chars);
|
||||
activeInputs = null;
|
||||
}
|
||||
derivedWrap.innerHTML = '';
|
||||
derivedWrap.appendChild(renderDerivedStats(derived));
|
||||
saveWrap.style.display = '';
|
||||
genMsg.innerHTML = '';
|
||||
}
|
||||
|
||||
methodSel.addEventListener('change', () => {
|
||||
statInputsWrap.innerHTML = '';
|
||||
derivedWrap.innerHTML = '';
|
||||
saveWrap.style.display = 'none';
|
||||
genMsg.innerHTML = '';
|
||||
budgetDisplay.style.display = 'none';
|
||||
currentChars = null;
|
||||
activeInputs = null;
|
||||
|
||||
if (methodSel.value === 'deliberate') {
|
||||
activeInputs = buildStatInputs(false, null);
|
||||
}
|
||||
});
|
||||
|
||||
const rollBtn = el(`<button class="button primary" id="gen-roll-btn">Roll</button>`);
|
||||
const calcBtn = el(`<button class="button primary" id="gen-calc-btn" style="display:none">Calculate</button>`);
|
||||
|
||||
methodSel.addEventListener('change', () => {
|
||||
rollBtn.style.display = methodSel.value === 'deliberate' ? 'none' : '';
|
||||
calcBtn.style.display = methodSel.value === 'deliberate' ? '' : 'none';
|
||||
});
|
||||
|
||||
rollBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await api('POST', '/api/characters/roll', { method: methodSel.value });
|
||||
showRolledResult(res.chars, res.derived, res.bonusPoints);
|
||||
} catch (err) {
|
||||
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
calcBtn.addEventListener('click', async () => {
|
||||
const chars = {};
|
||||
CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; });
|
||||
try {
|
||||
const val = await api('POST', '/api/characters/validate', { method: 'deliberate', chars });
|
||||
if (!val.valid) {
|
||||
genMsg.innerHTML = `<p class="error-message">${val.errors.map(escapeHtml).join('<br>')}</p>`;
|
||||
return;
|
||||
}
|
||||
const res = await api('POST', '/api/characters/derive', chars);
|
||||
currentChars = chars;
|
||||
derivedWrap.innerHTML = '';
|
||||
derivedWrap.appendChild(renderDerivedStats(res.derived));
|
||||
saveWrap.style.display = '';
|
||||
genMsg.innerHTML = '';
|
||||
} catch (err) {
|
||||
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply combined bonus button
|
||||
const applyBonusBtn = el(`<button class="button" id="gen-apply-btn" style="display:none">Apply Bonus Points</button>`);
|
||||
methodSel.addEventListener('change', () => {
|
||||
applyBonusBtn.style.display = methodSel.value === 'combined' ? '' : 'none';
|
||||
});
|
||||
applyBonusBtn.addEventListener('click', async () => {
|
||||
if (!activeInputs) return;
|
||||
const chars = {};
|
||||
CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; });
|
||||
try {
|
||||
const val = await api('POST', '/api/characters/validate', { method: 'combined', chars });
|
||||
if (!val.valid) {
|
||||
genMsg.innerHTML = `<p class="error-message">${val.errors.map(escapeHtml).join('<br>')}</p>`;
|
||||
return;
|
||||
}
|
||||
const res = await api('POST', '/api/characters/derive', chars);
|
||||
currentChars = chars;
|
||||
derivedWrap.innerHTML = '';
|
||||
derivedWrap.appendChild(renderDerivedStats(res.derived));
|
||||
saveWrap.style.display = '';
|
||||
genMsg.innerHTML = '';
|
||||
} catch (err) {
|
||||
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Save section
|
||||
const nameInput = el(`<input placeholder="Character name" style="margin-right:0.5rem">`);
|
||||
const saveBtn = el(`<button class="button primary">Save Character</button>`);
|
||||
saveWrap.append(nameInput, saveBtn);
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { genMsg.innerHTML = `<p class="error-message">Name is required.</p>`; return; }
|
||||
if (!currentChars) return;
|
||||
const chars = activeInputs
|
||||
? Object.fromEntries(CHAR_KEYS.map((k) => [k, Number(activeInputs[k].value) || 0]))
|
||||
: currentChars;
|
||||
try {
|
||||
await api('POST', '/api/characters', { name, generation_method: methodSel.value, chars });
|
||||
await loadPlayerCharacters();
|
||||
genMsg.innerHTML = `<p style="color:var(--success,green)">Saved "${escapeHtml(name)}".</p>`;
|
||||
nameInput.value = '';
|
||||
renderSidebar();
|
||||
} catch (err) {
|
||||
genMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
const btnRow = el(`<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap"></div>`);
|
||||
btnRow.append(rollBtn, calcBtn, applyBonusBtn);
|
||||
|
||||
genCard.append(statInputsWrap, budgetDisplay, btnRow, derivedWrap, saveWrap, genMsg);
|
||||
wrap.appendChild(genCard);
|
||||
|
||||
// --- Selected PC view ---
|
||||
const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId);
|
||||
if (pc) {
|
||||
wrap.appendChild(el(`<h2>${escapeHtml(pc.name)}</h2>`));
|
||||
const pcCard = el(`<div class="card"></div>`);
|
||||
const sb = pc.stat_block;
|
||||
const statsRow = el(`<div class="field-row" style="flex-wrap:wrap;gap:0.75rem"></div>`);
|
||||
['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((k) => {
|
||||
statsRow.appendChild(el(`<span><strong>${CHAR_LABELS[k]}:</strong> ${sb[k]}</span>`));
|
||||
});
|
||||
pcCard.appendChild(statsRow);
|
||||
pcCard.appendChild(el(`<p style="margin-top:0.5rem"><strong>HP:</strong> ${sb.current_hp}/${sb.max_hp} <strong>MP:</strong> ${sb.magic_points_current}/${sb.magic_points_max}</p>`));
|
||||
|
||||
if (sb.hit_locations && sb.hit_locations.length) {
|
||||
const locs = sb.hit_locations.map((l) => `<tr><td>${escapeHtml(l.location_name)}</td><td>${l.current_hp}/${l.max_hp}</td></tr>`).join('');
|
||||
pcCard.appendChild(el(`<table style="border-collapse:collapse;font-size:0.85em;margin-top:0.5rem">
|
||||
<thead><tr><th style="text-align:left;padding-right:1rem">Location</th><th style="text-align:left">HP</th></tr></thead>
|
||||
<tbody>${locs}</tbody>
|
||||
</table>`));
|
||||
}
|
||||
|
||||
const delBtn = el(`<button class="button" style="margin-top:0.75rem;color:red">Delete Character</button>`);
|
||||
delBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete "${pc.name}"?`)) return;
|
||||
await api('DELETE', `/api/characters/${pc.id}`);
|
||||
state.selectedCharacterId = null;
|
||||
await loadPlayerCharacters();
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
});
|
||||
pcCard.appendChild(delBtn);
|
||||
wrap.appendChild(pcCard);
|
||||
|
||||
// --- Inventory ---
|
||||
wrap.appendChild(el(`<h2>Inventory</h2>`));
|
||||
wrap.appendChild(renderInventory(pc));
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderInventory(pc) {
|
||||
const wrap = el(`<div class="card"></div>`);
|
||||
const listWrap = el(`<div></div>`);
|
||||
const encDisplay = el(`<p></p>`);
|
||||
const errMsg = el(`<div></div>`);
|
||||
|
||||
async function refresh() {
|
||||
const res = await loadInventory(pc.id);
|
||||
listWrap.innerHTML = '';
|
||||
if (!state.characterInventory.length) {
|
||||
listWrap.appendChild(el(`<p class="empty-state">No items.</p>`));
|
||||
} else {
|
||||
const tbl = el(`<table style="border-collapse:collapse;width:100%;font-size:0.9em"></table>`);
|
||||
tbl.innerHTML = `<thead><tr>
|
||||
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Item</th>
|
||||
<th style="text-align:left;padding:0 0.5rem 0.25rem 0">Category</th>
|
||||
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">Qty</th>
|
||||
<th style="text-align:right;padding:0 0.5rem 0.25rem 0">ENC</th>
|
||||
<th></th>
|
||||
</tr></thead>`;
|
||||
const tbody = el(`<tbody></tbody>`);
|
||||
state.characterInventory.forEach((item) => {
|
||||
const row = el(`<tr></tr>`);
|
||||
const notesTip = item.notes ? ` — ${escapeHtml(item.notes)}` : '';
|
||||
row.innerHTML = `
|
||||
<td style="padding:0.15rem 0.5rem 0.15rem 0">${escapeHtml(item.name)}${notesTip ? `<span style="color:var(--muted,#888);font-size:0.8em">${notesTip}</span>` : ''}</td>
|
||||
<td style="padding:0.15rem 0.5rem 0.15rem 0">${escapeHtml(item.category)}</td>
|
||||
<td style="text-align:right;padding:0.15rem 0.5rem 0.15rem 0">${item.quantity}</td>
|
||||
<td style="text-align:right;padding:0.15rem 0.5rem 0.15rem 0">${(item.enc * item.quantity).toFixed(1)}</td>
|
||||
`;
|
||||
const removeBtn = el(`<button class="button" style="padding:0.1rem 0.4rem;font-size:0.8em">✕</button>`);
|
||||
removeBtn.addEventListener('click', async () => {
|
||||
await api('DELETE', `/api/characters/${pc.id}/inventory/${item.id}`);
|
||||
await refresh();
|
||||
});
|
||||
const td = el(`<td style="padding:0.15rem 0"></td>`);
|
||||
td.appendChild(removeBtn);
|
||||
row.appendChild(td);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
tbl.appendChild(tbody);
|
||||
listWrap.appendChild(tbl);
|
||||
}
|
||||
const totalEnc = res ? res.totalEnc : 0;
|
||||
const fp = pc.stat_block.str + pc.stat_block.con;
|
||||
const effectiveFp = fp - totalEnc;
|
||||
encDisplay.innerHTML = `<strong>Total ENC carried:</strong> ${totalEnc.toFixed(1)} <strong>Effective FP:</strong> ${effectiveFp.toFixed(1)} / ${fp}`;
|
||||
}
|
||||
|
||||
// Add item form
|
||||
const nameIn = el(`<input placeholder="Item name" style="flex:1;min-width:8rem">`);
|
||||
const qtyIn = el(`<input type="number" value="1" min="1" style="width:4rem">`);
|
||||
const encIn = el(`<input type="number" value="0" min="0" step="0.1" style="width:4.5rem" placeholder="ENC">`);
|
||||
const catSel = el(`<select>
|
||||
<option value="equipment">equipment</option>
|
||||
<option value="weapon">weapon</option>
|
||||
<option value="armor">armor</option>
|
||||
<option value="money">money</option>
|
||||
<option value="provisions">provisions</option>
|
||||
</select>`);
|
||||
const notesIn = el(`<input placeholder="Notes (optional)" style="flex:1;min-width:8rem">`);
|
||||
const addBtn = el(`<button class="button primary">Add</button>`);
|
||||
|
||||
addBtn.addEventListener('click', async () => {
|
||||
const name = nameIn.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await api('POST', `/api/characters/${pc.id}/inventory`, {
|
||||
name,
|
||||
quantity: Number(qtyIn.value) || 1,
|
||||
enc: Number(encIn.value) || 0,
|
||||
category: catSel.value,
|
||||
notes: notesIn.value.trim() || null,
|
||||
});
|
||||
nameIn.value = ''; notesIn.value = ''; qtyIn.value = '1'; encIn.value = '0';
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
errMsg.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
const formRow = el(`<div style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;margin-top:0.5rem"></div>`);
|
||||
formRow.append(nameIn, qtyIn, encIn, catSel, notesIn, addBtn);
|
||||
|
||||
wrap.append(encDisplay, listWrap, formRow, errMsg);
|
||||
refresh();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---------- Adventure ----------
|
||||
|
||||
function renderAdventureSidebar() {
|
||||
const wrap = el(`<div></div>`);
|
||||
wrap.appendChild(el(`<h3>Characters</h3>`));
|
||||
if (!state.playerCharacters.length) {
|
||||
wrap.appendChild(el(`<p class="empty-state">Create a character first.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
state.playerCharacters.forEach((pc) => {
|
||||
const active = state.adventure && state.adventure.characterId === pc.id;
|
||||
const btn = el(`<button class="sidebar-item${active ? ' active' : ''}">${escapeHtml(pc.name)}</button>`);
|
||||
btn.addEventListener('click', () => {
|
||||
state.selectedCharacterId = pc.id;
|
||||
renderSidebar();
|
||||
renderMain();
|
||||
});
|
||||
wrap.appendChild(btn);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderAdventureMain() {
|
||||
const wrap = el(`<div></div>`);
|
||||
wrap.appendChild(el(`<h2>Adventure</h2>`));
|
||||
|
||||
const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId)
|
||||
|| state.playerCharacters[0];
|
||||
|
||||
if (!pc) {
|
||||
wrap.appendChild(el(`<p class="empty-state">Create and select a character to begin adventuring.</p>`));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const sceneCard = el(`<div class="card"></div>`);
|
||||
const sceneBox = el(`<div></div>`);
|
||||
const choiceBox = el(`<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem"></div>`);
|
||||
const outcomeBox = el(`<div style="margin-top:0.75rem"></div>`);
|
||||
const errBox = el(`<div></div>`);
|
||||
|
||||
// Table selector for scene generation
|
||||
const tableOptions = state.tablesTree.flatMap(function collect(node) {
|
||||
return node.table ? [{ id: node.table.id, name: node.heading_text }] : (node.children || []).flatMap(collect);
|
||||
});
|
||||
const tableSel = el(`<select style="flex:1"><option value="">— no table (narrative only) —</option>${
|
||||
tableOptions.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('')
|
||||
}</select>`);
|
||||
const startBtn = el(`<button class="button primary">New Scene</button>`);
|
||||
const clearBtn = el(`<button class="button">Clear</button>`);
|
||||
|
||||
function renderScene(adventureState) {
|
||||
sceneBox.innerHTML = '';
|
||||
choiceBox.innerHTML = '';
|
||||
outcomeBox.innerHTML = '';
|
||||
if (!adventureState) return;
|
||||
|
||||
const scene = adventureState.scene;
|
||||
sceneBox.appendChild(el(`<p style="font-style:italic;font-size:1.05em">${escapeHtml(scene.description)}</p>`));
|
||||
if (scene.effectiveFp != null) {
|
||||
sceneBox.appendChild(el(`<p style="font-size:0.85em;color:var(--muted,#888)">Effective FP: ${scene.effectiveFp}</p>`));
|
||||
}
|
||||
|
||||
if (scene.status === 'active') {
|
||||
scene.choices.forEach((ch) => {
|
||||
const label = ch.skillPercent != null
|
||||
? `${ch.label} (${ch.skill} ${ch.skillPercent}%)`
|
||||
: ch.label;
|
||||
const btn = el(`<button class="button">${escapeHtml(label)}</button>`);
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await api('POST', '/api/adventure/choose', { choiceId: ch.id });
|
||||
state.adventure = res;
|
||||
renderScene(res);
|
||||
await loadLog(); renderLog();
|
||||
} catch (err) {
|
||||
errBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
choiceBox.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
if (scene.resolution) {
|
||||
const r = scene.resolution;
|
||||
let html = `<p><strong>${escapeHtml(scene.choices.find(c => c.id === scene.selectedChoice)?.label || scene.selectedChoice)}</strong>`;
|
||||
if (r.roll != null) html += ` — rolled ${r.roll} vs ${r.skillPercent}% (${r.tier})`;
|
||||
html += `</p><p>${escapeHtml(r.outcome)}</p>`;
|
||||
if (scene.status === 'combat') {
|
||||
html += `<p><strong>Set up the combat encounter in the Combat tab.</strong></p>`;
|
||||
}
|
||||
if (scene.status === 'escalated') {
|
||||
html += `<p>The situation is now hostile — fight or flee!</p>`;
|
||||
// Offer fight button
|
||||
const fightBtn = el(`<button class="button primary">Engage in Combat</button>`);
|
||||
fightBtn.addEventListener('click', async () => {
|
||||
await api('POST', '/api/adventure/choose', { choiceId: 'fight' });
|
||||
setView('combat');
|
||||
});
|
||||
outcomeBox.appendChild(el(`<div>${html}</div>`));
|
||||
outcomeBox.appendChild(fightBtn);
|
||||
return;
|
||||
}
|
||||
outcomeBox.innerHTML = html;
|
||||
if (scene.status === 'resolved' || scene.status === 'combat') {
|
||||
const nextBtn = el(`<button class="button" style="margin-top:0.5rem">Next Scene</button>`);
|
||||
nextBtn.addEventListener('click', () => startBtn.click());
|
||||
outcomeBox.appendChild(nextBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await api('POST', '/api/adventure/start', {
|
||||
characterId: pc.id,
|
||||
tableId: tableSel.value ? Number(tableSel.value) : undefined,
|
||||
});
|
||||
state.adventure = res;
|
||||
renderScene(res);
|
||||
await loadLog(); renderLog();
|
||||
} catch (err) {
|
||||
errBox.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
await api('DELETE', '/api/adventure');
|
||||
state.adventure = null;
|
||||
renderScene(null);
|
||||
sceneBox.innerHTML = '';
|
||||
});
|
||||
|
||||
const ctrlRow = el(`<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem"></div>`);
|
||||
ctrlRow.append(tableSel, startBtn, clearBtn);
|
||||
sceneCard.append(ctrlRow, sceneBox, choiceBox, outcomeBox, errBox);
|
||||
wrap.appendChild(sceneCard);
|
||||
|
||||
// Render active scene on load
|
||||
if (state.adventure && state.adventure.characterId === pc.id) {
|
||||
renderScene(state.adventure);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---------- export reminder modal ----------
|
||||
|
||||
let pendingLeaveConfirmed = false;
|
||||
@@ -848,7 +1423,7 @@ async function init() {
|
||||
|
||||
await Promise.all([
|
||||
loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(),
|
||||
loadAttackModifiers(), loadArmorTable(),
|
||||
loadAttackModifiers(), loadArmorTable(), loadPlayerCharacters(), loadAdventure(),
|
||||
]);
|
||||
setView('tables');
|
||||
renderLog();
|
||||
|
||||
Reference in New Issue
Block a user