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 = `| Skill | Category | Total % |
`;
+ 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 = `| Weapon | Attack % | 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(``));
- }
-
- 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 |