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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user