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
+745
View File
@@ -718,6 +718,73 @@ function sumAttackModifiers(selectedIds, { targetSiz } = {}) {
}, 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 ----------
const CHARACTER_CULTURES = [
@@ -1074,6 +1141,678 @@ function buildStrikeRankSchedule(actions) {
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 = {
rollDie,
rollDice,
@@ -1091,6 +1830,12 @@ module.exports = {
computeBaseSkills,
SCENE_CHOICES,
resolveSceneChoice,
PERSONALITY_TRAITS,
TRAIT_ACTION_BIAS,
rollPersonalityAction,
rollAge,
OCCUPATIONS,
computeOccupationSkills,
dexStrikeRank,
sizStrikeRankModifier,
baseStrikeRank,