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
+21 -5
View File
@@ -2,28 +2,44 @@
## High priority (mechanics gaps) ## High priority (mechanics gaps)
- [ ] Call `resolveHeadInjury()` in the attack handler when a Head hit penetrates armor - [x] Fix `rollOnTable` to roll the table's actual dice notation instead of picking a flat random row — now affects the adventure engine (Culture Table, Occupation Tables) where unequal row ranges matter
- [x] Resolve stuck-weapon follow-up after impale/slash special hits (`attemptWeaponRemoval`, `removeStuckWeaponFromSelf`, `removeStuckWeaponWithFirstAid`) - [x] Resolve stuck-weapon follow-up after impale/slash special hits (`attemptWeaponRemoval`, `removeStuckWeaponFromSelf`, `removeStuckWeaponWithFirstAid`)
## Medium priority (missing features) ## Medium priority (missing features)
### Adventure / CYOA
- [x] Add character location / destination tracker — where the PC currently is and where they are heading; used to auto-select which encounter table to roll
- [ ] Add Layer 3 (and possibly Layer 4) sub-tables to the Norse encounter tables so scene descriptions cascade into richer detail
- [ ] Add a Previous Experience step to the PC generator (culture → occupation → skills, magic, equipment) using the imported occupation tables
- [ ] Wire PC weapon skills into adventure scene choices so "Fight" shows the correct attack %
- [ ] Persist scene history so the adventure log reads as a continuous narrative
### Combat
- [ ] 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`)
### Other
- [ ] 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)
- [ ] Wire up the export reminder modal (show on `beforeunload`, hook up Export Now / Leave Anyway / Cancel buttons) - [ ] Wire up the export reminder modal (show on `beforeunload`, hook up Export Now / Leave Anyway / Cancel buttons)
- [ ] Add `DELETE /api/spell-mappings/:id` endpoint and a spell mapping management UI - [ ] Add `DELETE /api/spell-mappings/:id` endpoint and a spell mapping management UI
- [ ] Add weapon and spell editing forms to NPC stat block view (currently only available for enemies) - [ ] Add weapon and spell editing forms to NPC stat block view (currently only available for enemies)
- [ ] Add item editing (change qty inline) and ENC-per-item display to inventory UI
## Correctness fixes ## Correctness fixes
- [ ] Fix `rollOnTable` to roll the table's actual dice notation and match the range, instead of picking a flat random row index
- [ ] Fix `state.dirty` being set to `true` on GET requests - [ ] Fix `state.dirty` being set to `true` on GET requests
## Cleanup ## Cleanup
- [ ] Remove dead `state.reactionType` from frontend state - [ ] Remove dead `state.reactionType` from frontend state
## Done
- [x] Player character generator — random/deliberate/combined methods, correct RQ3 derived stats
- [x] Fix total HP formula (was CON+SIZ, now ceil((CON+SIZ)/2)); fix hit location HPs
- [x] Skill category modifiers (all 7, primary/secondary/negative influences) + fatigue points
- [x] Inventory system — items with ENC tracking, effective FP = STR+CON total ENC
- [x] Adventure scene engine — procedural CYOA choices resolved via skill checks; scene state persisted
- [x] Import RQ3 character creation tables (Culture, Occupation, Craft, Language, Aging, etc.) with cross-table links
+62 -14
View File
@@ -245,11 +245,11 @@ function setHitLocations(statBlockId, locations) {
function setWeapons(statBlockId, weapons) { function setWeapons(statBlockId, weapons) {
db.prepare('DELETE FROM combatant_weapons WHERE stat_block_id = ?').run(statBlockId); db.prepare('DELETE FROM combatant_weapons WHERE stat_block_id = ?').run(statBlockId);
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, mode, experience_checked) INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, parry_percent, mode, experience_checked)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
for (const w of weapons) { 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', '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() { function listNpcs() {
const npcs = db.prepare('SELECT * FROM npcs ORDER BY id DESC').all(); 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) { function getNpc(id) {
const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id); const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id);
if (!npc) return null; if (!npc) return null;
npc.stat_block = npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null; return { ...parseTraits(npc), stat_block: npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null };
return npc; }
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) { function createNpc(data) {
@@ -432,25 +441,38 @@ function clearAdventureState() {
db.prepare('DELETE FROM adventure_state WHERE id = 1').run(); 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 ---------- // ---------- player characters ----------
function listPlayerCharacters() { function listPlayerCharacters() {
const rows = db.prepare('SELECT * FROM player_characters ORDER BY id DESC').all(); 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) { function getPlayerCharacter(id) {
const row = db.prepare('SELECT * FROM player_characters WHERE id = ?').get(id); const row = db.prepare('SELECT * FROM player_characters WHERE id = ?').get(id);
if (!row) return null; 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 statBlockId = createStatBlock(stat_block || {}).id;
const info = db.prepare(` const info = db.prepare(`
INSERT INTO player_characters (name, generation_method, stat_block_id) INSERT INTO player_characters (name, generation_method, stat_block_id, age, culture, occupation)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(name, generation_method ?? 'random', statBlockId); `).run(name, generation_method ?? 'random', statBlockId, age ?? 21, culture ?? null, occupation ?? null);
return getPlayerCharacter(info.lastInsertRowid); return getPlayerCharacter(info.lastInsertRowid);
} }
@@ -462,6 +484,19 @@ function deletePlayerCharacter(id) {
return true; 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 ---------- // ---------- log ----------
function appendLogEntry({ type, summary, details }) { 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) { function importAll(dump) {
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare('DELETE FROM npcs').run(); db.prepare('DELETE FROM npcs').run();
@@ -689,9 +736,9 @@ module.exports = {
markWeaponExperienceChecked, markWeaponExperienceChecked,
updateWeaponSkillPercent, 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 }, 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 }, inventory: { list: listInventory, add: addInventoryItem, update: updateInventoryItem, delete: deleteInventoryItem, totalEnc: inventoryTotalEnc },
adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState }, adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState },
log: { append: appendLogEntry, get: getLogEntry, search: searchLog }, log: { append: appendLogEntry, get: getLogEntry, search: searchLog },
@@ -712,6 +759,7 @@ module.exports = {
getRowsForTable, getRowsForTable,
}, },
spellMappings: { list: listSpellMappings, upsert: upsertSpellMapping }, spellMappings: { list: listSpellMappings, upsert: upsertSpellMapping },
clearAll,
exportAll, exportAll,
importAll, importAll,
}; };
+1091 -231
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -14,6 +14,7 @@
<input type="file" id="import-file" accept="application/json" hidden> <input type="file" id="import-file" accept="application/json" hidden>
<button id="export-all-btn" class="button">Export All</button> <button id="export-all-btn" class="button">Export All</button>
<button id="export-log-btn" class="button">Export Log</button> <button id="export-log-btn" class="button">Export Log</button>
<button id="clear-all-btn" class="button" style="color:red">Clear All</button>
</div> </div>
</header> </header>
+745
View File
@@ -718,6 +718,73 @@ function sumAttackModifiers(selectedIds, { targetSiz } = {}) {
}, 0); }, 0);
} }
// ---------- Personality Traits ----------
// Traits are stored as { name, rating } where rating is 0-100%.
// A trait roll succeeds when d100 <= rating, indicating the character acts on it.
const PERSONALITY_TRAITS = [
'Brave', 'Cowardly', 'Honest', 'Deceitful', 'Generous', 'Greedy',
'Loyal', 'Treacherous', 'Curious', 'Cautious', 'Reckless', 'Aggressive',
'Peaceful', 'Patient', 'Impulsive', 'Proud', 'Vengeful', 'Forgiving',
'Pious', 'Cynical', 'Suspicious', 'Trusting', 'Compassionate', 'Ruthless',
];
// Bias each scene choice ID by ±% when this trait fires.
// Positive = more inclined; negative = less inclined.
const TRAIT_ACTION_BIAS = {
Brave: { fight: +25, flee: -20 },
Cowardly: { fight: -25, flee: +30, sneak: +15 },
Aggressive: { fight: +30, talk: -15 },
Peaceful: { fight: -20, talk: +20 },
Curious: { investigate: +30 },
Greedy: { investigate: +20 },
Cautious: { investigate: +15, fight: -15, flee: +10 },
Reckless: { fight: +20, flee: -25 },
Deceitful: { talk: +25 },
Honest: { talk: -10 },
Suspicious: { talk: -15, investigate: +15 },
Trusting: { talk: +15 },
Loyal: { fight: +15, flee: -15 },
Treacherous: { flee: +15 },
Vengeful: { fight: +20 },
Forgiving: { talk: +20, fight: -10 },
Patient: { investigate: +20, sneak: +15 },
Impulsive: { fight: +15, sneak: -10 },
Proud: { flee: -20, fight: +10 },
Ruthless: { fight: +15, flee: +10 },
Compassionate:{ fight: -15, talk: +20 },
Pious: { fight: +10 },
Cynical: { talk: -10 },
Generous: { talk: +10 },
};
// Roll each trait against its rating. Returns { firedTraits, choiceBiases, suggested }.
// choiceBiases: { choiceId → net bias } summed across all fired traits.
// suggested: the choice ID with the highest net positive bias (null if none).
function rollPersonalityAction(traits) {
const fired = [];
const biases = {};
for (const trait of (traits || [])) {
const roll = rollPercentile();
const success = roll <= trait.rating;
if (success) {
fired.push({ name: trait.name, roll, rating: trait.rating });
const b = TRAIT_ACTION_BIAS[trait.name] || {};
for (const [choiceId, delta] of Object.entries(b)) {
biases[choiceId] = (biases[choiceId] || 0) + delta;
}
}
}
const suggested = Object.entries(biases).sort((a, b) => b[1] - a[1]).find(([, v]) => v > 0);
return {
firedTraits: fired,
choiceBiases: biases,
suggested: suggested ? suggested[0] : null,
};
}
// ---------- Character Culture & Cultural Weapon Bonuses ---------- // ---------- Character Culture & Cultural Weapon Bonuses ----------
const CHARACTER_CULTURES = [ const CHARACTER_CULTURES = [
@@ -1074,6 +1141,678 @@ function buildStrikeRankSchedule(actions) {
return schedule; return schedule;
} }
// ---------- Age ----------
function rollAge() { return rollNotation('2d6') + 15; }
// ---------- Occupations ----------
// skills entries: { key, mult, group? }
// group flags OR-choice groups; the user picks exactly one from each group.
// Weapon skill keys: 'attack:fist', 'attack:dagger', 'attack:primary', 'attack:missile',
// 'attack:1hweapon', 'attack:2hweapon', 'attack:spear1h', 'attack:spear2h',
// 'parry:weapon', 'parry:shield'
// Ritual keys: 'ritual:ceremony', 'ritual:enchant', 'ritual:summon'
// Craft keys: 'craft:leather', 'craft:stone', 'craft:wood'
const OCCUPATIONS = {
Primitive: {
fisher: {
label: 'Fisher',
skills: [
{ key: 'boat', mult: 4 },
{ key: 'swim', mult: 3 },
{ key: 'firstAid', mult: 1 },
{ key: 'listen', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Net or fishing spear, knife, canoe or coracle',
},
hunter: {
label: 'Hunter',
skills: [
{ key: 'throw', mult: 3 },
{ key: 'craft:leather', mult: 1 },
{ key: 'craft:stone', mult: 1 },
{ key: 'animalLore', mult: 2 },
{ key: 'plantLore', mult: 2 },
{ key: 'listen', mult: 3 },
{ key: 'scan', mult: 3 },
{ key: 'track', mult: 3 },
{ key: 'hide', mult: 3 },
{ key: 'sneak', mult: 4 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:missile', mult: 3 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Primary weapon, missile weapon, knife, hide armor (2 AP)',
},
shaman: {
label: 'Assistant Shaman',
skills: [
{ key: 'firstAid', mult: 2 },
{ key: 'animalLore', mult: 3 },
{ key: 'plantLore', mult: 3 },
{ key: 'worldLore', mult: 2 },
{ key: 'listen', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'ritual:ceremony', mult: 3 },
{ key: 'ritual:summon', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 3, extraPerYears: 1 },
equipment: "Shaman's fetish bundle, knife, hide or leather armor",
},
},
Nomad: {
crafter: {
label: 'Crafter',
skills: [
{ key: 'craft:leather', mult: 4 },
{ key: 'craft:wood', mult: 2 },
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 2 },
{ key: 'evaluate', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Craft tools, trade goods worth 100L, knife',
},
herder: {
label: 'Herder',
skills: [
{ key: 'ride', mult: 4 },
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 4 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 3 },
{ key: 'track', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Riding animal, lasso, primary weapon, knife',
},
hunter: {
label: 'Hunter',
skills: [
{ key: 'ride', mult: 2 },
{ key: 'throw', mult: 2 },
{ key: 'animalLore', mult: 2 },
{ key: 'plantLore', mult: 1 },
{ key: 'worldLore', mult: 1 },
{ key: 'listen', mult: 2 },
{ key: 'scan', mult: 3 },
{ key: 'track', mult: 3 },
{ key: 'hide', mult: 2 },
{ key: 'sneak', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:missile', mult: 3 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Riding animal, missile weapon, primary weapon, knife',
},
noble: {
label: 'Noble',
skills: [
{ key: 'ride', mult: 3 },
{ key: 'orate', mult: 2 },
{ key: 'humanLore', mult: 2 },
{ key: 'worldLore', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'evaluate', mult: 2 },
{ key: 'speakOwnLanguage', mult: 3 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:missile', mult: 2 },
{ key: 'attack:primary', mult: 3 },
{ key: 'parry:weapon', mult: 2, group: 'parryChoice' },
{ key: 'parry:shield', mult: 2, group: 'parryChoice' },
{ key: 'dodge', mult: 2, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 2 },
equipment: 'Riding animal, primary weapon, shield or parry weapon, light armor',
},
shaman: {
label: 'Assistant Shaman',
skills: [
{ key: 'firstAid', mult: 2 },
{ key: 'animalLore', mult: 3 },
{ key: 'plantLore', mult: 2 },
{ key: 'worldLore', mult: 2 },
{ key: 'listen', mult: 2 },
{ key: 'ritual:ceremony', mult: 3 },
{ key: 'ritual:summon', mult: 3 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 3, extraPerYears: 1 },
equipment: "Shaman's fetish bundle, knife",
},
warrior: {
label: 'Warrior',
skills: [
{ key: 'ride', mult: 3 },
{ key: 'throw', mult: 2 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'track', mult: 1 },
{ key: 'attack:fist', mult: 2 },
{ key: 'attack:dagger', mult: 2 },
{ key: 'attack:missile', mult: 2 },
{ key: 'attack:primary', mult: 4 },
{ key: 'parry:weapon', mult: 3, group: 'parryChoice' },
{ key: 'parry:shield', mult: 3, group: 'parryChoice' },
{ key: 'dodge', mult: 3, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 2 },
equipment: 'Riding animal, primary weapon, parry weapon or shield, leather armor',
},
},
Barbarian: {
crafter: {
label: 'Crafter',
skills: [
{ key: 'craft:leather', mult: 2, group: 'craftPick' },
{ key: 'craft:wood', mult: 2, group: 'craftPick' },
{ key: 'craft:stone', mult: 2, group: 'craftPick' },
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 1 },
{ key: 'evaluate', mult: 3 },
{ key: 'scan', mult: 1 },
{ key: 'speakOwnLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Craft tools, trade goods worth 150L, knife',
},
entertainer: {
label: 'Entertainer',
skills: [
{ key: 'climb', mult: 1 },
{ key: 'jump', mult: 1 },
{ key: 'fastTalk', mult: 3 },
{ key: 'orate', mult: 2 },
{ key: 'sing', mult: 3 },
{ key: 'humanLore', mult: 1 },
{ key: 'conceal', mult: 1 },
{ key: 'sleight', mult: 2 },
{ key: 'scan', mult: 1 },
{ key: 'speakOwnLanguage', mult: 2 },
{ key: 'speakOtherLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Costume, instrument or juggling props, knife',
},
farmer: {
label: 'Farmer',
skills: [
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 3 },
{ key: 'plantLore', mult: 3 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'track', mult: 1 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Farm tools, knife, primary weapon',
},
fisher: {
label: 'Fisher',
skills: [
{ key: 'boat', mult: 4 },
{ key: 'swim', mult: 3 },
{ key: 'firstAid', mult: 1 },
{ key: 'listen', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Fishing net or harpoon, knife, small boat',
},
herder: {
label: 'Herder',
skills: [
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 4 },
{ key: 'plantLore', mult: 2 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'track', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Primary weapon, knife, herd of 2d6+6 animals',
},
hunter: {
label: 'Hunter',
skills: [
{ key: 'throw', mult: 2 },
{ key: 'animalLore', mult: 2 },
{ key: 'plantLore', mult: 2 },
{ key: 'listen', mult: 2 },
{ key: 'scan', mult: 3 },
{ key: 'track', mult: 3 },
{ key: 'hide', mult: 2 },
{ key: 'sneak', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:missile', mult: 3 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 1 },
equipment: 'Missile weapon, primary weapon, knife, light armor',
},
noble: {
label: 'Noble',
skills: [
{ key: 'ride', mult: 2 },
{ key: 'orate', mult: 3 },
{ key: 'humanLore', mult: 2 },
{ key: 'worldLore', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'evaluate', mult: 1 },
{ key: 'speakOwnLanguage', mult: 3 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:missile', mult: 1 },
{ key: 'attack:primary', mult: 3 },
{ key: 'parry:weapon', mult: 2, group: 'parryChoice' },
{ key: 'parry:shield', mult: 2, group: 'parryChoice' },
{ key: 'dodge', mult: 2, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 2 },
equipment: 'Primary weapon, shield or parry weapon, light to medium armor, riding animal',
},
warrior: {
label: 'Warrior',
skills: [
{ key: 'throw', mult: 2 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'attack:fist', mult: 2 },
{ key: 'attack:dagger', mult: 2 },
{ key: 'attack:missile', mult: 2 },
{ key: 'attack:spear1h', mult: 3, group: 'weaponStyle' },
{ key: 'parry:shield', mult: 3, group: 'weaponStyle' },
{ key: 'attack:2hweapon', mult: 3, group: 'weaponStyle' },
{ key: 'parry:weapon', mult: 3, group: 'weaponStyle' },
],
magic: { type: 'spirit', basePoints: 2 },
equipment: 'Primary weapon, shield or two-handed weapon, medium armor',
},
shaman: {
label: 'Assistant Shaman',
skills: [
{ key: 'firstAid', mult: 2 },
{ key: 'animalLore', mult: 3 },
{ key: 'plantLore', mult: 2 },
{ key: 'worldLore', mult: 2 },
{ key: 'listen', mult: 2 },
{ key: 'ritual:ceremony', mult: 3 },
{ key: 'ritual:summon', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'spirit', basePoints: 3, extraPerYears: 1 },
equipment: "Shaman's fetish bundle, knife",
},
},
Civilized: {
crafter: {
label: 'Crafter',
skills: [
{ key: 'craft:leather', mult: 4, group: 'craftPick' },
{ key: 'craft:wood', mult: 4, group: 'craftPick' },
{ key: 'craft:stone', mult: 4, group: 'craftPick' },
{ key: 'devise', mult: 2 },
{ key: 'evaluate', mult: 3 },
{ key: 'speakOwnLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine_or_sorcery', basePoints: 1 },
equipment: 'Craft tools, workshop access, knife',
},
entertainer: {
label: 'Entertainer',
skills: [
{ key: 'climb', mult: 1 },
{ key: 'jump', mult: 1 },
{ key: 'fastTalk', mult: 3 },
{ key: 'orate', mult: 3 },
{ key: 'sing', mult: 3 },
{ key: 'humanLore', mult: 1 },
{ key: 'conceal', mult: 1 },
{ key: 'sleight', mult: 2 },
{ key: 'scan', mult: 1 },
{ key: 'speakOwnLanguage', mult: 2 },
{ key: 'speakOtherLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine_or_sorcery', basePoints: 1 },
equipment: 'Costume, instrument or props, knife',
},
farmer: {
label: 'Farmer',
skills: [
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 3 },
{ key: 'plantLore', mult: 4 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 1 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine', basePoints: 1 },
equipment: 'Farm tools, primary weapon, knife',
},
healer: {
label: 'Healer',
skills: [
{ key: 'firstAid', mult: 5 },
{ key: 'animalLore', mult: 1 },
{ key: 'humanLore', mult: 3 },
{ key: 'plantLore', mult: 3 },
{ key: 'listen', mult: 1 },
{ key: 'search', mult: 2 },
{ key: 'speakOwnLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine', basePoints: 3 },
equipment: "Healer's kit, knife, 2d6×10L",
},
herder: {
label: 'Herder',
skills: [
{ key: 'firstAid', mult: 1 },
{ key: 'animalLore', mult: 5 },
{ key: 'plantLore', mult: 1 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'track', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine', basePoints: 1 },
equipment: 'Primary weapon, knife, herd',
},
merchant: {
label: 'Merchant',
skills: [
{ key: 'ride', mult: 1 },
{ key: 'fastTalk', mult: 2 },
{ key: 'orate', mult: 1 },
{ key: 'humanLore', mult: 2 },
{ key: 'worldLore', mult: 2 },
{ key: 'evaluate', mult: 5 },
{ key: 'speakOwnLanguage', mult: 2 },
{ key: 'speakOtherLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine_or_sorcery', basePoints: 1 },
equipment: 'Pack animal or cart, trade goods worth 500L, knife',
},
noble: {
label: 'Noble',
skills: [
{ key: 'ride', mult: 2 },
{ key: 'fastTalk', mult: 1 },
{ key: 'orate', mult: 3 },
{ key: 'humanLore', mult: 2 },
{ key: 'worldLore', mult: 2 },
{ key: 'evaluate', mult: 2 },
{ key: 'speakOwnLanguage', mult: 3 },
{ key: 'speakOtherLanguage', mult: 2 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'attack:primary', mult: 3 },
{ key: 'parry:weapon', mult: 2, group: 'parryChoice' },
{ key: 'parry:shield', mult: 2, group: 'parryChoice' },
{ key: 'dodge', mult: 2, group: 'parryChoice' },
],
magic: { type: 'divine', basePoints: 2 },
equipment: 'Primary weapon, shield, medium to heavy armor, riding animal',
},
sailor: {
label: 'Sailor',
skills: [
{ key: 'boat', mult: 5 },
{ key: 'climb', mult: 2 },
{ key: 'swim', mult: 3 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'speakOtherLanguage', mult: 1 },
{ key: 'attack:fist', mult: 2 },
{ key: 'attack:dagger', mult: 2 },
{ key: 'attack:primary', mult: 2 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine', basePoints: 1 },
equipment: "Primary weapon, knife, sailor's gear",
},
scribe: {
label: 'Scribe',
skills: [
{ key: 'devise', mult: 1 },
{ key: 'humanLore', mult: 3 },
{ key: 'mineralLore', mult: 1 },
{ key: 'worldLore', mult: 3 },
{ key: 'search', mult: 2 },
{ key: 'evaluate', mult: 2 },
{ key: 'speakOwnLanguage', mult: 3 },
{ key: 'speakOtherLanguage', mult: 3 },
{ key: 'attack:fist', mult: 1 },
{ key: 'attack:dagger', mult: 1 },
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
{ key: 'dodge', mult: 1, group: 'parryChoice' },
],
magic: { type: 'divine_or_sorcery', basePoints: 2 },
equipment: 'Writing materials, knife, access to library',
},
soldier: {
label: 'Soldier',
skills: [
{ key: 'firstAid', mult: 1 },
{ key: 'worldLore', mult: 1 },
{ key: 'scan', mult: 2 },
{ key: 'attack:fist', mult: 2 },
{ key: 'attack:dagger', mult: 2 },
{ key: 'attack:missile', mult: 2 },
{ key: 'attack:spear1h', mult: 3, group: 'weaponStyle' },
{ key: 'parry:shield', mult: 3, group: 'weaponStyle' },
{ key: 'attack:2hweapon', mult: 3, group: 'weaponStyle' },
{ key: 'parry:weapon', mult: 3, group: 'weaponStyle' },
],
magic: { type: 'divine', basePoints: 1 },
equipment: 'Primary weapon, shield or two-handed weapon, medium armor',
},
thief: {
label: 'Thief',
skills: [
{ key: 'climb', mult: 3 },
{ key: 'jump', mult: 2 },
{ key: 'dodge', mult: 2 },
{ key: 'fastTalk', mult: 2 },
{ key: 'conceal', mult: 3 },
{ key: 'sleight', mult: 3 },
{ key: 'devise', mult: 3 },
{ key: 'listen', mult: 2 },
{ key: 'scan', mult: 2 },
{ key: 'search', mult: 2 },
{ key: 'hide', mult: 3 },
{ key: 'sneak', mult: 3 },
{ key: 'attack:fist', mult: 2 },
{ key: 'attack:dagger', mult: 3 },
{ key: 'attack:missile', mult: 1 },
],
magic: { type: 'divine_or_sorcery', basePoints: 1 },
equipment: 'Dagger, climbing gear, lockpicks, dark clothing',
},
},
};
// ---------- computeOccupationSkills ----------
// Returns:
// skills: {} general skill key → total % (base + category mod + occ bonus)
// craftBonuses: {} craft key → occupation bonus (years * mult)
// ritualBonuses: {} ritual key → occupation bonus
// weaponBonuses: {} weapon key → occupation bonus
// choiceGroups: {} groupName → [{ key, mult, bonus, total?, note }]
// occupation: {} the occupation object
function computeOccupationSkills(chars, culture, occupationKey, years) {
const occ = (OCCUPATIONS[culture] || {})[occupationKey];
if (!occ) throw new Error(`Unknown occupation "${occupationKey}" for culture "${culture}"`);
const mods = computeSkillCategoryModifiers(chars);
const baseSkills = computeBaseSkills(chars);
const skills = {};
const craftBonuses = {};
const ritualBonuses = {};
const weaponBonuses = {};
const choiceGroups = {};
// Extended base values for skills not in BASE_SKILLS
function extendedBase(key) {
if (key in baseSkills) return baseSkills[key];
if (key === 'evaluate') return Math.max(0, 5 + (mods.knowledge || 0));
if (key === 'speakOwnLanguage') return 30;
if (key === 'speakOtherLanguage') return 0;
return 0;
}
for (const entry of occ.skills) {
const { key, mult, group } = entry;
const bonus = years * mult;
if (group) {
if (!choiceGroups[group]) choiceGroups[group] = [];
if (key.startsWith('attack:') || key.startsWith('parry:')) {
choiceGroups[group].push({ key, mult, bonus, note: 'weapon' });
} else if (key.startsWith('craft:')) {
choiceGroups[group].push({ key, mult, bonus, note: 'craft' });
} else {
const base = extendedBase(key);
choiceGroups[group].push({ key, mult, bonus, total: Math.max(0, base + bonus), note: 'skill' });
}
continue;
}
// Non-grouped entries
if (key.startsWith('attack:') || key.startsWith('parry:')) {
weaponBonuses[key] = (weaponBonuses[key] || 0) + bonus;
} else if (key.startsWith('craft:')) {
craftBonuses[key] = (craftBonuses[key] || 0) + bonus;
} else if (key.startsWith('ritual:')) {
ritualBonuses[key] = (ritualBonuses[key] || 0) + bonus;
} else {
const base = extendedBase(key);
skills[key] = Math.max(0, (skills[key] !== undefined ? skills[key] : base) + bonus);
}
}
return { skills, craftBonuses, ritualBonuses, weaponBonuses, choiceGroups, occupation: occ };
}
module.exports = { module.exports = {
rollDie, rollDie,
rollDice, rollDice,
@@ -1091,6 +1830,12 @@ module.exports = {
computeBaseSkills, computeBaseSkills,
SCENE_CHOICES, SCENE_CHOICES,
resolveSceneChoice, resolveSceneChoice,
PERSONALITY_TRAITS,
TRAIT_ACTION_BIAS,
rollPersonalityAction,
rollAge,
OCCUPATIONS,
computeOccupationSkills,
dexStrikeRank, dexStrikeRank,
sizStrikeRankModifier, sizStrikeRankModifier,
baseStrikeRank, baseStrikeRank,
+49
View File
@@ -107,6 +107,54 @@ function resolveLayer1Links() {
} }
} }
const CULTURE_TABLE_LINKS = [
[/primitive/i, 'Primitive Occupation Table (d100)'],
[/nomad/i, 'Nomad Occupation Table (d100)'],
[/barbarian/i, 'Barbarian Occupation Table (d100)'],
[/civilized/i, 'Civilized Occupation Table (d100)'],
];
const OCCUPATION_CRAFTER_LINKS = [
['Barbarian Occupation Table (d100)', 'Barbarian Craft Table (d100)'],
['Civilized Occupation Table (d100)', 'Urban Crafts Table (d100)'],
];
function resolveCharacterTableLinks() {
const sourceFile = 'rq3_character_tables.md';
// Culture Table → Occupation Tables
const cultureTable = dbApi.tables.findTableByHeading(sourceFile, 'Culture Table (d8)');
if (cultureTable) {
const rows = dbApi.tables.getRowsForTable(cultureTable.id);
for (const row of rows) {
const text = row.cells[0] || '';
const match = CULTURE_TABLE_LINKS.find(([pattern]) => pattern.test(text));
if (!match) continue;
const [, targetHeading] = match;
const target = dbApi.tables.findTableByHeading(sourceFile, targetHeading);
if (!target) { console.warn(`Culture link target not found: "${targetHeading}"`); continue; }
dbApi.tables.insertLink({ table_id: cultureTable.id, row_id: row.id, target_table_id: target.id });
stats.links++;
}
} else {
console.warn('Could not find Culture Table for link resolution');
}
// Occupation Tables → Craft Sub-tables (Crafter rows)
for (const [occHeading, craftHeading] of OCCUPATION_CRAFTER_LINKS) {
const occTable = dbApi.tables.findTableByHeading(sourceFile, occHeading);
const craftTable = dbApi.tables.findTableByHeading(sourceFile, craftHeading);
if (!occTable || !craftTable) continue;
const rows = dbApi.tables.getRowsForTable(occTable.id);
for (const row of rows) {
if (/^crafter$/i.test((row.cells[0] || '').trim())) {
dbApi.tables.insertLink({ table_id: occTable.id, row_id: row.id, target_table_id: craftTable.id });
stats.links++;
}
}
}
}
function main() { function main() {
dbApi.tables.clearImported(); dbApi.tables.clearImported();
@@ -116,6 +164,7 @@ function main() {
} }
resolveLayer1Links(); resolveLayer1Links();
resolveCharacterTableLinks();
console.log('---'); console.log('---');
console.log(`Files: ${stats.files}`); console.log(`Files: ${stats.files}`);
+104 -4
View File
@@ -250,8 +250,67 @@ app.get('/api/export/log', (req, res) => {
// ---------- Player Characters ---------- // ---------- 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) => { 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) => { app.post('/api/characters/roll', (req, res) => {
@@ -279,7 +338,7 @@ app.post('/api/characters/validate', (req, res) => {
}); });
app.post('/api/characters', (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 (!name) return res.status(400).json({ error: 'name is required' });
if (!chars) return res.status(400).json({ error: 'chars 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, pow: chars.pow, dex: chars.dex, app: chars.app,
max_hp: derived.totalHp, current_hp: derived.totalHp, max_hp: derived.totalHp, current_hp: derived.totalHp,
magic_points_max: derived.magicPoints, magic_points_current: derived.magicPoints, 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.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)); res.status(201).json(dbApi.playerCharacters.get(pc.id));
}); });
@@ -302,6 +365,38 @@ app.delete('/api/characters/:id', (req, res) => {
res.status(204).end(); 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 ---------- // ---------- Inventory ----------
app.get('/api/characters/:id/inventory', (req, res) => { app.get('/api/characters/:id/inventory', (req, res) => {
@@ -747,6 +842,11 @@ app.post('/api/import', (req, res) => {
res.json(dump); res.json(dump);
}); });
app.post('/api/clear-all', (req, res) => {
dbApi.clearAll();
res.status(204).end();
});
// ---------- Errors ---------- // ---------- Errors ----------
app.use((req, res) => { app.use((req, res) => {
+190
View File
@@ -0,0 +1,190 @@
# RQ3 Character Creation Tables
Tables extracted from the RQ3 Players Book. Roll Culture first, then roll on the
matching Occupation table to determine the adventurer's parental background.
---
## Culture Table (d8)
| D8 | Culture |
|----|---------|
| 1 | Primitive |
| 2-3 | Nomad |
| 4-6 | Barbarian |
| 7-8 | Civilized |
---
## Primitive Occupation Table (d100)
| D100 | Occupation |
|------|-----------|
| 01-30 | Fisher |
| 31-98 | Hunter |
| 99-100 | Shaman (use the primitive Assistant Shaman occupation) |
---
## Nomad Occupation Table (d100)
| D100 | Occupation |
|------|-----------|
| 01-07 | Crafter |
| 08-85 | Herder |
| 86-95 | Hunter |
| 96 | Noble |
| 97-98 | Shaman (consult the nomad Assistant Shaman occupation) |
| 99-100 | Warrior |
---
## Barbarian Occupation Table (d100)
| D100 | Occupation |
|------|-----------|
| 01-02 | Crafter |
| 03 | Entertainer |
| 04-55 | Farmer |
| 56-70 | Fisher |
| 71-80 | Herder |
| 81-90 | Hunter |
| 91-92 | Noble |
| 93-94 | Priest or Shaman |
| 95-100 | Warrior |
---
## Barbarian Craft Table (d100)
| D100 | Craft |
|------|-------|
| 01-19 | Weaver |
| 20-44 | Tailor |
| 45-50 | Potter |
| 51-54 | Blacksmith |
| 55-56 | Armorer |
| 57-71 | Leatherworker |
| 72-74 | Cooper |
| 75-77 | Joiner |
| 78-79 | Carpenter |
| 80-85 | Mason |
| 86-91 | Butcher |
| 92-97 | Baker |
| 98-100 | Herbalist |
---
## Civilized Occupation Table (d100)
| D100 | Occupation |
|------|-----------|
| 01 | Adept Sorcerer (use Apprentice Sorcerer for previous experience) |
| 02-06 | Crafter |
| 07 | Entertainer |
| 08-66 | Farmer |
| 67 | Healer |
| 68-77 | Herder |
| 78-79 | Merchant |
| 80 | Noble |
| 81-82 | Priest |
| 83-92 | Sailor |
| 93 | Scribe |
| 94-98 | Soldier |
| 99-100 | Thief |
---
## Urban Crafts Table (d100)
| D100 | Craft |
|------|-------|
| 01 | Armourer |
| 02-06 | Baker |
| 07 | Brewer |
| 08-13 | Butcher |
| 14-15 | Carpenter |
| 16-17 | Cook |
| 18-20 | Cooper |
| 21-23 | Herbalist |
| 24 | Jeweller |
| 25-27 | Joiner |
| 28-42 | Leatherworker |
| 43-47 | Mason |
| 48-52 | Potter |
| 53-56 | Smith |
| 57-76 | Tailor |
| 77-100 | Weaver |
---
## Language Proficiency Table (d100)
| D100 | Fluency |
|------|---------|
| 01-10 | Simple ideas only — "I want food" |
| 11-30 | Basic needs — day-to-day survival in a native country |
| 31-50 | Assured communication — better than a stupid native |
| 51-80 | Stories, bargaining, debate — as well as any native speaker |
| 81-100 | Diplomatic fluency — the language of poets and philosophers |
---
## Dropped Oil Lamp Table (d100)
| D100 | Result |
|------|--------|
| 01-30 | Light extinguished; lamp unharmed |
| 31-70 | Lamp continues burning; roll for random beam direction |
| 71-75 | Lamp breaks; oil spreads over floor (slick surface); wick burns at candle brightness |
| 76-85 | Lamp breaks irreparably; oil spreads over floor; wick extinguished |
| 86-100 | Lamp breaks; burning oil spreads — treat as a small fire |
---
## Aging: Characteristics Lost per Year (2d6)
| 2D6 | Points Lost |
|-----|-------------|
| 2 | 4 |
| 3 | 3 |
| 4 | 2 |
| 5 | 1 |
| 6-8 | None |
| 9 | 1 |
| 10 | 2 |
| 11 | 3 |
| 12 | 4 |
---
## Aging: Which Characteristic Affected (d10)
| D10 | Characteristic |
|-----|---------------|
| 1-2 | -1 STR |
| 3-4 | -1 CON |
| 5-6 | -1 DEX |
| 7-8 | -1 APP |
| 9-10 | No loss this point |
---
## Armor Points for Objects
| Object | Armor Points |
|--------|-------------|
| Light wooden furniture | 5 |
| Light wooden door | 6 |
| Heavy wooden furniture | 8 |
| Heavy wooden door | 8 |
| Hut wall | 6 |
| Fence rail | 12 |
| Farmhouse wall (wood and plaster) | 15 |
| Postern gate | 20 |
| Large stone | 20 |
| Loose stone wall | 20 |
| Adobe | 25 |
| Castle or town gate | 30 |
| Wooden palisade | 30 |
| Mortared stone or brick wall | 35 |