feat: full character creation, personality traits, and session quality-of-life

Character creation:
- Full RQ3 Previous Experience wizard (4-step: identity, characteristics,
  culture/occupation/skills, review+save) with all 29 occupations across
  4 cultures; skills computed as base + category modifier + years × multiplier
- Character sheet expanded to show culture, occupation, derived stats (HP/FP/MP/
  DB/SR), all 7 skill category modifier badges, computed skill percentages grouped
  by category, weapon attack/parry %, hit locations with armour AP
- Location/destination tracker on character sheet (auto-saves on blur)
- parry_percent stored on combatant_weapons

Personality traits:
- 24 predefined traits (Brave, Greedy, Cautious, etc.) each rated 0-100%
- Trait → action bias map drives scene choice weighting
- rollPersonalityAction() rolls d100 per trait, sums biases, returns suggestion
- Trait editor (pill UI) on both character sheet and NPC view
- Adventure tab: Roll Personality button fires traits, highlights suggested choice

Tables and data:
- Import RQ3 character creation tables (Culture d8, Occupation d100 ×4, Craft
  sub-tables, Language Proficiency, Dropped Oil Lamp, Aging, Armor Points)
- Cross-table links: Culture → Occupation, Barbarian/Civilized Crafter → Craft
- Fix rollOnTable to use actual dice notation instead of flat random row index
- Remove unused resolveHeadInjury function

Session tools:
- Clear All button wipes all session data (characters, NPCs, enemies, combat,
  adventure, log) while keeping tables and spell mappings
- PATCH /api/characters/:id/traits and /api/npcs/:id/traits endpoints
- GET /api/rules/personality-traits reference endpoint
- POST /api/adventure/personality-roll endpoint
This commit is contained in:
2026-07-03 21:44:11 +10:00
parent 8bccf6f484
commit 5e83fe1ef2
8 changed files with 2263 additions and 254 deletions
+104 -4
View File
@@ -250,8 +250,67 @@ app.get('/api/export/log', (req, res) => {
// ---------- Player Characters ----------
function enrichCharacter(pc) {
if (!pc || !pc.stat_block) return pc;
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 };
pc.derived = rq3.deriveCharacterStats(chars);
if (pc.culture && pc.occupation) {
const years = Math.max(0, (pc.age || 21) - 15);
const occ = rq3.computeOccupationSkills(chars, pc.culture, pc.occupation, years);
if (occ) {
pc.skills = occ.skills;
pc.craftBonuses = occ.craftBonuses;
pc.ritualBonuses = occ.ritualBonuses;
pc.occupation_label = occ.occupation && occ.occupation.label;
}
} else {
pc.skills = rq3.computeBaseSkills(chars);
}
return pc;
}
app.get('/api/characters', (req, res) => {
res.json(dbApi.playerCharacters.list());
res.json(dbApi.playerCharacters.list().map(enrichCharacter));
});
app.get('/api/characters/:id', (req, res) => {
const pc = dbApi.playerCharacters.get(Number(req.params.id));
if (!pc) return res.status(404).json({ error: 'Character not found' });
res.json(enrichCharacter(pc));
});
app.patch('/api/characters/:id/location', (req, res) => {
const { current_location, destination } = req.body || {};
const pc = dbApi.playerCharacters.updateLocation(Number(req.params.id), { current_location, destination });
if (!pc) return res.status(404).json({ error: 'Character not found' });
res.json({ current_location: pc.current_location, destination: pc.destination });
});
app.post('/api/characters/roll-age', (req, res) => {
res.json({ age: rq3.rollAge() });
});
app.post('/api/characters/compute-occupation', (req, res) => {
const { characterId, culture, occupation, years } = req.body || {};
if (!culture || !occupation) return res.status(400).json({ error: 'culture and occupation are required' });
let chars;
if (characterId) {
const pc = dbApi.playerCharacters.get(Number(characterId));
if (!pc) return res.status(404).json({ error: 'Character not found' });
const sb = pc.stat_block;
chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
} else if (req.body.chars) {
chars = req.body.chars;
} else {
return res.status(400).json({ error: 'characterId or chars is required' });
}
try {
const result = rq3.computeOccupationSkills(chars, culture, occupation, Number(years) || 0);
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/characters/roll', (req, res) => {
@@ -279,7 +338,7 @@ app.post('/api/characters/validate', (req, res) => {
});
app.post('/api/characters', (req, res) => {
const { name, generation_method, chars } = req.body || {};
const { name, generation_method, chars, age, culture, occupation, weapons } = req.body || {};
if (!name) return res.status(400).json({ error: 'name is required' });
if (!chars) return res.status(400).json({ error: 'chars is required' });
@@ -289,10 +348,14 @@ app.post('/api/characters', (req, res) => {
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,
culture: culture ?? null,
};
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock });
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock, age, culture, occupation });
dbApi.statBlocks.setHitLocations(pc.stat_block_id, derived.hitLocations);
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived } });
if (weapons && Array.isArray(weapons) && weapons.length) {
dbApi.statBlocks.setWeapons(pc.stat_block_id, weapons);
}
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived, age, culture, occupation } });
res.status(201).json(dbApi.playerCharacters.get(pc.id));
});
@@ -302,6 +365,38 @@ app.delete('/api/characters/:id', (req, res) => {
res.status(204).end();
});
app.patch('/api/characters/:id/traits', (req, res) => {
const traits = req.body && Array.isArray(req.body.traits) ? req.body.traits : [];
const pc = dbApi.playerCharacters.updateTraits(Number(req.params.id), traits);
if (!pc) return res.status(404).json({ error: 'Character not found' });
res.json({ traits: pc.traits || [] });
});
app.patch('/api/npcs/:id/traits', (req, res) => {
const traits = req.body && Array.isArray(req.body.traits) ? req.body.traits : [];
const npc = dbApi.npcs.updateTraits(Number(req.params.id), traits);
if (!npc) return res.status(404).json({ error: 'NPC not found' });
res.json({ traits: npc.traits || [] });
});
app.get('/api/rules/personality-traits', (req, res) => {
res.json({ traits: rq3.PERSONALITY_TRAITS, biases: rq3.TRAIT_ACTION_BIAS });
});
app.post('/api/adventure/personality-roll', (req, res) => {
const state = dbApi.adventure.get();
if (!state) return res.status(400).json({ error: 'No active adventure scene' });
const pc = dbApi.playerCharacters.get(state.characterId);
if (!pc) return res.status(400).json({ error: 'No character for active adventure' });
const result = rq3.rollPersonalityAction(pc.traits || []);
dbApi.log.append({
type: 'adventure',
summary: `Personality roll for ${pc.name}: suggested ${result.suggested || 'none'} (${result.firedTraits.map(t => t.name).join(', ') || 'no traits fired'})`,
details: result,
});
res.json(result);
});
// ---------- Inventory ----------
app.get('/api/characters/:id/inventory', (req, res) => {
@@ -747,6 +842,11 @@ app.post('/api/import', (req, res) => {
res.json(dump);
});
app.post('/api/clear-all', (req, res) => {
dbApi.clearAll();
res.status(204).end();
});
// ---------- Errors ----------
app.use((req, res) => {