From 5e83fe1ef203acc971c45f48d3012cccf715b93d Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Fri, 3 Jul 2026 21:44:11 +1000 Subject: [PATCH] feat: full character creation, personality traits, and session quality-of-life MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 26 +- db.js | 76 +- public/app.js | 1322 ++++++++++++++++++++++++++------ public/index.html | 1 + rq3.js | 745 ++++++++++++++++++ scripts/import-tables.js | 49 ++ server.js | 108 ++- tables/rq3_character_tables.md | 190 +++++ 8 files changed, 2263 insertions(+), 254 deletions(-) create mode 100644 tables/rq3_character_tables.md diff --git a/TODO.md b/TODO.md index 86e35ab..689de5e 100644 --- a/TODO.md +++ b/TODO.md @@ -2,28 +2,44 @@ ## 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`) ## 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`) + +### Other - [ ] 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) - [ ] 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 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 -- [ ] 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 ## Cleanup - [ ] 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 diff --git a/db.js b/db.js index d08ea3d..0ac2717 100644 --- a/db.js +++ b/db.js @@ -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, }; diff --git a/public/app.js b/public/app.js index 966fa36..78db9c4 100644 --- a/public/app.js +++ b/public/app.js @@ -381,6 +381,13 @@ function renderNpcsMain() { if (npc.npc_type === 'full') { NPC_ATTR_FIELDS.forEach(([key, label]) => card.appendChild(renderNpcField(npc, key, label, true))); } + + const traitsRow = el(`
`); + traitsRow.appendChild(renderTraitsEditor(npc.traits || [], async (traits) => { + await api('PATCH', `/api/npcs/${npc.id}/traits`, { traits }); + npc.traits = traits; + })); + card.appendChild(traitsRow); wrap.appendChild(card); const sbCard = el(`

Stat Block

`); @@ -899,269 +906,1075 @@ function renderDerivedStats(derived) { `); } +// ====================================================================== +// CHARACTER CREATION WIZARD DATA +// ====================================================================== + +const CULTURES = ['Primitive', 'Nomad', 'Barbarian', 'Civilized']; + +const OCCUPATION_LABELS = { + Primitive: { fisher: 'Fisher', hunter: 'Hunter', shaman: 'Assistant Shaman' }, + Nomad: { crafter: 'Crafter', herder: 'Herder', hunter: 'Hunter', noble: 'Noble', shaman: 'Assistant Shaman', warrior: 'Warrior' }, + Barbarian: { crafter: 'Crafter', entertainer: 'Entertainer', farmer: 'Farmer', fisher: 'Fisher', herder: 'Herder', hunter: 'Hunter', noble: 'Noble', warrior: 'Warrior', shaman: 'Assistant Shaman' }, + Civilized: { crafter: 'Crafter', entertainer: 'Entertainer', farmer: 'Farmer', healer: 'Healer', herder: 'Herder', merchant: 'Merchant', noble: 'Noble', sailor: 'Sailor', scribe: 'Scribe', soldier: 'Soldier', thief: 'Thief' }, +}; + +const SKILL_DISPLAY_NAMES = { + boat: 'Boat', climb: 'Climb', dodge: 'Dodge', jump: 'Jump', ride: 'Ride', swim: 'Swim', throw: 'Throw', + fastTalk: 'Fast Talk', orate: 'Orate', sing: 'Sing', + firstAid: 'First Aid', animalLore: 'Animal Lore', humanLore: 'Human Lore', mineralLore: 'Mineral Lore', + plantLore: 'Plant Lore', worldLore: 'World Lore', + conceal: 'Conceal', sleight: 'Sleight', devise: 'Devise', + listen: 'Listen', scan: 'Scan', search: 'Search', track: 'Track', + hide: 'Hide', sneak: 'Sneak', + evaluate: 'Evaluate', speakOwnLanguage: 'Speak Own Language', speakOtherLanguage: 'Speak Other Language', +}; + +const SKILL_CATEGORIES = { + boat: 'Agility', climb: 'Agility', dodge: 'Agility', jump: 'Agility', ride: 'Agility', swim: 'Agility', throw: 'Agility', + fastTalk: 'Communication', orate: 'Communication', sing: 'Communication', + firstAid: 'Knowledge', animalLore: 'Knowledge', humanLore: 'Knowledge', mineralLore: 'Knowledge', + plantLore: 'Knowledge', worldLore: 'Knowledge', evaluate: 'Knowledge', + conceal: 'Manipulation', sleight: 'Manipulation', devise: 'Manipulation', + listen: 'Perception', scan: 'Perception', search: 'Perception', track: 'Perception', + hide: 'Stealth', sneak: 'Stealth', + speakOwnLanguage: 'Communication', speakOtherLanguage: 'Communication', +}; + +// Cultural weapon lists for dropdowns (simplified from CULTURAL_WEAPON_BONUSES) +const CULTURAL_WEAPONS = { + Primitive: { + attackParry: ['Short Spear (1H)', 'Long Spear (2H)', 'Battleaxe (1H)', 'Light Mace (1H)', 'Wooden Club (1H)'], + attackOnly: ['Javelin (thrown)', 'Boomerang (War)', 'Sling', 'Self Bow', 'Short Spear (thrown)'], + parryOnly: ['Buckler', 'Heater/Target Shield'], + }, + Nomad: { + attackParry: ['Battleaxe (1H)', 'Light Mace (1H)', 'Short Spear (1H)', 'Broadsword', 'Scimitar'], + attackOnly: ['Lance (mounted)', 'Self Bow', 'Long Bow', 'Composite Bow', 'Javelin (thrown)'], + parryOnly: ['Buckler', 'Heater/Target Shield'], + }, + Barbarian: { + attackParry: ['Short Spear (1H)', 'Long Spear (2H)', 'Battleaxe (1H)', 'Light Mace (1H)', 'Broadsword', 'Bastard Sword (2H)', 'Greatsword'], + attackOnly: ['Self Bow', 'Long Bow', 'Composite Bow', 'Javelin (thrown)'], + parryOnly: ['Buckler', 'Kite Shield', 'Viking Round Shield'], + }, + Civilized: { + attackParry: ['Broadsword', 'Rapier', 'Scimitar', 'Gladius', 'Short Spear (1H)', 'Long Spear (2H)', 'Bastard Sword (2H)', 'Greatsword'], + attackOnly: ['Heavy Crossbow', 'Medium Crossbow', 'Light Crossbow', 'Sling'], + parryOnly: ['Main Gauche', 'Buckler', 'Heater/Target Shield', 'Kite Shield', 'Hoplite Shield'], + }, +}; + +// ====================================================================== +// CHARACTER CREATION WIZARD +// ====================================================================== + function renderCharactersMain() { const wrap = el(`
`); - // --- Generator section --- + // ---- Wizard state ---- + const genState = { + step: 1, + name: '', age: 21, gender: 'M', + method: 'random', + chars: null, derived: null, + culture: null, occupation: null, + occResult: null, // result from compute-occupation API + choiceSelections: {}, // groupName → selected key + primaryWeapon: null, missileWeapon: null, shieldWeapon: null, + }; + + // Container for the wizard wrap.appendChild(el(`

Character Generator

`)); - const genCard = el(`
`); + const wizardWrap = el(`
`); + wrap.appendChild(wizardWrap); - // Method picker - const methodRow = el(`
`); - const methodSel = el(``); - methodRow.appendChild(methodSel); - genCard.appendChild(methodRow); + // Progress bar + const progressEl = el(`

`); + wizardWrap.appendChild(progressEl); - const statInputsWrap = el(`
`); - const derivedWrap = el(`
`); - const saveWrap = el(`
`); - const genMsg = el(`
`); + const stepContent = el(`
`); + wizardWrap.appendChild(stepContent); - // Budget tracker for deliberate - const budgetDisplay = el(`

`); + function updateProgress() { + const labels = ['Identity', 'Characteristics', 'Previous Experience', 'Review & Save']; + progressEl.innerHTML = labels.map((l, i) => { + const n = i + 1; + const active = n === genState.step; + return `${n}. ${l}`; + }).join('  |  '); + } - let currentChars = null; - - function buildStatInputs(readOnly, chars) { - statInputsWrap.innerHTML = ''; - budgetDisplay.style.display = 'none'; - - if (readOnly) { - const row = el(`
`); - CHAR_KEYS.forEach((k) => { - row.appendChild(el(`${CHAR_LABELS[k]}: ${chars[k]}`)); - }); - statInputsWrap.appendChild(row); - return null; + // ---- Navigation helpers ---- + function navRow(backFn, nextFn, nextLabel) { + const row = el(`
`); + if (backFn) { + const b = el(``); + b.addEventListener('click', backFn); + row.appendChild(b); } + if (nextFn) { + const n = el(``); + n.addEventListener('click', nextFn); + row.appendChild(n); + } + return row; + } - // Editable inputs - const inputs = {}; - const grid = el(`
`); - CHAR_KEYS.forEach((k) => { - const cell = el(`
`); - const inp = el(``); - inputs[k] = inp; - cell.appendChild(inp); - grid.appendChild(cell); + function goToStep(n) { + genState.step = n; + updateProgress(); + stepContent.innerHTML = ''; + if (n === 1) renderStep1(); + else if (n === 2) renderStep2(); + else if (n === 3) renderStep3(); + else if (n === 4) renderStep4(); + } + + // ======== STEP 1 — Identity ======== + function renderStep1() { + const msg = el(`
`); + + const nameIn = el(``); + const genderRow = el(`
`); + ['M', 'F'].forEach((g) => { + const lbl = el(``); + genderRow.appendChild(lbl); }); - statInputsWrap.appendChild(grid); - if (methodSel.value === 'deliberate') { - budgetDisplay.style.display = ''; - function updateBudget() { - const total = CHAR_KEYS.reduce((s, k) => s + (Number(inputs[k].value) || 0), 0); - const rem = 80 - total; - budgetDisplay.textContent = `Points used: ${total}/80 (${rem >= 0 ? rem + ' remaining' : Math.abs(rem) + ' over'})`; - budgetDisplay.style.color = rem === 0 ? 'var(--success,green)' : rem < 0 ? 'red' : 'inherit'; + const ageIn = el(``); + const rollAgeBtn = el(``); + rollAgeBtn.addEventListener('click', async () => { + try { + const res = await api('POST', '/api/characters/roll-age', {}); + ageIn.value = res.age; + genState.age = res.age; + } catch (err) { + msg.innerHTML = `

${escapeHtml(err.message)}

`; } - CHAR_KEYS.forEach((k) => inputs[k].addEventListener('input', updateBudget)); - updateBudget(); + }); + + const fields = [ + ['Name', nameIn], + ['Gender', genderRow], + ['Age', el(``)], + ]; + + fields.forEach(([label, inp]) => { + const row = el(`
`); + row.appendChild(inp); + stepContent.appendChild(row); + }); + // replace the placeholder age row with proper age row + stepContent.lastChild.remove(); + const ageRow = el(`
`); + const ageInputWrap = el(`
`); + ageInputWrap.append(ageIn, rollAgeBtn); + ageRow.appendChild(ageInputWrap); + stepContent.appendChild(ageRow); + + stepContent.appendChild(msg); + stepContent.appendChild(navRow(null, () => { + genState.name = nameIn.value.trim(); + const genderSel = stepContent.querySelector('input[name="wiz-gender"]:checked'); + genState.gender = genderSel ? genderSel.value : 'M'; + genState.age = Math.max(15, Number(ageIn.value) || 21); + if (!genState.name) { msg.innerHTML = `

Name is required.

`; return; } + goToStep(2); + })); + } + + // ======== STEP 2 — Characteristics ======== + function renderStep2() { + const msg = el(`
`); + + // Method picker + const methodRow = el(`
`); + const methodSel = el(``); + methodRow.appendChild(methodSel); + stepContent.appendChild(methodRow); + + const statInputsWrap = el(`
`); + const derivedWrap = el(`
`); + const budgetDisplay = el(`

`); + stepContent.append(statInputsWrap, budgetDisplay, derivedWrap, msg); + + let currentChars = genState.chars; + let activeInputs = null; + + function buildStatInputs(readOnly, chars) { + statInputsWrap.innerHTML = ''; + budgetDisplay.style.display = 'none'; + if (readOnly) { + const row = el(`
`); + CHAR_KEYS.forEach((k) => row.appendChild(el(`${CHAR_LABELS[k]}: ${chars[k]}`))); + statInputsWrap.appendChild(row); + return null; + } + const inputs = {}; + const grid = el(`
`); + CHAR_KEYS.forEach((k) => { + const cell = el(`
`); + const inp = el(``); + inputs[k] = inp; + cell.appendChild(inp); + grid.appendChild(cell); + }); + statInputsWrap.appendChild(grid); + if (methodSel.value === 'deliberate') { + budgetDisplay.style.display = ''; + function updateBudget() { + const total = CHAR_KEYS.reduce((s, k) => s + (Number(inputs[k].value) || 0), 0); + const rem = 80 - total; + budgetDisplay.textContent = `Points used: ${total}/80 (${rem >= 0 ? rem + ' remaining' : Math.abs(rem) + ' over'})`; + budgetDisplay.style.color = rem === 0 ? 'var(--success,green)' : rem < 0 ? 'red' : 'inherit'; + } + CHAR_KEYS.forEach((k) => inputs[k].addEventListener('input', updateBudget)); + updateBudget(); + } + if (methodSel.value === 'combined' && chars) { + let bonusLeft = 6; + const bonusLabel = el(`

Bonus points remaining: 6

`); + statInputsWrap.appendChild(bonusLabel); + CHAR_KEYS.forEach((k) => { + const base = chars[k]; + inputs[k].readOnly = true; + inputs[k].style.background = 'var(--input-disabled-bg,#f0f0f0)'; + const plusBtn = el(``); + const minusBtn = el(``); + plusBtn.addEventListener('click', () => { + if (bonusLeft <= 0 || Number(inputs[k].value) >= 18) return; + inputs[k].value = Number(inputs[k].value) + 1; bonusLeft--; + bonusLabel.querySelector('#wiz-bonus-left').textContent = bonusLeft; + }); + minusBtn.addEventListener('click', () => { + if (Number(inputs[k].value) <= base) return; + inputs[k].value = Number(inputs[k].value) - 1; bonusLeft++; + bonusLabel.querySelector('#wiz-bonus-left').textContent = bonusLeft; + }); + const cell = inputs[k].parentElement; + const btnRow = el(`
`); + btnRow.append(minusBtn, plusBtn); + cell.appendChild(btnRow); + }); + } + return inputs; } - if (methodSel.value === 'combined' && chars) { - // Show +/- buttons with bonus point tracking - let bonusLeft = 6; - const bonusLabel = el(`

Bonus points remaining: 6

`); - statInputsWrap.appendChild(bonusLabel); + if (genState.method === 'deliberate' && !currentChars) { + activeInputs = buildStatInputs(false, null); + } else if (currentChars) { + if (genState.method === 'combined') { + activeInputs = buildStatInputs(false, currentChars); + } else { + buildStatInputs(true, currentChars); + } + derivedWrap.innerHTML = ''; + if (genState.derived) derivedWrap.appendChild(renderDerivedStats(genState.derived)); + } - CHAR_KEYS.forEach((k) => { - const base = chars[k]; - inputs[k].readOnly = true; - inputs[k].style.background = 'var(--input-disabled-bg, #f0f0f0)'; + methodSel.addEventListener('change', () => { + genState.method = methodSel.value; + currentChars = null; genState.chars = null; genState.derived = null; + derivedWrap.innerHTML = ''; + activeInputs = null; + if (methodSel.value === 'deliberate') { activeInputs = buildStatInputs(false, null); } + else { statInputsWrap.innerHTML = ''; } + }); - const plusBtn = el(``); - const minusBtn = el(``); - plusBtn.addEventListener('click', () => { - if (bonusLeft <= 0 || Number(inputs[k].value) >= 18) return; - inputs[k].value = Number(inputs[k].value) + 1; - bonusLeft--; - bonusLabel.querySelector('#bonus-left').textContent = bonusLeft; - }); - minusBtn.addEventListener('click', () => { - if (Number(inputs[k].value) <= base) return; - inputs[k].value = Number(inputs[k].value) - 1; - bonusLeft++; - bonusLabel.querySelector('#bonus-left').textContent = bonusLeft; - }); - // attach buttons next to each input - const cell = inputs[k].parentElement; - const btnRow = el(`
`); - btnRow.append(minusBtn, plusBtn); - cell.appendChild(btnRow); + const btnRow = el(`
`); + + const rollBtn = el(``); + rollBtn.style.display = genState.method === 'deliberate' ? 'none' : ''; + rollBtn.addEventListener('click', async () => { + try { + const res = await api('POST', '/api/characters/roll', { method: methodSel.value }); + currentChars = res.chars; + genState.method = methodSel.value; + if (res.bonusPoints > 0) { activeInputs = buildStatInputs(false, currentChars); } + else { buildStatInputs(true, currentChars); activeInputs = null; } + derivedWrap.innerHTML = ''; + derivedWrap.appendChild(renderDerivedStats(res.derived)); + genState.chars = currentChars; genState.derived = res.derived; + msg.innerHTML = ''; + } catch (err) { msg.innerHTML = `

${escapeHtml(err.message)}

`; } + }); + + const calcBtn = el(``); + calcBtn.style.display = genState.method === 'deliberate' ? '' : 'none'; + calcBtn.addEventListener('click', async () => { + const chars = {}; + CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; }); + try { + const val = await api('POST', '/api/characters/validate', { method: 'deliberate', chars }); + if (!val.valid) { msg.innerHTML = `

${val.errors.map(escapeHtml).join('
')}

`; return; } + const res = await api('POST', '/api/characters/derive', chars); + currentChars = chars; genState.chars = chars; genState.derived = res.derived; + derivedWrap.innerHTML = ''; + derivedWrap.appendChild(renderDerivedStats(res.derived)); + msg.innerHTML = ''; + } catch (err) { msg.innerHTML = `

${escapeHtml(err.message)}

`; } + }); + + const applyBtn = el(``); + applyBtn.style.display = genState.method === 'combined' ? '' : 'none'; + applyBtn.addEventListener('click', async () => { + if (!activeInputs) return; + const chars = {}; + CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; }); + try { + const val = await api('POST', '/api/characters/validate', { method: 'combined', chars }); + if (!val.valid) { msg.innerHTML = `

${val.errors.map(escapeHtml).join('
')}

`; return; } + const res = await api('POST', '/api/characters/derive', chars); + currentChars = chars; genState.chars = chars; genState.derived = res.derived; + derivedWrap.innerHTML = ''; + derivedWrap.appendChild(renderDerivedStats(res.derived)); + msg.innerHTML = ''; + } catch (err) { msg.innerHTML = `

${escapeHtml(err.message)}

`; } + }); + + methodSel.addEventListener('change', () => { + rollBtn.style.display = methodSel.value === 'deliberate' ? 'none' : ''; + calcBtn.style.display = methodSel.value === 'deliberate' ? '' : 'none'; + applyBtn.style.display = methodSel.value === 'combined' ? '' : 'none'; + }); + + btnRow.append(rollBtn, calcBtn, applyBtn); + // Insert btnRow before msg + stepContent.insertBefore(btnRow, msg); + + stepContent.appendChild(navRow( + () => goToStep(1), + () => { + if (!genState.chars) { msg.innerHTML = `

Roll or calculate characteristics first.

`; return; } + goToStep(3); + } + )); + } + + // ======== STEP 3 — Previous Experience ======== + async function renderStep3() { + const years = genState.age - 15; + stepContent.appendChild(el(`

Years of Experience: ${years} (age ${genState.age} − 15)

`)); + + const msg = el(`
`); + const occResultWrap = el(`
`); + + // Culture picker + const cultureSel = el(``); + const rollCultureBtn = el(``); + rollCultureBtn.addEventListener('click', () => { + const d8 = Math.ceil(Math.random() * 8); + const mapped = d8 <= 1 ? 'Primitive' : d8 <= 3 ? 'Nomad' : d8 <= 6 ? 'Barbarian' : 'Civilized'; + cultureSel.value = mapped; + cultureSel.dispatchEvent(new Event('change')); + }); + const cultureRow = el(`
`); + const cultureCtrl = el(`
`); + cultureCtrl.append(rollCultureBtn, cultureSel); + cultureRow.appendChild(cultureCtrl); + stepContent.appendChild(cultureRow); + + // Occupation picker + const occSel = el(``); + const rollOccBtn = el(``); + const occRow = el(`
`); + const occCtrl = el(`
`); + occCtrl.append(rollOccBtn, occSel); + occRow.appendChild(occCtrl); + stepContent.appendChild(occRow); + + function refreshOccupations() { + const culture = cultureSel.value; + occSel.innerHTML = ''; + if (!culture || !OCCUPATION_LABELS[culture]) return; + Object.entries(OCCUPATION_LABELS[culture]).forEach(([key, label]) => { + occSel.appendChild(el(``)); }); } - return inputs; + cultureSel.addEventListener('change', () => { + genState.culture = cultureSel.value || null; + genState.occupation = null; + genState.occResult = null; + genState.choiceSelections = {}; + genState.primaryWeapon = null; genState.missileWeapon = null; genState.shieldWeapon = null; + refreshOccupations(); + occResultWrap.innerHTML = ''; + }); + + rollOccBtn.addEventListener('click', () => { + const culture = cultureSel.value; + if (!culture || !OCCUPATION_LABELS[culture]) return; + const keys = Object.keys(OCCUPATION_LABELS[culture]); + occSel.value = keys[Math.floor(Math.random() * keys.length)]; + occSel.dispatchEvent(new Event('change')); + }); + + refreshOccupations(); + + async function loadOccupation() { + const culture = cultureSel.value; + const occupation = occSel.value; + if (!culture || !occupation) { occResultWrap.innerHTML = ''; return; } + genState.culture = culture; + genState.occupation = occupation; + genState.choiceSelections = {}; + try { + const result = await api('POST', '/api/characters/compute-occupation', { + chars: genState.chars, + culture, + occupation, + years, + }); + genState.occResult = result; + renderOccResult(result, culture, years); + } catch (err) { + occResultWrap.innerHTML = `

${escapeHtml(err.message)}

`; + } + } + + occSel.addEventListener('change', loadOccupation); + + // If we already have selections from a back-navigation, reload + if (genState.culture && genState.occupation && !genState.occResult) { + await loadOccupation(); + } else if (genState.occResult) { + renderOccResult(genState.occResult, genState.culture, years); + } + + stepContent.appendChild(occResultWrap); + stepContent.appendChild(msg); + stepContent.appendChild(navRow( + () => goToStep(2), + () => { + if (!genState.culture || !genState.occupation) { + msg.innerHTML = `

Please select culture and occupation.

`; return; + } + goToStep(4); + } + )); + + function sign(n) { return n >= 0 ? `+${n}` : `${n}`; } + + function renderOccResult(result, culture, years) { + occResultWrap.innerHTML = ''; + const occ = result.occupation; + + // --- Choice groups --- + if (Object.keys(result.choiceGroups).length) { + const choiceCard = el(`

Choices Required

`); + Object.entries(result.choiceGroups).forEach(([groupName, options]) => { + const groupLabel = { + parryChoice: 'Parry / Defence choice', + weaponStyle: 'Weapon style choice', + craftPick: 'Craft specialisation', + lorePick: 'Lore specialisation', + }[groupName] || groupName; + const gDiv = el(`
`); + const sel = el(``); + options.forEach((opt) => { + const label = opt.note === 'weapon' ? `${opt.key} (×${opt.mult}, +${opt.bonus}%)` : + opt.note === 'craft' ? `${opt.key} (×${opt.mult}, +${opt.bonus}%)` : + `${SKILL_DISPLAY_NAMES[opt.key] || opt.key} (×${opt.mult}, total ${opt.total}%)`; + const optEl = el(``); + sel.appendChild(optEl); + }); + sel.addEventListener('change', () => { + genState.choiceSelections[groupName] = sel.value || null; + }); + if (genState.choiceSelections[groupName]) sel.value = genState.choiceSelections[groupName]; + gDiv.appendChild(sel); + choiceCard.appendChild(gDiv); + }); + occResultWrap.appendChild(choiceCard); + } + + // --- General skills table --- + if (Object.keys(result.skills).length) { + const skillCard = el(`

Occupation Skills

`); + const grouped = {}; + Object.entries(result.skills).forEach(([key, val]) => { + const cat = SKILL_CATEGORIES[key] || 'Other'; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push({ key, val }); + }); + const tbl = el(`
`); + tbl.innerHTML = `SkillCategoryTotal %`; + const tbody = el(``); + Object.keys(grouped).sort().forEach((cat) => { + grouped[cat].sort((a, b) => a.key.localeCompare(b.key)).forEach(({ key, val }) => { + tbody.appendChild(el(`${escapeHtml(SKILL_DISPLAY_NAMES[key] || key)}${cat}${val}%`)); + }); + }); + tbl.appendChild(tbody); + skillCard.appendChild(tbl); + occResultWrap.appendChild(skillCard); + } + + // --- Weapon skills --- + const mods = deriveModsFromChars(genState.chars); + const attackMod = mods ? mods.attack : 0; + const parryMod = mods ? mods.parry : 0; + const wb = result.weaponBonuses; + + const weaponCard = el(`

Weapon Skills

`); + weaponCard.appendChild(el(`

Attack modifier: ${sign(attackMod)}%   Parry modifier: ${sign(parryMod)}%

`)); + + // Cultural weapon lists + const cw = CULTURAL_WEAPONS[culture] || { attackParry: [], attackOnly: [], parryOnly: [] }; + const allPrimary = [...cw.attackParry]; + const allMissile = [...cw.attackOnly]; + const allShield = [...cw.parryOnly]; + + function getCulturalBase(weaponName) { + // Return approximate cultural base percent for display + if (cw.attackParry.includes(weaponName)) { + if (culture === 'Primitive') return 25; + if (culture === 'Nomad') return 20; + if (culture === 'Barbarian') return (weaponName.includes('2H') || weaponName.includes('Greatsword') || weaponName.includes('Bastard')) ? 15 : 25; + if (culture === 'Civilized') return (weaponName.includes('2H') || weaponName.includes('Greatsword') || weaponName.includes('Bastard')) ? 15 : 25; + } + if (cw.attackOnly.includes(weaponName)) { + if (culture === 'Primitive') return weaponName.includes('Sling') || weaponName.includes('Bow') ? 25 : 20; + if (culture === 'Nomad') return weaponName.includes('Lance') ? 30 : 20; + if (culture === 'Barbarian') return 25; + if (culture === 'Civilized') return 25; + } + if (cw.parryOnly.includes(weaponName)) { + if (culture === 'Primitive' || culture === 'Nomad' || culture === 'Civilized') return 25; + if (culture === 'Barbarian') return 25; + } + return 0; + } + + // Primary weapon select + const primarySel = el(``); + const missileSel = el(``); + const shieldSel = el(``); + + [['Primary weapon', primarySel], ['Missile weapon', missileSel], ['Parry weapon / shield', shieldSel]].forEach(([label, sel]) => { + const row = el(`
`); + row.appendChild(sel); + weaponCard.appendChild(row); + }); + + primarySel.addEventListener('change', () => { genState.primaryWeapon = primarySel.value || null; updateWeaponTable(); }); + missileSel.addEventListener('change', () => { genState.missileWeapon = missileSel.value || null; updateWeaponTable(); }); + shieldSel.addEventListener('change', () => { genState.shieldWeapon = shieldSel.value || null; updateWeaponTable(); }); + + const weaponTableWrap = el(`
`); + weaponCard.appendChild(weaponTableWrap); + + function updateWeaponTable() { + weaponTableWrap.innerHTML = ''; + const rows = []; + + // Fist + const fistAtk = 25 + attackMod + (wb['attack:fist'] || 0); + rows.push({ name: 'Fist', attack: Math.max(0, fistAtk), parry: null, note: 'natural' }); + + // Dagger + const dagAtk = 15 + attackMod + (wb['attack:dagger'] || 0); + rows.push({ name: 'Dagger', attack: Math.max(0, dagAtk), parry: null, note: 'natural' }); + + // Primary weapon + if (genState.primaryWeapon) { + const w = genState.primaryWeapon; + const base = getCulturalBase(w); + const atkBonus = wb['attack:primary'] || 0; + const atk = Math.max(0, base + attackMod + atkBonus); + // Parry depends on what the user chose for parryChoice + const parryChoiceKey = genState.choiceSelections['parryChoice'] || genState.choiceSelections['weaponStyle']; + let pry = null; + if (parryChoiceKey === 'parry:weapon') { + const parryBonus = (result.choiceGroups['parryChoice'] || result.choiceGroups['weaponStyle'] || []) + .find((o) => o.key === 'parry:weapon'); + pry = Math.max(0, base + parryMod + (parryBonus ? parryBonus.bonus : 0)); + } else { + pry = Math.max(0, base + parryMod); + } + rows.push({ name: w, attack: atk, parry: pry, note: 'primary' }); + } + + // Missile weapon + if (genState.missileWeapon) { + const w = genState.missileWeapon; + const base = getCulturalBase(w); + const missileBonus = wb['attack:missile'] || 0; + const atk = Math.max(0, base + attackMod + missileBonus); + rows.push({ name: w, attack: atk, parry: null, note: 'missile' }); + } + + // Shield / parry weapon + if (genState.shieldWeapon) { + const w = genState.shieldWeapon; + const base = getCulturalBase(w); + const parryChoiceKey = genState.choiceSelections['parryChoice'] || genState.choiceSelections['weaponStyle']; + const parryChoiceGroup = result.choiceGroups['parryChoice'] || result.choiceGroups['weaponStyle'] || []; + let parryBonus = 0; + if (parryChoiceKey === 'parry:shield') { + const found = parryChoiceGroup.find((o) => o.key === 'parry:shield'); + parryBonus = found ? found.bonus : 0; + } + const pry = Math.max(0, base + parryMod + parryBonus); + rows.push({ name: w, attack: null, parry: pry, note: 'shield' }); + } + + if (!rows.length) return; + const tbl = el(`
`); + tbl.innerHTML = `WeaponAttack %Parry %`; + const tbody = el(``); + rows.forEach(({ name, attack, parry }) => { + tbody.appendChild(el(` + ${escapeHtml(name)} + ${attack != null ? attack + '%' : '—'} + ${parry != null ? parry + '%' : '—'} + `)); + }); + tbl.appendChild(tbody); + weaponTableWrap.appendChild(tbl); + } + + updateWeaponTable(); + occResultWrap.appendChild(weaponCard); + + // --- Ritual skills --- + if (Object.keys(result.ritualBonuses).length) { + const ritCard = el(`

Ritual Skills

`); + Object.entries(result.ritualBonuses).forEach(([key, bonus]) => { + ritCard.appendChild(el(`

${escapeHtml(key)}: +${bonus}% (${years} × ${bonus / years})

`)); + }); + occResultWrap.appendChild(ritCard); + } + + // --- Craft bonuses --- + if (Object.keys(result.craftBonuses).length) { + const craftCard = el(`

Craft Skills

`); + Object.entries(result.craftBonuses).forEach(([key, bonus]) => { + craftCard.appendChild(el(`

${escapeHtml(key)}: +${bonus}% (${years} × ${bonus / years})

`)); + }); + occResultWrap.appendChild(craftCard); + } + + // --- Magic --- + if (occ.magic) { + const magicCard = el(`

Magic

`); + const m = occ.magic; + let magicText = `Type: ${m.type}. Starting points: ${m.basePoints || 0}`; + if (m.extraPerYears) magicText += ` + 1 per ${m.extraPerYears} year(s) (total +${Math.floor(years / m.extraPerYears)})`; + magicCard.appendChild(el(`

${escapeHtml(magicText)}

`)); + occResultWrap.appendChild(magicCard); + } + + // --- Equipment --- + if (occ.equipment) { + const eqCard = el(`

Starting Equipment

`); + eqCard.appendChild(el(`

${escapeHtml(occ.equipment)}

`)); + occResultWrap.appendChild(eqCard); + } + } } - let activeInputs = null; - - function showRolledResult(chars, derived, bonusPoints) { - currentChars = chars; - if (bonusPoints > 0) { - activeInputs = buildStatInputs(false, chars); - } else { - buildStatInputs(true, chars); - activeInputs = null; - } - derivedWrap.innerHTML = ''; - derivedWrap.appendChild(renderDerivedStats(derived)); - saveWrap.style.display = ''; - genMsg.innerHTML = ''; + // Helper: derive attack/parry modifiers from chars without a server call + function deriveModsFromChars(chars) { + if (!chars) return null; + const { str, con, siz, int: INT, pow, dex, app } = chars; + function primary(c) { return c - 10; } + function secondary(c) { const d = c - 10; if (!d) return 0; const r = Math.sign(d) * Math.ceil(Math.abs(d) / 2); return Math.min(r, 10); } + function negative(c) { return 10 - c; } + const agility = primary(dex) + secondary(str) + negative(siz); + const manipulation = primary(INT) + primary(dex) + secondary(str); + return { attack: manipulation, parry: agility }; } - methodSel.addEventListener('change', () => { - statInputsWrap.innerHTML = ''; - derivedWrap.innerHTML = ''; - saveWrap.style.display = 'none'; - genMsg.innerHTML = ''; - budgetDisplay.style.display = 'none'; - currentChars = null; - activeInputs = null; + // ======== STEP 4 — Review & Save ======== + function renderStep4() { + const msg = el(`
`); + const years = genState.age - 15; - if (methodSel.value === 'deliberate') { - activeInputs = buildStatInputs(false, null); + const reviewCard = el(`
`); + reviewCard.appendChild(el(`

${escapeHtml(genState.name)}

`)); + + // Identity summary + reviewCard.appendChild(el(`

Age: ${genState.age}   Gender: ${genState.gender}   Years exp: ${years}

`)); + + // Characteristics summary + if (genState.chars) { + const row = el(`
`); + CHAR_KEYS.forEach((k) => row.appendChild(el(`${CHAR_LABELS[k]}: ${genState.chars[k]}`))); + reviewCard.appendChild(row); } - }); + if (genState.derived) reviewCard.appendChild(renderDerivedStats(genState.derived)); - const rollBtn = el(``); - const calcBtn = el(``); + // Culture / Occupation + reviewCard.appendChild(el(`

Culture: ${escapeHtml(genState.culture || '—')}   Occupation: ${escapeHtml(genState.occupation ? ((OCCUPATION_LABELS[genState.culture] || {})[genState.occupation] || genState.occupation) : '—')}

`)); - methodSel.addEventListener('change', () => { - rollBtn.style.display = methodSel.value === 'deliberate' ? 'none' : ''; - calcBtn.style.display = methodSel.value === 'deliberate' ? '' : 'none'; - }); - - rollBtn.addEventListener('click', async () => { - try { - const res = await api('POST', '/api/characters/roll', { method: methodSel.value }); - showRolledResult(res.chars, res.derived, res.bonusPoints); - } catch (err) { - genMsg.innerHTML = `

${escapeHtml(err.message)}

`; + // Choices + if (Object.keys(genState.choiceSelections).length) { + const choiceP = el(`

Choices: ${Object.entries(genState.choiceSelections).map(([g, k]) => `${g}: ${k}`).join('; ')}

`); + reviewCard.appendChild(choiceP); } - }); - calcBtn.addEventListener('click', async () => { - const chars = {}; - CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; }); - try { - const val = await api('POST', '/api/characters/validate', { method: 'deliberate', chars }); - if (!val.valid) { - genMsg.innerHTML = `

${val.errors.map(escapeHtml).join('
')}

`; - return; + stepContent.appendChild(reviewCard); + stepContent.appendChild(msg); + + const saveBtn = el(``); + stepContent.appendChild(saveBtn); + stepContent.appendChild(navRow(() => goToStep(3), null)); + + saveBtn.addEventListener('click', async () => { + if (!genState.chars) { msg.innerHTML = `

Missing characteristics.

`; return; } + + // Build weapon list + const weapons = []; + const mods = deriveModsFromChars(genState.chars); + const attackMod = mods ? mods.attack : 0; + const parryMod = mods ? mods.parry : 0; + const wb = (genState.occResult && genState.occResult.weaponBonuses) || {}; + const cw = CULTURAL_WEAPONS[genState.culture] || { attackParry: [], attackOnly: [], parryOnly: [] }; + + function getCulturalBase(weaponName) { + if (cw.attackParry.includes(weaponName)) { + if (genState.culture === 'Primitive') return 25; + if (genState.culture === 'Nomad') return 20; + if (genState.culture === 'Barbarian') return (weaponName.includes('2H') || weaponName.includes('Greatsword') || weaponName.includes('Bastard')) ? 15 : 25; + if (genState.culture === 'Civilized') return (weaponName.includes('2H') || weaponName.includes('Greatsword') || weaponName.includes('Bastard')) ? 15 : 25; + } + if (cw.attackOnly.includes(weaponName)) { + if (genState.culture === 'Primitive') return weaponName.includes('Sling') || weaponName.includes('Bow') ? 25 : 20; + if (genState.culture === 'Nomad') return weaponName.includes('Lance') ? 30 : 20; + return 25; + } + if (cw.parryOnly.includes(weaponName)) return 25; + return 0; } - const res = await api('POST', '/api/characters/derive', chars); - currentChars = chars; - derivedWrap.innerHTML = ''; - derivedWrap.appendChild(renderDerivedStats(res.derived)); - saveWrap.style.display = ''; - genMsg.innerHTML = ''; - } catch (err) { - genMsg.innerHTML = `

${escapeHtml(err.message)}

`; - } - }); - // Apply combined bonus button - const applyBonusBtn = el(``); - methodSel.addEventListener('change', () => { - applyBonusBtn.style.display = methodSel.value === 'combined' ? '' : 'none'; - }); - applyBonusBtn.addEventListener('click', async () => { - if (!activeInputs) return; - const chars = {}; - CHAR_KEYS.forEach((k) => { chars[k] = Number(activeInputs[k].value) || 0; }); - try { - const val = await api('POST', '/api/characters/validate', { method: 'combined', chars }); - if (!val.valid) { - genMsg.innerHTML = `

${val.errors.map(escapeHtml).join('
')}

`; - return; + // Fist + weapons.push({ weapon_name: 'Fist', category: 'Fist', skill_percent: Math.max(0, 25 + attackMod + (wb['attack:fist'] || 0)), mode: 'melee' }); + // Dagger + weapons.push({ weapon_name: 'Dagger', category: 'Dagger', skill_percent: Math.max(0, 15 + attackMod + (wb['attack:dagger'] || 0)), mode: 'melee' }); + + if (genState.primaryWeapon) { + const base = getCulturalBase(genState.primaryWeapon); + weapons.push({ weapon_name: genState.primaryWeapon, category: 'primary', skill_percent: Math.max(0, base + attackMod + (wb['attack:primary'] || 0)), mode: 'melee' }); + } + if (genState.missileWeapon) { + const base = getCulturalBase(genState.missileWeapon); + weapons.push({ weapon_name: genState.missileWeapon, category: 'missile', skill_percent: Math.max(0, base + attackMod + (wb['attack:missile'] || 0)), mode: 'missile' }); + } + if (genState.shieldWeapon) { + const base = getCulturalBase(genState.shieldWeapon); + const parryChoiceKey = genState.choiceSelections['parryChoice'] || genState.choiceSelections['weaponStyle']; + let parryBonus = 0; + if (genState.occResult) { + const parryChoiceGroup = (genState.occResult.choiceGroups || {})['parryChoice'] || (genState.occResult.choiceGroups || {})['weaponStyle'] || []; + if (parryChoiceKey === 'parry:shield') { + const found = parryChoiceGroup.find((o) => o.key === 'parry:shield'); + parryBonus = found ? found.bonus : 0; + } + } + weapons.push({ weapon_name: genState.shieldWeapon, category: 'shield', skill_percent: Math.max(0, base + parryMod + parryBonus), mode: 'parry' }); } - const res = await api('POST', '/api/characters/derive', chars); - currentChars = chars; - derivedWrap.innerHTML = ''; - derivedWrap.appendChild(renderDerivedStats(res.derived)); - saveWrap.style.display = ''; - genMsg.innerHTML = ''; - } catch (err) { - genMsg.innerHTML = `

${escapeHtml(err.message)}

`; - } - }); - // Save section - const nameInput = el(``); - const saveBtn = el(``); - saveWrap.append(nameInput, saveBtn); + try { + await api('POST', '/api/characters', { + name: genState.name, + generation_method: genState.method, + chars: genState.chars, + age: genState.age, + culture: genState.culture, + occupation: genState.occupation, + weapons, + }); + await loadPlayerCharacters(); + msg.innerHTML = `

Saved "${escapeHtml(genState.name)}"! Starting a new character...

`; + renderSidebar(); + // Reset wizard + setTimeout(() => { + Object.assign(genState, { step: 1, name: '', age: 21, gender: 'M', method: 'random', chars: null, derived: null, culture: null, occupation: null, occResult: null, choiceSelections: {}, primaryWeapon: null, missileWeapon: null, shieldWeapon: null }); + goToStep(1); + }, 1500); + } catch (err) { + msg.innerHTML = `

${escapeHtml(err.message)}

`; + } + }); + } - saveBtn.addEventListener('click', async () => { - const name = nameInput.value.trim(); - if (!name) { genMsg.innerHTML = `

Name is required.

`; return; } - if (!currentChars) return; - const chars = activeInputs - ? Object.fromEntries(CHAR_KEYS.map((k) => [k, Number(activeInputs[k].value) || 0])) - : currentChars; - try { - await api('POST', '/api/characters', { name, generation_method: methodSel.value, chars }); - await loadPlayerCharacters(); - genMsg.innerHTML = `

Saved "${escapeHtml(name)}".

`; - nameInput.value = ''; - renderSidebar(); - } catch (err) { - genMsg.innerHTML = `

${escapeHtml(err.message)}

`; - } - }); - - const btnRow = el(`
`); - btnRow.append(rollBtn, calcBtn, applyBonusBtn); - - genCard.append(statInputsWrap, budgetDisplay, btnRow, derivedWrap, saveWrap, genMsg); - wrap.appendChild(genCard); + // Start wizard + updateProgress(); + renderStep1(); // --- Selected PC view --- const pc = state.playerCharacters.find((c) => c.id === state.selectedCharacterId); if (pc) { - wrap.appendChild(el(`

${escapeHtml(pc.name)}

`)); - const pcCard = el(`
`); - const sb = pc.stat_block; - const statsRow = el(`
`); - ['str', 'con', 'siz', 'int', 'pow', 'dex', 'app'].forEach((k) => { - statsRow.appendChild(el(`${CHAR_LABELS[k]}: ${sb[k]}`)); - }); - pcCard.appendChild(statsRow); - pcCard.appendChild(el(`

HP: ${sb.current_hp}/${sb.max_hp}   MP: ${sb.magic_points_current}/${sb.magic_points_max}

`)); - - if (sb.hit_locations && sb.hit_locations.length) { - const locs = sb.hit_locations.map((l) => `${escapeHtml(l.location_name)}${l.current_hp}/${l.max_hp}`).join(''); - pcCard.appendChild(el(` - - ${locs} -
LocationHP
`)); - } - - const delBtn = el(``); - delBtn.addEventListener('click', async () => { - if (!confirm(`Delete "${pc.name}"?`)) return; - await api('DELETE', `/api/characters/${pc.id}`); - state.selectedCharacterId = null; - await loadPlayerCharacters(); - renderSidebar(); - renderMain(); - }); - pcCard.appendChild(delBtn); - wrap.appendChild(pcCard); - - // --- Inventory --- - wrap.appendChild(el(`

Inventory

`)); - wrap.appendChild(renderInventory(pc)); + wrap.appendChild(renderCharacterSheet(pc)); } return wrap; } +// ---------- Shared trait editor ---------- + +let _knownTraits = []; // populated from /api/rules/personality-traits on init + +async function loadPersonalityTraits() { + try { + const res = await api('GET', '/api/rules/personality-traits'); + _knownTraits = res.traits || []; + } catch (_) {} +} + +// saveFn: async (traits) → void +function renderTraitsEditor(traits, saveFn) { + const wrap = el(`
`); + const listWrap = el(`
`); + + let current = [...(traits || [])]; + + function redraw() { + listWrap.innerHTML = ''; + current.forEach((t, i) => { + const pill = el(``); + const nameSpan = el(`${escapeHtml(t.name)}`); + const ratingIn = el(``); + ratingIn.addEventListener('change', async () => { + current[i] = { ...current[i], rating: Math.min(100, Math.max(1, Number(ratingIn.value) || current[i].rating)) }; + await saveFn(current); + }); + const removeBtn = el(``); + removeBtn.addEventListener('click', async () => { + current.splice(i, 1); + await saveFn(current); + redraw(); + }); + pill.append(nameSpan, el(`:`), ratingIn, el(`%`), removeBtn); + listWrap.appendChild(pill); + }); + } + + // Add trait form + const suggestions = _knownTraits.filter(n => !current.find(t => t.name === n)); + const traitSel = el(``); + const customIn = el(``); + traitSel.addEventListener('change', () => { if (traitSel.value) customIn.value = traitSel.value; }); + const ratingIn = el(``); + const addBtn = el(``); + + addBtn.addEventListener('click', async () => { + const name = (customIn.value.trim() || traitSel.value).trim(); + if (!name) return; + if (current.find(t => t.name.toLowerCase() === name.toLowerCase())) return; + current.push({ name, rating: Math.min(100, Math.max(1, Number(ratingIn.value) || 60)) }); + await saveFn(current); + customIn.value = ''; traitSel.value = ''; + redraw(); + }); + + const formRow = el(`
`); + formRow.append(traitSel, customIn, ratingIn, el(`%`), addBtn); + + redraw(); + wrap.append(listWrap, formRow); + return wrap; +} + +function renderCharacterSheet(pc) { + const frag = el(`
`); + const sb = pc.stat_block; + const derived = pc.derived || {}; + const sm = derived.skillModifiers || {}; + const skills = pc.skills || {}; + + function sign(n) { return n >= 0 ? `+${n}` : `${n}`; } + function pct(n) { return n != null ? `${n}%` : '—'; } + + // ── Identity & location ────────────────────────────────────────── + frag.appendChild(el(`

${escapeHtml(pc.name)}

`)); + const identCard = el(`
`); + + const identRow = el(`
`); + if (pc.age) identRow.appendChild(el(`Age: ${pc.age}`)); + if (pc.culture) identRow.appendChild(el(`Culture: ${escapeHtml(pc.culture)}`)); + if (pc.occupation_label || pc.occupation) + identRow.appendChild(el(`Occupation: ${escapeHtml(pc.occupation_label || pc.occupation)}`)); + identCard.appendChild(identRow); + + // Location tracker + const locRow = el(`
`); + const locInput = el(``); + const destInput = el(``); + async function saveLocation() { + await api('PATCH', `/api/characters/${pc.id}/location`, { + current_location: locInput.value.trim() || null, + destination: destInput.value.trim() || null, + }); + pc.current_location = locInput.value.trim() || null; + pc.destination = destInput.value.trim() || null; + } + locInput.addEventListener('blur', saveLocation); + destInput.addEventListener('blur', saveLocation); + locRow.append(el(`Location:`), locInput, + el(``), destInput); + identCard.appendChild(locRow); + frag.appendChild(identCard); + + // ── Characteristics & derived ──────────────────────────────────── + const statsCard = el(`
`); + const charRow = el(`
`); + CHAR_KEYS.forEach((k) => { + charRow.appendChild(el(`
${CHAR_LABELS[k]}
${sb[k]}
`)); + }); + statsCard.appendChild(charRow); + + const derivedRow = el(`
`); + [ + ['HP', `${sb.current_hp}/${sb.max_hp}`], + ['FP', `${derived.fatigue ?? sb.str + sb.con}`], + ['MP', `${sb.magic_points_current}/${sb.magic_points_max}`], + ['DB', escapeHtml(derived.damageBonus || '0')], + ['SR', derived.strikeRank ?? '—'], + ].forEach(([label, val]) => derivedRow.appendChild(el(`${label}: ${val}`))); + statsCard.appendChild(derivedRow); + + const modRow = el(`
`); + [['Agility', sm.agility], ['Comm', sm.communication], ['Know', sm.knowledge], + ['Magic', sm.magic], ['Manip', sm.manipulation], ['Percep', sm.perception], ['Stealth', sm.stealth]].forEach(([label, v]) => { + if (v == null) return; + modRow.appendChild(el(`${label} ${sign(v)}%`)); + }); + statsCard.appendChild(modRow); + frag.appendChild(statsCard); + + // ── Skills ─────────────────────────────────────────────────────── + const SKILL_DISPLAY = { + agility: { label: 'Agility', keys: ['boat','climb','dodge','jump','ride','swim','throw'] }, + communication: { label: 'Communication', keys: ['fastTalk','orate','sing','speakOwnLanguage','speakOtherLanguage'] }, + knowledge: { label: 'Knowledge', keys: ['firstAid','animalLore','humanLore','mineralLore','plantLore','worldLore','evaluate'] }, + manipulation: { label: 'Manipulation', keys: ['conceal','sleight','devise'] }, + perception: { label: 'Perception', keys: ['listen','scan','search','track'] }, + stealth: { label: 'Stealth', keys: ['hide','sneak'] }, + }; + const SKILL_NAMES = { + boat:'Boat', climb:'Climb', dodge:'Dodge', jump:'Jump', ride:'Ride', swim:'Swim', throw:'Throw', + fastTalk:'Fast Talk', orate:'Orate', sing:'Sing', speakOwnLanguage:'Speak (own)', speakOtherLanguage:'Speak (other)', + firstAid:'First Aid', animalLore:'Animal Lore', humanLore:'Human Lore', mineralLore:'Mineral Lore', + plantLore:'Plant Lore', worldLore:'World Lore', evaluate:'Evaluate', + conceal:'Conceal', sleight:'Sleight', devise:'Devise', + listen:'Listen', scan:'Scan', search:'Search', track:'Track', + hide:'Hide', sneak:'Sneak', + }; + + if (Object.keys(skills).length) { + const skillsCard = el(`
`); + skillsCard.appendChild(el(`Skills`)); + const grid = el(`
`); + for (const [, { label, keys }] of Object.entries(SKILL_DISPLAY)) { + const section = el(`
`); + section.appendChild(el(`
${label}
`)); + keys.forEach((k) => { + const val = skills[k]; + if (!val && val !== 0) return; + section.appendChild(el(`
${SKILL_NAMES[k] || k}${pct(val)}
`)); + }); + // Ritual skills + if (pc.ritualBonuses) { + Object.entries(pc.ritualBonuses).forEach(([rk, bonus]) => { + const name = rk.replace('ritual:', '').replace(/^\w/, c => c.toUpperCase()); + section.appendChild(el(`
${name}${pct(5 + bonus)}
`)); + }); + } + if (section.children.length > 1) grid.appendChild(section); + } + skillsCard.appendChild(grid); + frag.appendChild(skillsCard); + } + + // ── Weapons ────────────────────────────────────────────────────── + if (sb.weapons && sb.weapons.length) { + const weapCard = el(`
`); + weapCard.appendChild(el(`Weapons`)); + const tbl = el(`
`); + tbl.innerHTML = ` + Weapon + Atk% + Par% + Mode + `; + const tbody = el(``); + sb.weapons.forEach((w) => { + const row = el(` + ${escapeHtml(w.weapon_name)} + ${w.skill_percent}% + ${w.parry_percent ? w.parry_percent + '%' : '—'} + ${escapeHtml(w.mode || w.category || '')} + `); + tbody.appendChild(row); + }); + tbl.appendChild(tbody); + weapCard.appendChild(tbl); + frag.appendChild(weapCard); + } + + // ── Hit Locations ───────────────────────────────────────────────── + if (sb.hit_locations && sb.hit_locations.length) { + const locCard = el(`
`); + locCard.appendChild(el(`Hit Locations`)); + const tbl = el(`
`); + tbl.innerHTML = ` + Location + HP + Armour AP + `; + const tbody = el(``); + sb.hit_locations.forEach((l) => { + tbody.appendChild(el(` + ${escapeHtml(l.location_name)} + ${l.current_hp}/${l.max_hp} + ${l.armor_ap || 0} + `)); + }); + tbl.appendChild(tbody); + locCard.appendChild(tbl); + frag.appendChild(locCard); + } + + // ── Personality Traits ─────────────────────────────────────────── + const traitCard = el(`
`); + traitCard.appendChild(el(`Personality Traits`)); + traitCard.appendChild(renderTraitsEditor(pc.traits || [], async (traits) => { + await api('PATCH', `/api/characters/${pc.id}/traits`, { traits }); + pc.traits = traits; + await loadPlayerCharacters(); + })); + frag.appendChild(traitCard); + + // ── Delete ──────────────────────────────────────────────────────── + const delBtn = el(``); + delBtn.addEventListener('click', async () => { + if (!confirm(`Delete "${pc.name}"?`)) return; + await api('DELETE', `/api/characters/${pc.id}`); + state.selectedCharacterId = null; + await loadPlayerCharacters(); + renderSidebar(); + renderMain(); + }); + frag.appendChild(delBtn); + + // ── Inventory ──────────────────────────────────────────────────── + frag.appendChild(el(`

Inventory

`)); + frag.appendChild(renderInventory(pc)); + + return frag; +} + function renderInventory(pc) { const wrap = el(`
`); const listWrap = el(`
`); @@ -1314,23 +2127,58 @@ function renderAdventureMain() { } if (scene.status === 'active') { - scene.choices.forEach((ch) => { - const label = ch.skillPercent != null - ? `${ch.label} (${ch.skill} ${ch.skillPercent}%)` - : ch.label; - const btn = el(``); - btn.addEventListener('click', async () => { + const personalityHint = el(`

`); + choiceBox.appendChild(personalityHint); + + let suggestedId = null; + + function renderChoiceButtons(highlightId) { + // remove old choice buttons (everything after the hint p) + [...choiceBox.children].filter(c => c !== personalityHint).forEach(c => c.remove()); + scene.choices.forEach((ch) => { + const label = ch.skillPercent != null + ? `${ch.label} (${ch.skill} ${ch.skillPercent}%)` + : ch.label; + const isHint = ch.id === highlightId; + const btn = el(``); + btn.addEventListener('click', async () => { + try { + const res = await api('POST', '/api/adventure/choose', { choiceId: ch.id }); + state.adventure = res; + renderScene(res); + await loadLog(); renderLog(); + } catch (err) { + errBox.innerHTML = `

${escapeHtml(err.message)}

`; + } + }); + choiceBox.appendChild(btn); + }); + } + + renderChoiceButtons(null); + + // Personality roll button — only shown if the adventuring PC has traits + const pc = state.playerCharacters.find(c => c.id === (state.adventure && state.adventure.characterId)); + if (pc && pc.traits && pc.traits.length) { + const rollTraitBtn = el(``); + rollTraitBtn.addEventListener('click', async () => { try { - const res = await api('POST', '/api/adventure/choose', { choiceId: ch.id }); - state.adventure = res; - renderScene(res); - await loadLog(); renderLog(); + const res = await api('POST', '/api/adventure/personality-roll'); + suggestedId = res.suggested; + const fired = res.firedTraits.map(t => `${t.name} (rolled ${t.roll}/${t.rating}%)`).join(', '); + if (res.suggested) { + const choiceLabel = scene.choices.find(c => c.id === res.suggested)?.label || res.suggested; + personalityHint.textContent = `Personality suggests: ${choiceLabel}${fired ? ` — ${fired}` : ''}`; + } else { + personalityHint.textContent = fired ? `Traits fired (${fired}) but no clear action.` : 'No traits fired — act freely.'; + } + renderChoiceButtons(suggestedId); } catch (err) { errBox.innerHTML = `

${escapeHtml(err.message)}

`; } }); - choiceBox.appendChild(btn); - }); + choiceBox.appendChild(rollTraitBtn); + } } if (scene.resolution) { @@ -1414,6 +2262,17 @@ async function init() { qs('#export-all-btn').addEventListener('click', exportAllData); qs('#export-log-btn').addEventListener('click', exportLogData); + qs('#clear-all-btn').addEventListener('click', async () => { + if (!confirm('Clear all characters, NPCs, enemies, combat, adventure, and log entries?\nTables and spell mappings are kept.\nThis cannot be undone.')) return; + await api('POST', '/api/clear-all'); + await Promise.all([loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadPlayerCharacters(), loadAdventure()]); + state.selectedCharacterId = null; + state.selectedNpcId = null; + state.selectedEnemyId = null; + state.characterInventory = []; + state.dirty = false; + setView('tables'); + }); qs('#import-file').addEventListener('change', (e) => { if (e.target.files[0]) importFile(e.target.files[0]); e.target.value = ''; @@ -1424,6 +2283,7 @@ async function init() { await Promise.all([ loadTablesTree(), loadNpcs(), loadEnemies(), loadCombat(), loadLog(), loadSpellMappings(), loadAttackModifiers(), loadArmorTable(), loadPlayerCharacters(), loadAdventure(), + loadPersonalityTraits(), ]); setView('tables'); renderLog(); diff --git a/public/index.html b/public/index.html index b45b97f..db7cc32 100644 --- a/public/index.html +++ b/public/index.html @@ -14,6 +14,7 @@ + diff --git a/rq3.js b/rq3.js index 829e1f4..d1e0b8d 100644 --- a/rq3.js +++ b/rq3.js @@ -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, diff --git a/scripts/import-tables.js b/scripts/import-tables.js index aa90b82..1324fc4 100644 --- a/scripts/import-tables.js +++ b/scripts/import-tables.js @@ -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() { dbApi.tables.clearImported(); @@ -116,6 +164,7 @@ function main() { } resolveLayer1Links(); + resolveCharacterTableLinks(); console.log('---'); console.log(`Files: ${stats.files}`); diff --git a/server.js b/server.js index ae8949d..2bede24 100644 --- a/server.js +++ b/server.js @@ -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) => { diff --git a/tables/rq3_character_tables.md b/tables/rq3_character_tables.md new file mode 100644 index 0000000..6fa8197 --- /dev/null +++ b/tables/rq3_character_tables.md @@ -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 |