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
+62 -14
View File
@@ -245,11 +245,11 @@ function setHitLocations(statBlockId, locations) {
function setWeapons(statBlockId, weapons) {
db.prepare('DELETE FROM combatant_weapons WHERE stat_block_id = ?').run(statBlockId);
const stmt = db.prepare(`
INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, mode, experience_checked)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, parry_percent, mode, experience_checked)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const w of weapons) {
stmt.run(statBlockId, w.weapon_name, w.category ?? null, w.skill_percent ?? 0, w.mode ?? null, w.experience_checked ? 1 : 0);
stmt.run(statBlockId, w.weapon_name, w.category ?? null, w.skill_percent ?? 0, w.parry_percent ?? 0, w.mode ?? null, w.experience_checked ? 1 : 0);
}
}
@@ -279,16 +279,25 @@ const NPC_FIELDS = [
'secret_obstacle', 'also_carrying', 'race', 'pronouns', 'age', 'intelligence', 'hair', 'build', 'status',
];
function parseTraits(row) {
return { ...row, traits: row.traits_json ? JSON.parse(row.traits_json) : [] };
}
function listNpcs() {
const npcs = db.prepare('SELECT * FROM npcs ORDER BY id DESC').all();
return npcs.map((n) => ({ ...n, stat_block: n.stat_block_id ? getStatBlock(n.stat_block_id) : null }));
return npcs.map((n) => ({ ...parseTraits(n), stat_block: n.stat_block_id ? getStatBlock(n.stat_block_id) : null }));
}
function getNpc(id) {
const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id);
if (!npc) return null;
npc.stat_block = npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null;
return npc;
return { ...parseTraits(npc), stat_block: npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null };
}
function updateNpcTraits(id, traits) {
db.prepare(`UPDATE npcs SET traits_json=?, updated_at=datetime('now') WHERE id=?`)
.run(JSON.stringify(traits), id);
return getNpc(id);
}
function createNpc(data) {
@@ -432,25 +441,38 @@ function clearAdventureState() {
db.prepare('DELETE FROM adventure_state WHERE id = 1').run();
}
for (const sql of [
'ALTER TABLE player_characters ADD COLUMN age INTEGER NOT NULL DEFAULT 21',
'ALTER TABLE player_characters ADD COLUMN culture TEXT',
'ALTER TABLE player_characters ADD COLUMN occupation TEXT',
'ALTER TABLE player_characters ADD COLUMN current_location TEXT',
'ALTER TABLE player_characters ADD COLUMN destination TEXT',
'ALTER TABLE combatant_weapons ADD COLUMN parry_percent INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE player_characters ADD COLUMN traits_json TEXT',
'ALTER TABLE npcs ADD COLUMN traits_json TEXT',
]) {
try { db.exec(sql); } catch (_) {}
}
// ---------- 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) }));
return rows.map((r) => ({ ...parseTraits(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) };
return { ...parseTraits(row), stat_block: getStatBlock(row.stat_block_id) };
}
function createPlayerCharacter({ name, generation_method, stat_block }) {
function createPlayerCharacter({ name, generation_method, stat_block, age, culture, occupation }) {
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);
INSERT INTO player_characters (name, generation_method, stat_block_id, age, culture, occupation)
VALUES (?, ?, ?, ?, ?, ?)
`).run(name, generation_method ?? 'random', statBlockId, age ?? 21, culture ?? null, occupation ?? null);
return getPlayerCharacter(info.lastInsertRowid);
}
@@ -462,6 +484,19 @@ function deletePlayerCharacter(id) {
return true;
}
function updatePlayerCharacterTraits(id, traits) {
db.prepare(`UPDATE player_characters SET traits_json=?, updated_at=datetime('now') WHERE id=?`)
.run(JSON.stringify(traits), id);
return getPlayerCharacter(id);
}
function updatePlayerCharacterLocation(id, { current_location, destination }) {
db.prepare(`
UPDATE player_characters SET current_location=?, destination=?, updated_at=datetime('now') WHERE id=?
`).run(current_location ?? null, destination ?? null, id);
return getPlayerCharacter(id);
}
// ---------- log ----------
function appendLogEntry({ type, summary, details }) {
@@ -645,6 +680,18 @@ function exportAll() {
};
}
function clearAll() {
db.transaction(() => {
db.prepare('DELETE FROM adventure_state').run(); // references player_characters
db.prepare('DELETE FROM player_characters').run(); // cascades inventory_items
db.prepare('DELETE FROM npcs').run();
db.prepare('DELETE FROM enemies').run();
db.prepare('DELETE FROM stat_blocks').run(); // cascades hit_locations, weapons, spells
db.prepare('DELETE FROM log_entries').run();
db.prepare('DELETE FROM combat_state').run();
})();
}
function importAll(dump) {
const tx = db.transaction(() => {
db.prepare('DELETE FROM npcs').run();
@@ -689,9 +736,9 @@ module.exports = {
markWeaponExperienceChecked,
updateWeaponSkillPercent,
},
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, updateTraits: updateNpcTraits },
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter },
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter, updateLocation: updatePlayerCharacterLocation, updateTraits: updatePlayerCharacterTraits },
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 },
@@ -712,6 +759,7 @@ module.exports = {
getRowsForTable,
},
spellMappings: { list: listSpellMappings, upsert: upsertSpellMapping },
clearAll,
exportAll,
importAll,
};