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:
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
- [ ] Build strike-rank round scheduling UI (round counter, Next Round button, SR-ordered action display using `buildStrikeRankSchedule`)
|
- [ ] Build strike-rank round scheduling UI (round counter, Next Round button, SR-ordered action display using `buildStrikeRankSchedule`)
|
||||||
- [ ] Add API routes and UI for the experience/improvement system (`markWeaponExperienceChecked`, `updateWeaponSkillPercent`, `applyImprovement`)
|
- [ ] Add API routes and UI for the experience/improvement system (`markWeaponExperienceChecked`, `updateWeaponSkillPercent`, `applyImprovement`)
|
||||||
|
- [ ] Add Layer 3 (and possibly Layer 4) sub-tables to the Norse encounter tables so rolls can cascade deeper
|
||||||
|
- [ ] Add a character location / destination tracker — where the PC currently is and where they are heading (used to contextualise encounters and travel events)
|
||||||
|
- [x] Player character generator — stat rolls, derived stats, skills, weapons, hit locations; needed for combat and skill checks
|
||||||
|
|
||||||
## Low priority (polish)
|
## Low priority (polish)
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,34 @@ CREATE TABLE IF NOT EXISTS enemies (
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
character_id INTEGER NOT NULL REFERENCES player_characters(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enc REAL NOT NULL DEFAULT 0,
|
||||||
|
category TEXT NOT NULL DEFAULT 'equipment',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_character ON inventory_items(character_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
character_id INTEGER REFERENCES player_characters(id),
|
||||||
|
scene_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_characters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
generation_method TEXT NOT NULL DEFAULT 'random',
|
||||||
|
stat_block_id INTEGER NOT NULL REFERENCES stat_blocks(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS log_entries (
|
CREATE TABLE IF NOT EXISTS log_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
@@ -352,6 +380,88 @@ function deleteEnemy(id) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- inventory ----------
|
||||||
|
|
||||||
|
function listInventory(characterId) {
|
||||||
|
return db.prepare('SELECT * FROM inventory_items WHERE character_id = ? ORDER BY category, name').all(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInventoryItem({ character_id, name, quantity = 1, enc = 0, category = 'equipment', notes = null }) {
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO inventory_items (character_id, name, quantity, enc, category, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(character_id, name, quantity, enc, category, notes);
|
||||||
|
return db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(info.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInventoryItem(id, { quantity, enc, notes }) {
|
||||||
|
const item = db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(id);
|
||||||
|
if (!item) return null;
|
||||||
|
db.prepare(`UPDATE inventory_items SET quantity=?, enc=?, notes=? WHERE id=?`).run(
|
||||||
|
quantity ?? item.quantity, enc ?? item.enc, notes !== undefined ? notes : item.notes, id
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteInventoryItem(id) {
|
||||||
|
return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryTotalEnc(characterId) {
|
||||||
|
const row = db.prepare('SELECT SUM(quantity * enc) AS total FROM inventory_items WHERE character_id = ?').get(characterId);
|
||||||
|
return row ? (row.total || 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- adventure state ----------
|
||||||
|
|
||||||
|
function getAdventureState() {
|
||||||
|
const row = db.prepare('SELECT * FROM adventure_state WHERE id = 1').get();
|
||||||
|
if (!row) return null;
|
||||||
|
return { characterId: row.character_id, scene: JSON.parse(row.scene_json) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdventureState(characterId, scene) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO adventure_state (id, character_id, scene_json, updated_at) VALUES (1, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(id) DO UPDATE SET character_id=?, scene_json=?, updated_at=datetime('now')
|
||||||
|
`).run(characterId, JSON.stringify(scene), characterId, JSON.stringify(scene));
|
||||||
|
return getAdventureState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdventureState() {
|
||||||
|
db.prepare('DELETE FROM adventure_state WHERE id = 1').run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- player characters ----------
|
||||||
|
|
||||||
|
function listPlayerCharacters() {
|
||||||
|
const rows = db.prepare('SELECT * FROM player_characters ORDER BY id DESC').all();
|
||||||
|
return rows.map((r) => ({ ...r, stat_block: getStatBlock(r.stat_block_id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerCharacter(id) {
|
||||||
|
const row = db.prepare('SELECT * FROM player_characters WHERE id = ?').get(id);
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row, stat_block: getStatBlock(row.stat_block_id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerCharacter({ name, generation_method, stat_block }) {
|
||||||
|
const statBlockId = createStatBlock(stat_block || {}).id;
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO player_characters (name, generation_method, stat_block_id)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(name, generation_method ?? 'random', statBlockId);
|
||||||
|
return getPlayerCharacter(info.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePlayerCharacter(id) {
|
||||||
|
const existing = getPlayerCharacter(id);
|
||||||
|
if (!existing) return false;
|
||||||
|
db.prepare('DELETE FROM player_characters WHERE id = ?').run(id);
|
||||||
|
deleteStatBlock(existing.stat_block_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- log ----------
|
// ---------- log ----------
|
||||||
|
|
||||||
function appendLogEntry({ type, summary, details }) {
|
function appendLogEntry({ type, summary, details }) {
|
||||||
@@ -581,6 +691,9 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
npcs: { list: listNpcs, get: getNpc, create: createNpc, update: updateNpc, delete: deleteNpc, linkStatBlock: linkNpcStatBlock },
|
npcs: { list: listNpcs, get: getNpc, create: createNpc, update: updateNpc, delete: deleteNpc, linkStatBlock: linkNpcStatBlock },
|
||||||
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
|
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
|
||||||
|
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter },
|
||||||
|
inventory: { list: listInventory, add: addInventoryItem, update: updateInventoryItem, delete: deleteInventoryItem, totalEnc: inventoryTotalEnc },
|
||||||
|
adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState },
|
||||||
log: { append: appendLogEntry, get: getLogEntry, search: searchLog },
|
log: { append: appendLogEntry, get: getLogEntry, search: searchLog },
|
||||||
combat: { get: getCombatState, set: setCombatState, clear: clearCombatState },
|
combat: { get: getCombatState, set: setCombatState, clear: clearCombatState },
|
||||||
tables: {
|
tables: {
|
||||||
|
|||||||
Executable
+4264
File diff suppressed because it is too large
Load Diff
+576
-1
@@ -22,6 +22,10 @@ const state = {
|
|||||||
|
|
||||||
spellMappings: [],
|
spellMappings: [],
|
||||||
logEntries: [],
|
logEntries: [],
|
||||||
|
playerCharacters: [],
|
||||||
|
selectedCharacterId: null,
|
||||||
|
characterInventory: [],
|
||||||
|
adventure: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
|
||||||
attackModifiers: [],
|
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 loadEnemies() { state.enemies = await api('GET', '/api/enemies'); }
|
||||||
async function loadCombat() { state.combat = await api('GET', '/api/combat'); }
|
async function loadCombat() { state.combat = await api('GET', '/api/combat'); }
|
||||||
async function loadSpellMappings() { state.spellMappings = await api('GET', '/api/spell-mappings'); }
|
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 = '') {
|
async function loadLog(search = '') {
|
||||||
state.logEntries = await api('GET', `/api/log${search ? `?search=${encodeURIComponent(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 === 'npcs') root.appendChild(renderNpcsSidebar());
|
||||||
if (state.view === 'enemies') root.appendChild(renderEnemiesSidebar());
|
if (state.view === 'enemies') root.appendChild(renderEnemiesSidebar());
|
||||||
if (state.view === 'combat') root.appendChild(renderCombatSidebar());
|
if (state.view === 'combat') root.appendChild(renderCombatSidebar());
|
||||||
|
if (state.view === 'characters') root.appendChild(renderCharactersSidebar());
|
||||||
|
if (state.view === 'adventure') root.appendChild(renderAdventureSidebar());
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMain() {
|
function renderMain() {
|
||||||
@@ -108,6 +121,8 @@ function renderMain() {
|
|||||||
if (state.view === 'npcs') root.appendChild(renderNpcsMain());
|
if (state.view === 'npcs') root.appendChild(renderNpcsMain());
|
||||||
if (state.view === 'enemies') root.appendChild(renderEnemiesMain());
|
if (state.view === 'enemies') root.appendChild(renderEnemiesMain());
|
||||||
if (state.view === 'combat') root.appendChild(renderCombatMain());
|
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;
|
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 ----------
|
// ---------- export reminder modal ----------
|
||||||
|
|
||||||
let pendingLeaveConfirmed = false;
|
let pendingLeaveConfirmed = false;
|
||||||
@@ -848,7 +1423,7 @@ async function init() {
|
|||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(),
|
loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(),
|
||||||
loadAttackModifiers(), loadArmorTable(),
|
loadAttackModifiers(), loadArmorTable(), loadPlayerCharacters(), loadAdventure(),
|
||||||
]);
|
]);
|
||||||
setView('tables');
|
setView('tables');
|
||||||
renderLog();
|
renderLog();
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<button class="tab-btn" data-view="npcs">NPCs</button>
|
<button class="tab-btn" data-view="npcs">NPCs</button>
|
||||||
<button class="tab-btn" data-view="enemies">Enemies</button>
|
<button class="tab-btn" data-view="enemies">Enemies</button>
|
||||||
<button class="tab-btn" data-view="combat">Combat</button>
|
<button class="tab-btn" data-view="combat">Combat</button>
|
||||||
|
<button class="tab-btn" data-view="characters">Characters</button>
|
||||||
|
<button class="tab-btn" data-view="adventure">Adventure</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="sidebar-content"></div>
|
<div id="sidebar-content"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -55,6 +55,189 @@ function rollCharacteristics() {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DELIBERATE_METHOD = {
|
||||||
|
totalPoints: 80,
|
||||||
|
minSiz: 8,
|
||||||
|
minInt: 8,
|
||||||
|
minOther: 6,
|
||||||
|
maxAny: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateDeliberate(chars) {
|
||||||
|
const vals = { STR: chars.str, CON: chars.con, SIZ: chars.siz, INT: chars.int, POW: chars.pow, DEX: chars.dex, APP: chars.app };
|
||||||
|
const errors = [];
|
||||||
|
const total = Object.values(vals).reduce((a, b) => a + b, 0);
|
||||||
|
if (total !== DELIBERATE_METHOD.totalPoints) errors.push(`Total must be exactly 80 (got ${total})`);
|
||||||
|
if (vals.SIZ < DELIBERATE_METHOD.minSiz) errors.push(`SIZ minimum is 8 (got ${vals.SIZ})`);
|
||||||
|
if (vals.INT < DELIBERATE_METHOD.minInt) errors.push(`INT minimum is 8 (got ${vals.INT})`);
|
||||||
|
for (const k of ['STR', 'CON', 'POW', 'DEX', 'APP']) {
|
||||||
|
if (vals[k] < DELIBERATE_METHOD.minOther) errors.push(`${k} minimum is 6 (got ${vals[k]})`);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(vals)) {
|
||||||
|
if (v > DELIBERATE_METHOD.maxAny) errors.push(`${k} cannot exceed 18 (got ${v})`);
|
||||||
|
}
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCombined(chars) {
|
||||||
|
const vals = { STR: chars.str, CON: chars.con, SIZ: chars.siz, INT: chars.int, POW: chars.pow, DEX: chars.dex, APP: chars.app };
|
||||||
|
const errors = [];
|
||||||
|
const total = Object.values(vals).reduce((a, b) => a + b, 0);
|
||||||
|
if (total > 91) errors.push(`Total cannot exceed 91 (got ${total})`);
|
||||||
|
for (const [k, v] of Object.entries(vals)) {
|
||||||
|
if (v > 18) errors.push(`${k} cannot exceed 18 (got ${v})`);
|
||||||
|
}
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Skills Category Modifiers ----------
|
||||||
|
// Primary: +1% per point over 10, -1% per point under 10.
|
||||||
|
// Secondary: +1% per 2 points over 10 (max +10%), -1% per 2 points under 10, using ceil.
|
||||||
|
// Negative: inverse of primary.
|
||||||
|
|
||||||
|
function _primary(c) { return c - 10; }
|
||||||
|
function _secondary(c) {
|
||||||
|
const diff = c - 10;
|
||||||
|
if (diff === 0) return 0;
|
||||||
|
const raw = Math.sign(diff) * Math.ceil(Math.abs(diff) / 2);
|
||||||
|
return Math.min(raw, 10); // cap positive at +10%, no cap on negative
|
||||||
|
}
|
||||||
|
function _negative(c) { return 10 - c; }
|
||||||
|
|
||||||
|
function computeSkillCategoryModifiers(chars) {
|
||||||
|
const { str, con, siz, int, pow, dex, app } = chars;
|
||||||
|
const agility = _primary(dex) + _secondary(str) + _negative(siz);
|
||||||
|
const communication = _primary(int) + _secondary(pow) + _secondary(app);
|
||||||
|
const knowledge = _primary(int);
|
||||||
|
const magic = _primary(int) + _primary(pow) + _secondary(dex);
|
||||||
|
const manipulation = _primary(int) + _primary(dex) + _secondary(str);
|
||||||
|
const perception = _primary(int) + _secondary(pow) + _secondary(con);
|
||||||
|
const stealth = _primary(dex) + _negative(siz) + _negative(pow);
|
||||||
|
return {
|
||||||
|
agility, communication, knowledge, magic, manipulation, perception, stealth,
|
||||||
|
attack: manipulation, // attack modifier = manipulation modifier
|
||||||
|
parry: agility, // parry modifier = agility modifier
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCharacterStats(chars) {
|
||||||
|
// RQ3: total HP = ceil((CON + SIZ) / 2), NOT CON + SIZ
|
||||||
|
const totalHp = Math.ceil((chars.con + chars.siz) / 2);
|
||||||
|
const skillModifiers = computeSkillCategoryModifiers(chars);
|
||||||
|
return {
|
||||||
|
totalHp,
|
||||||
|
fatigue: chars.str + chars.con,
|
||||||
|
magicPoints: chars.pow,
|
||||||
|
damageBonus: damageBonusNotation(chars.str + chars.siz),
|
||||||
|
strikeRank: baseStrikeRank({ dex: chars.dex, siz: chars.siz }),
|
||||||
|
hitLocations: computeHitLocations(totalHp),
|
||||||
|
skillModifiers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Base Skills ----------
|
||||||
|
// Skills available without training, derived from base chance + category modifier.
|
||||||
|
// Skills with a 0 base chance are excluded (they must be trained before the modifier applies).
|
||||||
|
|
||||||
|
const BASE_SKILLS = {
|
||||||
|
// Agility
|
||||||
|
boat: 5, climb: 40, dodge: 5, jump: 25, ride: 5, swim: 15, throw: 25,
|
||||||
|
// Communication
|
||||||
|
fastTalk: 5, orate: 5, sing: 5,
|
||||||
|
// Knowledge
|
||||||
|
firstAid: 10, animalLore: 5, humanLore: 5, mineralLore: 5, plantLore: 5, worldLore: 5,
|
||||||
|
// Manipulation
|
||||||
|
conceal: 5, sleight: 5, devise: 5,
|
||||||
|
// Perception
|
||||||
|
listen: 25, scan: 25, search: 25, track: 5,
|
||||||
|
// Stealth
|
||||||
|
hide: 10, sneak: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL_CATEGORY_MAP = {
|
||||||
|
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',
|
||||||
|
conceal: 'manipulation', sleight: 'manipulation', devise: 'manipulation',
|
||||||
|
listen: 'perception', scan: 'perception', search: 'perception', track: 'perception',
|
||||||
|
hide: 'stealth', sneak: 'stealth',
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeBaseSkills(chars) {
|
||||||
|
const mods = computeSkillCategoryModifiers(chars);
|
||||||
|
const result = {};
|
||||||
|
for (const [skill, base] of Object.entries(BASE_SKILLS)) {
|
||||||
|
result[skill] = Math.max(0, base + (mods[SKILL_CATEGORY_MAP[skill]] || 0));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Adventure Scene Choices ----------
|
||||||
|
// Procedural choices offered for every encounter scene.
|
||||||
|
// 'fight' is always available and triggers combat rather than a skill roll.
|
||||||
|
|
||||||
|
const SCENE_CHOICES = [
|
||||||
|
{ id: 'fight', label: 'Fight', skill: null, safe: false },
|
||||||
|
{ id: 'sneak', label: 'Sneak past', skill: 'sneak', safe: false },
|
||||||
|
{ id: 'talk', label: 'Talk your way out', skill: 'fastTalk', safe: false },
|
||||||
|
{ id: 'investigate', label: 'Investigate', skill: 'scan', safe: true },
|
||||||
|
{ id: 'flee', label: 'Flee', skill: 'dodge', safe: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve a scene choice against a skill percent.
|
||||||
|
// Returns { tier, roll, outcome, escalates } where escalates=true means the scene
|
||||||
|
// should turn hostile (offer fight).
|
||||||
|
function resolveSceneChoice(choiceId, skillPercent) {
|
||||||
|
if (choiceId === 'fight') {
|
||||||
|
return { tier: null, roll: null, outcome: 'combat', escalates: false };
|
||||||
|
}
|
||||||
|
const check = resolveSkillCheck(skillPercent);
|
||||||
|
const choice = SCENE_CHOICES.find((c) => c.id === choiceId);
|
||||||
|
let outcome, escalates;
|
||||||
|
if (choiceId === 'sneak') {
|
||||||
|
if (check.tier === 'critical' || check.tier === 'special' || check.tier === 'success') {
|
||||||
|
outcome = check.tier === 'critical' ? 'You slipped past completely undetected — they have no idea you were ever there.'
|
||||||
|
: check.tier === 'special' ? 'You slipped past without a sound.'
|
||||||
|
: 'You crept past, just barely unnoticed.';
|
||||||
|
escalates = false;
|
||||||
|
} else if (check.tier === 'fumble') {
|
||||||
|
outcome = 'You stumbled noisily — they spotted you!'; escalates = true;
|
||||||
|
} else {
|
||||||
|
outcome = 'You were spotted. The situation turns hostile.'; escalates = true;
|
||||||
|
}
|
||||||
|
} else if (choiceId === 'talk') {
|
||||||
|
if (check.tier === 'critical') {
|
||||||
|
outcome = 'An inspired performance — they are charmed and share useful information.'; escalates = false;
|
||||||
|
} else if (check.tier === 'special' || check.tier === 'success') {
|
||||||
|
outcome = 'They buy it. The situation de-escalates.'; escalates = false;
|
||||||
|
} else if (check.tier === 'fumble') {
|
||||||
|
outcome = 'You said exactly the wrong thing. They are furious.'; escalates = true;
|
||||||
|
} else {
|
||||||
|
outcome = "They're not convinced. The tension remains."; escalates = false;
|
||||||
|
}
|
||||||
|
} else if (choiceId === 'investigate') {
|
||||||
|
if (check.tier === 'critical' || check.tier === 'special' || check.tier === 'success') {
|
||||||
|
outcome = check.tier === 'critical' ? 'Excellent observation — you notice every detail of the situation.'
|
||||||
|
: check.tier === 'special' ? 'You pick up on something others would have missed.'
|
||||||
|
: 'You observe the situation carefully and learn something useful.';
|
||||||
|
} else {
|
||||||
|
outcome = "You couldn't make out anything useful from this distance.";
|
||||||
|
}
|
||||||
|
escalates = false;
|
||||||
|
} else if (choiceId === 'flee') {
|
||||||
|
if (check.tier === 'critical' || check.tier === 'special' || check.tier === 'success') {
|
||||||
|
outcome = check.tier === 'fumble' ? '' : 'You broke away cleanly.'; escalates = false;
|
||||||
|
} else if (check.tier === 'fumble') {
|
||||||
|
outcome = 'You tripped! They close in with the advantage.'; escalates = true;
|
||||||
|
} else {
|
||||||
|
outcome = 'They caught up — you cannot escape.'; escalates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tier: check.tier, roll: check.roll, skillPercent, outcome, escalates, choiceId };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Strike Rank ----------
|
// ---------- Strike Rank ----------
|
||||||
|
|
||||||
// SR = DEX Strike Rank + SIZ Strike Rank Modifier (no INT). Weapon SR adds on top for attacks.
|
// SR = DEX Strike Rank + SIZ Strike Rank Modifier (no INT). Weapon SR adds on top for attacks.
|
||||||
@@ -898,6 +1081,16 @@ module.exports = {
|
|||||||
maxNotation,
|
maxNotation,
|
||||||
rollPercentile,
|
rollPercentile,
|
||||||
rollCharacteristics,
|
rollCharacteristics,
|
||||||
|
DELIBERATE_METHOD,
|
||||||
|
validateDeliberate,
|
||||||
|
validateCombined,
|
||||||
|
computeSkillCategoryModifiers,
|
||||||
|
deriveCharacterStats,
|
||||||
|
BASE_SKILLS,
|
||||||
|
SKILL_CATEGORY_MAP,
|
||||||
|
computeBaseSkills,
|
||||||
|
SCENE_CHOICES,
|
||||||
|
resolveSceneChoice,
|
||||||
dexStrikeRank,
|
dexStrikeRank,
|
||||||
sizStrikeRankModifier,
|
sizStrikeRankModifier,
|
||||||
baseStrikeRank,
|
baseStrikeRank,
|
||||||
|
|||||||
@@ -248,6 +248,175 @@ app.get('/api/export/log', (req, res) => {
|
|||||||
res.send(md);
|
res.send(md);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Player Characters ----------
|
||||||
|
|
||||||
|
app.get('/api/characters', (req, res) => {
|
||||||
|
res.json(dbApi.playerCharacters.list());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/roll', (req, res) => {
|
||||||
|
const { method } = req.body || {};
|
||||||
|
if (method !== 'random' && method !== 'combined') {
|
||||||
|
return res.status(400).json({ error: 'method must be random or combined' });
|
||||||
|
}
|
||||||
|
const chars = rq3.rollCharacteristics();
|
||||||
|
const derived = rq3.deriveCharacterStats(chars);
|
||||||
|
res.json({ chars, derived, bonusPoints: method === 'combined' ? 6 : 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/derive', (req, res) => {
|
||||||
|
const { str, con, siz, int, pow, dex, app: app_ } = req.body || {};
|
||||||
|
const chars = { str: Number(str) || 0, con: Number(con) || 0, siz: Number(siz) || 0, int: Number(int) || 0, pow: Number(pow) || 0, dex: Number(dex) || 0, app: Number(app_) || 0 };
|
||||||
|
const derived = rq3.deriveCharacterStats(chars);
|
||||||
|
res.json({ chars, derived });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/validate', (req, res) => {
|
||||||
|
const { method, chars } = req.body || {};
|
||||||
|
if (method === 'deliberate') return res.json(rq3.validateDeliberate(chars || {}));
|
||||||
|
if (method === 'combined') return res.json(rq3.validateCombined(chars || {}));
|
||||||
|
return res.status(400).json({ error: 'method must be deliberate or combined' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters', (req, res) => {
|
||||||
|
const { name, generation_method, chars } = req.body || {};
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
if (!chars) return res.status(400).json({ error: 'chars is required' });
|
||||||
|
|
||||||
|
const derived = rq3.deriveCharacterStats(chars);
|
||||||
|
const statBlock = {
|
||||||
|
str: chars.str, con: chars.con, siz: chars.siz, int: chars.int,
|
||||||
|
pow: chars.pow, dex: chars.dex, app: chars.app,
|
||||||
|
max_hp: derived.totalHp, current_hp: derived.totalHp,
|
||||||
|
magic_points_max: derived.magicPoints, magic_points_current: derived.magicPoints,
|
||||||
|
};
|
||||||
|
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock });
|
||||||
|
dbApi.statBlocks.setHitLocations(pc.stat_block_id, derived.hitLocations);
|
||||||
|
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived } });
|
||||||
|
res.status(201).json(dbApi.playerCharacters.get(pc.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/characters/:id', (req, res) => {
|
||||||
|
const ok = dbApi.playerCharacters.delete(Number(req.params.id));
|
||||||
|
if (!ok) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Inventory ----------
|
||||||
|
|
||||||
|
app.get('/api/characters/:id/inventory', (req, res) => {
|
||||||
|
const items = dbApi.inventory.list(Number(req.params.id));
|
||||||
|
const totalEnc = dbApi.inventory.totalEnc(Number(req.params.id));
|
||||||
|
res.json({ items, totalEnc });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/inventory', (req, res) => {
|
||||||
|
const { name, quantity, enc, category, notes } = req.body || {};
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const item = dbApi.inventory.add({ character_id: Number(req.params.id), name, quantity, enc, category, notes });
|
||||||
|
res.status(201).json(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/characters/:id/inventory/:itemId', (req, res) => {
|
||||||
|
const item = dbApi.inventory.update(Number(req.params.itemId), req.body || {});
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
res.json(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/characters/:id/inventory/:itemId', (req, res) => {
|
||||||
|
const ok = dbApi.inventory.delete(Number(req.params.itemId));
|
||||||
|
if (!ok) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Adventure ----------
|
||||||
|
|
||||||
|
app.get('/api/adventure', (req, res) => {
|
||||||
|
res.json(dbApi.adventure.get());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/adventure/start', (req, res) => {
|
||||||
|
const { characterId, tableId } = req.body || {};
|
||||||
|
const pc = dbApi.playerCharacters.get(Number(characterId));
|
||||||
|
if (!pc) return res.status(400).json({ error: 'Unknown character' });
|
||||||
|
|
||||||
|
// Roll a table to generate the scene description
|
||||||
|
let tableResult = null;
|
||||||
|
let description = 'You find yourself in an unexpected situation.';
|
||||||
|
if (tableId) {
|
||||||
|
tableResult = dbApi.tables.roll(Number(tableId));
|
||||||
|
if (tableResult) {
|
||||||
|
// Cascade into a linked sub-table if available
|
||||||
|
const link = tableResult.links && tableResult.links[0];
|
||||||
|
let subResult = null;
|
||||||
|
if (link) {
|
||||||
|
subResult = dbApi.tables.roll(link.target_table_id);
|
||||||
|
}
|
||||||
|
const cells = tableResult.row.cells.filter((c) => c && !/^\d+[-–]\d+$/.test(c.trim()) && !/^\d+$/.test(c.trim()));
|
||||||
|
const subCells = subResult ? subResult.row.cells.filter((c) => c && !/^\d+[-–]\d+$/.test(c.trim()) && !/^\d+$/.test(c.trim())) : [];
|
||||||
|
description = [...cells, ...subCells].join(' — ') || description;
|
||||||
|
if (subResult) tableResult.subResult = subResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sb = pc.stat_block;
|
||||||
|
const chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
|
||||||
|
const baseSkills = rq3.computeBaseSkills(chars);
|
||||||
|
const enc = dbApi.inventory.totalEnc(pc.id);
|
||||||
|
const effectiveFp = (chars.str + chars.con) - enc;
|
||||||
|
|
||||||
|
const choices = rq3.SCENE_CHOICES.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
skill: c.skill,
|
||||||
|
skillPercent: c.skill ? baseSkills[c.skill] ?? 0 : null,
|
||||||
|
safe: c.safe,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scene = {
|
||||||
|
status: 'active',
|
||||||
|
description,
|
||||||
|
tableResult: tableResult ? { tableId, tableName: tableResult.table.name, rowCells: tableResult.row.cells } : null,
|
||||||
|
choices,
|
||||||
|
effectiveFp,
|
||||||
|
selectedChoice: null,
|
||||||
|
resolution: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
dbApi.log.append({ type: 'adventure', summary: `Scene started: ${description.slice(0, 80)}`, details: { characterId, description } });
|
||||||
|
res.json(dbApi.adventure.set(pc.id, scene));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/adventure/choose', (req, res) => {
|
||||||
|
const { choiceId } = req.body || {};
|
||||||
|
const state = dbApi.adventure.get();
|
||||||
|
if (!state) return res.status(400).json({ error: 'No active adventure scene' });
|
||||||
|
if (state.scene.status !== 'active') return res.status(400).json({ error: 'Scene already resolved' });
|
||||||
|
|
||||||
|
const choice = state.scene.choices.find((c) => c.id === choiceId);
|
||||||
|
if (!choice) return res.status(400).json({ error: 'Unknown choice' });
|
||||||
|
|
||||||
|
const resolution = rq3.resolveSceneChoice(choiceId, choice.skillPercent);
|
||||||
|
const scene = {
|
||||||
|
...state.scene,
|
||||||
|
status: resolution.outcome === 'combat' ? 'combat' : resolution.escalates ? 'escalated' : 'resolved',
|
||||||
|
selectedChoice: choiceId,
|
||||||
|
resolution,
|
||||||
|
};
|
||||||
|
|
||||||
|
dbApi.log.append({
|
||||||
|
type: 'adventure',
|
||||||
|
summary: `Scene choice: ${choice.label} → ${resolution.outcome === 'combat' ? 'combat' : resolution.outcome}`,
|
||||||
|
details: resolution,
|
||||||
|
});
|
||||||
|
res.json(dbApi.adventure.set(state.characterId, scene));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/adventure', (req, res) => {
|
||||||
|
dbApi.adventure.clear();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Combat ----------
|
// ---------- Combat ----------
|
||||||
|
|
||||||
app.get('/api/combat', (req, res) => {
|
app.get('/api/combat', (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user