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:
2026-07-03 16:00:54 +10:00
parent c223912915
commit 8bccf6f484
7 changed files with 5320 additions and 1 deletions
+169
View File
@@ -248,6 +248,175 @@ app.get('/api/export/log', (req, res) => {
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 ----------
app.get('/api/combat', (req, res) => {