feat: full character creation, personality traits, and session quality-of-life
Character creation: - Full RQ3 Previous Experience wizard (4-step: identity, characteristics, culture/occupation/skills, review+save) with all 29 occupations across 4 cultures; skills computed as base + category modifier + years × multiplier - Character sheet expanded to show culture, occupation, derived stats (HP/FP/MP/ DB/SR), all 7 skill category modifier badges, computed skill percentages grouped by category, weapon attack/parry %, hit locations with armour AP - Location/destination tracker on character sheet (auto-saves on blur) - parry_percent stored on combatant_weapons Personality traits: - 24 predefined traits (Brave, Greedy, Cautious, etc.) each rated 0-100% - Trait → action bias map drives scene choice weighting - rollPersonalityAction() rolls d100 per trait, sums biases, returns suggestion - Trait editor (pill UI) on both character sheet and NPC view - Adventure tab: Roll Personality button fires traits, highlights suggested choice Tables and data: - Import RQ3 character creation tables (Culture d8, Occupation d100 ×4, Craft sub-tables, Language Proficiency, Dropped Oil Lamp, Aging, Armor Points) - Cross-table links: Culture → Occupation, Barbarian/Civilized Crafter → Craft - Fix rollOnTable to use actual dice notation instead of flat random row index - Remove unused resolveHeadInjury function Session tools: - Clear All button wipes all session data (characters, NPCs, enemies, combat, adventure, log) while keeping tables and spell mappings - PATCH /api/characters/:id/traits and /api/npcs/:id/traits endpoints - GET /api/rules/personality-traits reference endpoint - POST /api/adventure/personality-roll endpoint
This commit is contained in:
@@ -2,28 +2,44 @@
|
|||||||
|
|
||||||
## High priority (mechanics gaps)
|
## High priority (mechanics gaps)
|
||||||
|
|
||||||
- [ ] Call `resolveHeadInjury()` in the attack handler when a Head hit penetrates armor
|
- [x] Fix `rollOnTable` to roll the table's actual dice notation instead of picking a flat random row — now affects the adventure engine (Culture Table, Occupation Tables) where unequal row ranges matter
|
||||||
- [x] Resolve stuck-weapon follow-up after impale/slash special hits (`attemptWeaponRemoval`, `removeStuckWeaponFromSelf`, `removeStuckWeaponWithFirstAid`)
|
- [x] Resolve stuck-weapon follow-up after impale/slash special hits (`attemptWeaponRemoval`, `removeStuckWeaponFromSelf`, `removeStuckWeaponWithFirstAid`)
|
||||||
|
|
||||||
## Medium priority (missing features)
|
## Medium priority (missing features)
|
||||||
|
|
||||||
|
### Adventure / CYOA
|
||||||
|
- [x] Add character location / destination tracker — where the PC currently is and where they are heading; used to auto-select which encounter table to roll
|
||||||
|
- [ ] Add Layer 3 (and possibly Layer 4) sub-tables to the Norse encounter tables so scene descriptions cascade into richer detail
|
||||||
|
- [ ] Add a Previous Experience step to the PC generator (culture → occupation → skills, magic, equipment) using the imported occupation tables
|
||||||
|
- [ ] Wire PC weapon skills into adventure scene choices so "Fight" shows the correct attack %
|
||||||
|
- [ ] Persist scene history so the adventure log reads as a continuous narrative
|
||||||
|
|
||||||
|
### Combat
|
||||||
- [ ] Build strike-rank round scheduling UI (round counter, Next Round button, SR-ordered action display using `buildStrikeRankSchedule`)
|
- [ ] Build strike-rank round scheduling UI (round counter, Next Round button, SR-ordered action display using `buildStrikeRankSchedule`)
|
||||||
|
|
||||||
|
### Other
|
||||||
- [ ] Add API routes and UI for the experience/improvement system (`markWeaponExperienceChecked`, `updateWeaponSkillPercent`, `applyImprovement`)
|
- [ ] Add API routes and UI for the experience/improvement system (`markWeaponExperienceChecked`, `updateWeaponSkillPercent`, `applyImprovement`)
|
||||||
- [ ] Add Layer 3 (and possibly Layer 4) sub-tables to the Norse encounter tables so rolls can cascade deeper
|
|
||||||
- [ ] Add a character location / destination tracker — where the PC currently is and where they are heading (used to contextualise encounters and travel events)
|
|
||||||
- [x] Player character generator — stat rolls, derived stats, skills, weapons, hit locations; needed for combat and skill checks
|
|
||||||
|
|
||||||
## Low priority (polish)
|
## Low priority (polish)
|
||||||
|
|
||||||
- [ ] Wire up the export reminder modal (show on `beforeunload`, hook up Export Now / Leave Anyway / Cancel buttons)
|
- [ ] Wire up the export reminder modal (show on `beforeunload`, hook up Export Now / Leave Anyway / Cancel buttons)
|
||||||
- [ ] Add `DELETE /api/spell-mappings/:id` endpoint and a spell mapping management UI
|
- [ ] Add `DELETE /api/spell-mappings/:id` endpoint and a spell mapping management UI
|
||||||
- [ ] Add weapon and spell editing forms to NPC stat block view (currently only available for enemies)
|
- [ ] Add weapon and spell editing forms to NPC stat block view (currently only available for enemies)
|
||||||
|
- [ ] Add item editing (change qty inline) and ENC-per-item display to inventory UI
|
||||||
|
|
||||||
## Correctness fixes
|
## Correctness fixes
|
||||||
|
|
||||||
- [ ] Fix `rollOnTable` to roll the table's actual dice notation and match the range, instead of picking a flat random row index
|
|
||||||
- [ ] Fix `state.dirty` being set to `true` on GET requests
|
- [ ] Fix `state.dirty` being set to `true` on GET requests
|
||||||
|
|
||||||
## Cleanup
|
## Cleanup
|
||||||
|
|
||||||
- [ ] Remove dead `state.reactionType` from frontend state
|
- [ ] Remove dead `state.reactionType` from frontend state
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- [x] Player character generator — random/deliberate/combined methods, correct RQ3 derived stats
|
||||||
|
- [x] Fix total HP formula (was CON+SIZ, now ceil((CON+SIZ)/2)); fix hit location HPs
|
||||||
|
- [x] Skill category modifiers (all 7, primary/secondary/negative influences) + fatigue points
|
||||||
|
- [x] Inventory system — items with ENC tracking, effective FP = STR+CON − total ENC
|
||||||
|
- [x] Adventure scene engine — procedural CYOA choices resolved via skill checks; scene state persisted
|
||||||
|
- [x] Import RQ3 character creation tables (Culture, Occupation, Craft, Language, Aging, etc.) with cross-table links
|
||||||
|
|||||||
@@ -245,11 +245,11 @@ function setHitLocations(statBlockId, locations) {
|
|||||||
function setWeapons(statBlockId, weapons) {
|
function setWeapons(statBlockId, weapons) {
|
||||||
db.prepare('DELETE FROM combatant_weapons WHERE stat_block_id = ?').run(statBlockId);
|
db.prepare('DELETE FROM combatant_weapons WHERE stat_block_id = ?').run(statBlockId);
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, mode, experience_checked)
|
INSERT INTO combatant_weapons (stat_block_id, weapon_name, category, skill_percent, parry_percent, mode, experience_checked)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
for (const w of weapons) {
|
for (const w of weapons) {
|
||||||
stmt.run(statBlockId, w.weapon_name, w.category ?? null, w.skill_percent ?? 0, w.mode ?? null, w.experience_checked ? 1 : 0);
|
stmt.run(statBlockId, w.weapon_name, w.category ?? null, w.skill_percent ?? 0, w.parry_percent ?? 0, w.mode ?? null, w.experience_checked ? 1 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,16 +279,25 @@ const NPC_FIELDS = [
|
|||||||
'secret_obstacle', 'also_carrying', 'race', 'pronouns', 'age', 'intelligence', 'hair', 'build', 'status',
|
'secret_obstacle', 'also_carrying', 'race', 'pronouns', 'age', 'intelligence', 'hair', 'build', 'status',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function parseTraits(row) {
|
||||||
|
return { ...row, traits: row.traits_json ? JSON.parse(row.traits_json) : [] };
|
||||||
|
}
|
||||||
|
|
||||||
function listNpcs() {
|
function listNpcs() {
|
||||||
const npcs = db.prepare('SELECT * FROM npcs ORDER BY id DESC').all();
|
const npcs = db.prepare('SELECT * FROM npcs ORDER BY id DESC').all();
|
||||||
return npcs.map((n) => ({ ...n, stat_block: n.stat_block_id ? getStatBlock(n.stat_block_id) : null }));
|
return npcs.map((n) => ({ ...parseTraits(n), stat_block: n.stat_block_id ? getStatBlock(n.stat_block_id) : null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNpc(id) {
|
function getNpc(id) {
|
||||||
const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id);
|
const npc = db.prepare('SELECT * FROM npcs WHERE id = ?').get(id);
|
||||||
if (!npc) return null;
|
if (!npc) return null;
|
||||||
npc.stat_block = npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null;
|
return { ...parseTraits(npc), stat_block: npc.stat_block_id ? getStatBlock(npc.stat_block_id) : null };
|
||||||
return npc;
|
}
|
||||||
|
|
||||||
|
function updateNpcTraits(id, traits) {
|
||||||
|
db.prepare(`UPDATE npcs SET traits_json=?, updated_at=datetime('now') WHERE id=?`)
|
||||||
|
.run(JSON.stringify(traits), id);
|
||||||
|
return getNpc(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNpc(data) {
|
function createNpc(data) {
|
||||||
@@ -432,25 +441,38 @@ function clearAdventureState() {
|
|||||||
db.prepare('DELETE FROM adventure_state WHERE id = 1').run();
|
db.prepare('DELETE FROM adventure_state WHERE id = 1').run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const sql of [
|
||||||
|
'ALTER TABLE player_characters ADD COLUMN age INTEGER NOT NULL DEFAULT 21',
|
||||||
|
'ALTER TABLE player_characters ADD COLUMN culture TEXT',
|
||||||
|
'ALTER TABLE player_characters ADD COLUMN occupation TEXT',
|
||||||
|
'ALTER TABLE player_characters ADD COLUMN current_location TEXT',
|
||||||
|
'ALTER TABLE player_characters ADD COLUMN destination TEXT',
|
||||||
|
'ALTER TABLE combatant_weapons ADD COLUMN parry_percent INTEGER NOT NULL DEFAULT 0',
|
||||||
|
'ALTER TABLE player_characters ADD COLUMN traits_json TEXT',
|
||||||
|
'ALTER TABLE npcs ADD COLUMN traits_json TEXT',
|
||||||
|
]) {
|
||||||
|
try { db.exec(sql); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- player characters ----------
|
// ---------- player characters ----------
|
||||||
|
|
||||||
function listPlayerCharacters() {
|
function listPlayerCharacters() {
|
||||||
const rows = db.prepare('SELECT * FROM player_characters ORDER BY id DESC').all();
|
const rows = db.prepare('SELECT * FROM player_characters ORDER BY id DESC').all();
|
||||||
return rows.map((r) => ({ ...r, stat_block: getStatBlock(r.stat_block_id) }));
|
return rows.map((r) => ({ ...parseTraits(r), stat_block: getStatBlock(r.stat_block_id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlayerCharacter(id) {
|
function getPlayerCharacter(id) {
|
||||||
const row = db.prepare('SELECT * FROM player_characters WHERE id = ?').get(id);
|
const row = db.prepare('SELECT * FROM player_characters WHERE id = ?').get(id);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { ...row, stat_block: getStatBlock(row.stat_block_id) };
|
return { ...parseTraits(row), stat_block: getStatBlock(row.stat_block_id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPlayerCharacter({ name, generation_method, stat_block }) {
|
function createPlayerCharacter({ name, generation_method, stat_block, age, culture, occupation }) {
|
||||||
const statBlockId = createStatBlock(stat_block || {}).id;
|
const statBlockId = createStatBlock(stat_block || {}).id;
|
||||||
const info = db.prepare(`
|
const info = db.prepare(`
|
||||||
INSERT INTO player_characters (name, generation_method, stat_block_id)
|
INSERT INTO player_characters (name, generation_method, stat_block_id, age, culture, occupation)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(name, generation_method ?? 'random', statBlockId);
|
`).run(name, generation_method ?? 'random', statBlockId, age ?? 21, culture ?? null, occupation ?? null);
|
||||||
return getPlayerCharacter(info.lastInsertRowid);
|
return getPlayerCharacter(info.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +484,19 @@ function deletePlayerCharacter(id) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePlayerCharacterTraits(id, traits) {
|
||||||
|
db.prepare(`UPDATE player_characters SET traits_json=?, updated_at=datetime('now') WHERE id=?`)
|
||||||
|
.run(JSON.stringify(traits), id);
|
||||||
|
return getPlayerCharacter(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayerCharacterLocation(id, { current_location, destination }) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE player_characters SET current_location=?, destination=?, updated_at=datetime('now') WHERE id=?
|
||||||
|
`).run(current_location ?? null, destination ?? null, id);
|
||||||
|
return getPlayerCharacter(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- log ----------
|
// ---------- log ----------
|
||||||
|
|
||||||
function appendLogEntry({ type, summary, details }) {
|
function appendLogEntry({ type, summary, details }) {
|
||||||
@@ -645,6 +680,18 @@ function exportAll() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM adventure_state').run(); // references player_characters
|
||||||
|
db.prepare('DELETE FROM player_characters').run(); // cascades inventory_items
|
||||||
|
db.prepare('DELETE FROM npcs').run();
|
||||||
|
db.prepare('DELETE FROM enemies').run();
|
||||||
|
db.prepare('DELETE FROM stat_blocks').run(); // cascades hit_locations, weapons, spells
|
||||||
|
db.prepare('DELETE FROM log_entries').run();
|
||||||
|
db.prepare('DELETE FROM combat_state').run();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
function importAll(dump) {
|
function importAll(dump) {
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare('DELETE FROM npcs').run();
|
db.prepare('DELETE FROM npcs').run();
|
||||||
@@ -689,9 +736,9 @@ module.exports = {
|
|||||||
markWeaponExperienceChecked,
|
markWeaponExperienceChecked,
|
||||||
updateWeaponSkillPercent,
|
updateWeaponSkillPercent,
|
||||||
},
|
},
|
||||||
npcs: { list: listNpcs, get: getNpc, create: createNpc, update: updateNpc, delete: deleteNpc, linkStatBlock: linkNpcStatBlock },
|
npcs: { list: listNpcs, get: getNpc, create: createNpc, update: updateNpc, delete: deleteNpc, linkStatBlock: linkNpcStatBlock, updateTraits: updateNpcTraits },
|
||||||
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
|
enemies: { list: listEnemies, get: getEnemy, create: createEnemy, update: updateEnemy, delete: deleteEnemy },
|
||||||
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter },
|
playerCharacters: { list: listPlayerCharacters, get: getPlayerCharacter, create: createPlayerCharacter, delete: deletePlayerCharacter, updateLocation: updatePlayerCharacterLocation, updateTraits: updatePlayerCharacterTraits },
|
||||||
inventory: { list: listInventory, add: addInventoryItem, update: updateInventoryItem, delete: deleteInventoryItem, totalEnc: inventoryTotalEnc },
|
inventory: { list: listInventory, add: addInventoryItem, update: updateInventoryItem, delete: deleteInventoryItem, totalEnc: inventoryTotalEnc },
|
||||||
adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState },
|
adventure: { get: getAdventureState, set: setAdventureState, clear: clearAdventureState },
|
||||||
log: { append: appendLogEntry, get: getLogEntry, search: searchLog },
|
log: { append: appendLogEntry, get: getLogEntry, search: searchLog },
|
||||||
@@ -712,6 +759,7 @@ module.exports = {
|
|||||||
getRowsForTable,
|
getRowsForTable,
|
||||||
},
|
},
|
||||||
spellMappings: { list: listSpellMappings, upsert: upsertSpellMapping },
|
spellMappings: { list: listSpellMappings, upsert: upsertSpellMapping },
|
||||||
|
clearAll,
|
||||||
exportAll,
|
exportAll,
|
||||||
importAll,
|
importAll,
|
||||||
};
|
};
|
||||||
|
|||||||
+1091
-231
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
<input type="file" id="import-file" accept="application/json" hidden>
|
<input type="file" id="import-file" accept="application/json" hidden>
|
||||||
<button id="export-all-btn" class="button">Export All</button>
|
<button id="export-all-btn" class="button">Export All</button>
|
||||||
<button id="export-log-btn" class="button">Export Log</button>
|
<button id="export-log-btn" class="button">Export Log</button>
|
||||||
|
<button id="clear-all-btn" class="button" style="color:red">Clear All</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -718,6 +718,73 @@ function sumAttackModifiers(selectedIds, { targetSiz } = {}) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Personality Traits ----------
|
||||||
|
// Traits are stored as { name, rating } where rating is 0-100%.
|
||||||
|
// A trait roll succeeds when d100 <= rating, indicating the character acts on it.
|
||||||
|
|
||||||
|
const PERSONALITY_TRAITS = [
|
||||||
|
'Brave', 'Cowardly', 'Honest', 'Deceitful', 'Generous', 'Greedy',
|
||||||
|
'Loyal', 'Treacherous', 'Curious', 'Cautious', 'Reckless', 'Aggressive',
|
||||||
|
'Peaceful', 'Patient', 'Impulsive', 'Proud', 'Vengeful', 'Forgiving',
|
||||||
|
'Pious', 'Cynical', 'Suspicious', 'Trusting', 'Compassionate', 'Ruthless',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Bias each scene choice ID by ±% when this trait fires.
|
||||||
|
// Positive = more inclined; negative = less inclined.
|
||||||
|
const TRAIT_ACTION_BIAS = {
|
||||||
|
Brave: { fight: +25, flee: -20 },
|
||||||
|
Cowardly: { fight: -25, flee: +30, sneak: +15 },
|
||||||
|
Aggressive: { fight: +30, talk: -15 },
|
||||||
|
Peaceful: { fight: -20, talk: +20 },
|
||||||
|
Curious: { investigate: +30 },
|
||||||
|
Greedy: { investigate: +20 },
|
||||||
|
Cautious: { investigate: +15, fight: -15, flee: +10 },
|
||||||
|
Reckless: { fight: +20, flee: -25 },
|
||||||
|
Deceitful: { talk: +25 },
|
||||||
|
Honest: { talk: -10 },
|
||||||
|
Suspicious: { talk: -15, investigate: +15 },
|
||||||
|
Trusting: { talk: +15 },
|
||||||
|
Loyal: { fight: +15, flee: -15 },
|
||||||
|
Treacherous: { flee: +15 },
|
||||||
|
Vengeful: { fight: +20 },
|
||||||
|
Forgiving: { talk: +20, fight: -10 },
|
||||||
|
Patient: { investigate: +20, sneak: +15 },
|
||||||
|
Impulsive: { fight: +15, sneak: -10 },
|
||||||
|
Proud: { flee: -20, fight: +10 },
|
||||||
|
Ruthless: { fight: +15, flee: +10 },
|
||||||
|
Compassionate:{ fight: -15, talk: +20 },
|
||||||
|
Pious: { fight: +10 },
|
||||||
|
Cynical: { talk: -10 },
|
||||||
|
Generous: { talk: +10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roll each trait against its rating. Returns { firedTraits, choiceBiases, suggested }.
|
||||||
|
// choiceBiases: { choiceId → net bias } summed across all fired traits.
|
||||||
|
// suggested: the choice ID with the highest net positive bias (null if none).
|
||||||
|
function rollPersonalityAction(traits) {
|
||||||
|
const fired = [];
|
||||||
|
const biases = {};
|
||||||
|
|
||||||
|
for (const trait of (traits || [])) {
|
||||||
|
const roll = rollPercentile();
|
||||||
|
const success = roll <= trait.rating;
|
||||||
|
if (success) {
|
||||||
|
fired.push({ name: trait.name, roll, rating: trait.rating });
|
||||||
|
const b = TRAIT_ACTION_BIAS[trait.name] || {};
|
||||||
|
for (const [choiceId, delta] of Object.entries(b)) {
|
||||||
|
biases[choiceId] = (biases[choiceId] || 0) + delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggested = Object.entries(biases).sort((a, b) => b[1] - a[1]).find(([, v]) => v > 0);
|
||||||
|
return {
|
||||||
|
firedTraits: fired,
|
||||||
|
choiceBiases: biases,
|
||||||
|
suggested: suggested ? suggested[0] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Character Culture & Cultural Weapon Bonuses ----------
|
// ---------- Character Culture & Cultural Weapon Bonuses ----------
|
||||||
|
|
||||||
const CHARACTER_CULTURES = [
|
const CHARACTER_CULTURES = [
|
||||||
@@ -1074,6 +1141,678 @@ function buildStrikeRankSchedule(actions) {
|
|||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Age ----------
|
||||||
|
|
||||||
|
function rollAge() { return rollNotation('2d6') + 15; }
|
||||||
|
|
||||||
|
// ---------- Occupations ----------
|
||||||
|
// skills entries: { key, mult, group? }
|
||||||
|
// group flags OR-choice groups; the user picks exactly one from each group.
|
||||||
|
// Weapon skill keys: 'attack:fist', 'attack:dagger', 'attack:primary', 'attack:missile',
|
||||||
|
// 'attack:1hweapon', 'attack:2hweapon', 'attack:spear1h', 'attack:spear2h',
|
||||||
|
// 'parry:weapon', 'parry:shield'
|
||||||
|
// Ritual keys: 'ritual:ceremony', 'ritual:enchant', 'ritual:summon'
|
||||||
|
// Craft keys: 'craft:leather', 'craft:stone', 'craft:wood'
|
||||||
|
|
||||||
|
const OCCUPATIONS = {
|
||||||
|
Primitive: {
|
||||||
|
fisher: {
|
||||||
|
label: 'Fisher',
|
||||||
|
skills: [
|
||||||
|
{ key: 'boat', mult: 4 },
|
||||||
|
{ key: 'swim', mult: 3 },
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Net or fishing spear, knife, canoe or coracle',
|
||||||
|
},
|
||||||
|
hunter: {
|
||||||
|
label: 'Hunter',
|
||||||
|
skills: [
|
||||||
|
{ key: 'throw', mult: 3 },
|
||||||
|
{ key: 'craft:leather', mult: 1 },
|
||||||
|
{ key: 'craft:stone', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 2 },
|
||||||
|
{ key: 'plantLore', mult: 2 },
|
||||||
|
{ key: 'listen', mult: 3 },
|
||||||
|
{ key: 'scan', mult: 3 },
|
||||||
|
{ key: 'track', mult: 3 },
|
||||||
|
{ key: 'hide', mult: 3 },
|
||||||
|
{ key: 'sneak', mult: 4 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:missile', mult: 3 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Primary weapon, missile weapon, knife, hide armor (2 AP)',
|
||||||
|
},
|
||||||
|
shaman: {
|
||||||
|
label: 'Assistant Shaman',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 2 },
|
||||||
|
{ key: 'animalLore', mult: 3 },
|
||||||
|
{ key: 'plantLore', mult: 3 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'ritual:ceremony', mult: 3 },
|
||||||
|
{ key: 'ritual:summon', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 3, extraPerYears: 1 },
|
||||||
|
equipment: "Shaman's fetish bundle, knife, hide or leather armor",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Nomad: {
|
||||||
|
crafter: {
|
||||||
|
label: 'Crafter',
|
||||||
|
skills: [
|
||||||
|
{ key: 'craft:leather', mult: 4 },
|
||||||
|
{ key: 'craft:wood', mult: 2 },
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Craft tools, trade goods worth 100L, knife',
|
||||||
|
},
|
||||||
|
herder: {
|
||||||
|
label: 'Herder',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 4 },
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 4 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 3 },
|
||||||
|
{ key: 'track', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Riding animal, lasso, primary weapon, knife',
|
||||||
|
},
|
||||||
|
hunter: {
|
||||||
|
label: 'Hunter',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 2 },
|
||||||
|
{ key: 'throw', mult: 2 },
|
||||||
|
{ key: 'animalLore', mult: 2 },
|
||||||
|
{ key: 'plantLore', mult: 1 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 3 },
|
||||||
|
{ key: 'track', mult: 3 },
|
||||||
|
{ key: 'hide', mult: 2 },
|
||||||
|
{ key: 'sneak', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:missile', mult: 3 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Riding animal, missile weapon, primary weapon, knife',
|
||||||
|
},
|
||||||
|
noble: {
|
||||||
|
label: 'Noble',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 3 },
|
||||||
|
{ key: 'orate', mult: 2 },
|
||||||
|
{ key: 'humanLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 2 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 3 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:missile', mult: 2 },
|
||||||
|
{ key: 'attack:primary', mult: 3 },
|
||||||
|
{ key: 'parry:weapon', mult: 2, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 2, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 2, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 2 },
|
||||||
|
equipment: 'Riding animal, primary weapon, shield or parry weapon, light armor',
|
||||||
|
},
|
||||||
|
shaman: {
|
||||||
|
label: 'Assistant Shaman',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 2 },
|
||||||
|
{ key: 'animalLore', mult: 3 },
|
||||||
|
{ key: 'plantLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'ritual:ceremony', mult: 3 },
|
||||||
|
{ key: 'ritual:summon', mult: 3 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 3, extraPerYears: 1 },
|
||||||
|
equipment: "Shaman's fetish bundle, knife",
|
||||||
|
},
|
||||||
|
warrior: {
|
||||||
|
label: 'Warrior',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 3 },
|
||||||
|
{ key: 'throw', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'track', mult: 1 },
|
||||||
|
{ key: 'attack:fist', mult: 2 },
|
||||||
|
{ key: 'attack:dagger', mult: 2 },
|
||||||
|
{ key: 'attack:missile', mult: 2 },
|
||||||
|
{ key: 'attack:primary', mult: 4 },
|
||||||
|
{ key: 'parry:weapon', mult: 3, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 3, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 3, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 2 },
|
||||||
|
equipment: 'Riding animal, primary weapon, parry weapon or shield, leather armor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Barbarian: {
|
||||||
|
crafter: {
|
||||||
|
label: 'Crafter',
|
||||||
|
skills: [
|
||||||
|
{ key: 'craft:leather', mult: 2, group: 'craftPick' },
|
||||||
|
{ key: 'craft:wood', mult: 2, group: 'craftPick' },
|
||||||
|
{ key: 'craft:stone', mult: 2, group: 'craftPick' },
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 1 },
|
||||||
|
{ key: 'evaluate', mult: 3 },
|
||||||
|
{ key: 'scan', mult: 1 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Craft tools, trade goods worth 150L, knife',
|
||||||
|
},
|
||||||
|
entertainer: {
|
||||||
|
label: 'Entertainer',
|
||||||
|
skills: [
|
||||||
|
{ key: 'climb', mult: 1 },
|
||||||
|
{ key: 'jump', mult: 1 },
|
||||||
|
{ key: 'fastTalk', mult: 3 },
|
||||||
|
{ key: 'orate', mult: 2 },
|
||||||
|
{ key: 'sing', mult: 3 },
|
||||||
|
{ key: 'humanLore', mult: 1 },
|
||||||
|
{ key: 'conceal', mult: 1 },
|
||||||
|
{ key: 'sleight', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 1 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 2 },
|
||||||
|
{ key: 'speakOtherLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Costume, instrument or juggling props, knife',
|
||||||
|
},
|
||||||
|
farmer: {
|
||||||
|
label: 'Farmer',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 3 },
|
||||||
|
{ key: 'plantLore', mult: 3 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'track', mult: 1 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Farm tools, knife, primary weapon',
|
||||||
|
},
|
||||||
|
fisher: {
|
||||||
|
label: 'Fisher',
|
||||||
|
skills: [
|
||||||
|
{ key: 'boat', mult: 4 },
|
||||||
|
{ key: 'swim', mult: 3 },
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Fishing net or harpoon, knife, small boat',
|
||||||
|
},
|
||||||
|
herder: {
|
||||||
|
label: 'Herder',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 4 },
|
||||||
|
{ key: 'plantLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'track', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Primary weapon, knife, herd of 2d6+6 animals',
|
||||||
|
},
|
||||||
|
hunter: {
|
||||||
|
label: 'Hunter',
|
||||||
|
skills: [
|
||||||
|
{ key: 'throw', mult: 2 },
|
||||||
|
{ key: 'animalLore', mult: 2 },
|
||||||
|
{ key: 'plantLore', mult: 2 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 3 },
|
||||||
|
{ key: 'track', mult: 3 },
|
||||||
|
{ key: 'hide', mult: 2 },
|
||||||
|
{ key: 'sneak', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:missile', mult: 3 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 1 },
|
||||||
|
equipment: 'Missile weapon, primary weapon, knife, light armor',
|
||||||
|
},
|
||||||
|
noble: {
|
||||||
|
label: 'Noble',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 2 },
|
||||||
|
{ key: 'orate', mult: 3 },
|
||||||
|
{ key: 'humanLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 1 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 3 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:missile', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 3 },
|
||||||
|
{ key: 'parry:weapon', mult: 2, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 2, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 2, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 2 },
|
||||||
|
equipment: 'Primary weapon, shield or parry weapon, light to medium armor, riding animal',
|
||||||
|
},
|
||||||
|
warrior: {
|
||||||
|
label: 'Warrior',
|
||||||
|
skills: [
|
||||||
|
{ key: 'throw', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 2 },
|
||||||
|
{ key: 'attack:dagger', mult: 2 },
|
||||||
|
{ key: 'attack:missile', mult: 2 },
|
||||||
|
{ key: 'attack:spear1h', mult: 3, group: 'weaponStyle' },
|
||||||
|
{ key: 'parry:shield', mult: 3, group: 'weaponStyle' },
|
||||||
|
{ key: 'attack:2hweapon', mult: 3, group: 'weaponStyle' },
|
||||||
|
{ key: 'parry:weapon', mult: 3, group: 'weaponStyle' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 2 },
|
||||||
|
equipment: 'Primary weapon, shield or two-handed weapon, medium armor',
|
||||||
|
},
|
||||||
|
shaman: {
|
||||||
|
label: 'Assistant Shaman',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 2 },
|
||||||
|
{ key: 'animalLore', mult: 3 },
|
||||||
|
{ key: 'plantLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'ritual:ceremony', mult: 3 },
|
||||||
|
{ key: 'ritual:summon', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'spirit', basePoints: 3, extraPerYears: 1 },
|
||||||
|
equipment: "Shaman's fetish bundle, knife",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Civilized: {
|
||||||
|
crafter: {
|
||||||
|
label: 'Crafter',
|
||||||
|
skills: [
|
||||||
|
{ key: 'craft:leather', mult: 4, group: 'craftPick' },
|
||||||
|
{ key: 'craft:wood', mult: 4, group: 'craftPick' },
|
||||||
|
{ key: 'craft:stone', mult: 4, group: 'craftPick' },
|
||||||
|
{ key: 'devise', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 3 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine_or_sorcery', basePoints: 1 },
|
||||||
|
equipment: 'Craft tools, workshop access, knife',
|
||||||
|
},
|
||||||
|
entertainer: {
|
||||||
|
label: 'Entertainer',
|
||||||
|
skills: [
|
||||||
|
{ key: 'climb', mult: 1 },
|
||||||
|
{ key: 'jump', mult: 1 },
|
||||||
|
{ key: 'fastTalk', mult: 3 },
|
||||||
|
{ key: 'orate', mult: 3 },
|
||||||
|
{ key: 'sing', mult: 3 },
|
||||||
|
{ key: 'humanLore', mult: 1 },
|
||||||
|
{ key: 'conceal', mult: 1 },
|
||||||
|
{ key: 'sleight', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 1 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 2 },
|
||||||
|
{ key: 'speakOtherLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine_or_sorcery', basePoints: 1 },
|
||||||
|
equipment: 'Costume, instrument or props, knife',
|
||||||
|
},
|
||||||
|
farmer: {
|
||||||
|
label: 'Farmer',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 3 },
|
||||||
|
{ key: 'plantLore', mult: 4 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 1 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine', basePoints: 1 },
|
||||||
|
equipment: 'Farm tools, primary weapon, knife',
|
||||||
|
},
|
||||||
|
healer: {
|
||||||
|
label: 'Healer',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 5 },
|
||||||
|
{ key: 'animalLore', mult: 1 },
|
||||||
|
{ key: 'humanLore', mult: 3 },
|
||||||
|
{ key: 'plantLore', mult: 3 },
|
||||||
|
{ key: 'listen', mult: 1 },
|
||||||
|
{ key: 'search', mult: 2 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine', basePoints: 3 },
|
||||||
|
equipment: "Healer's kit, knife, 2d6×10L",
|
||||||
|
},
|
||||||
|
herder: {
|
||||||
|
label: 'Herder',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'animalLore', mult: 5 },
|
||||||
|
{ key: 'plantLore', mult: 1 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'track', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine', basePoints: 1 },
|
||||||
|
equipment: 'Primary weapon, knife, herd',
|
||||||
|
},
|
||||||
|
merchant: {
|
||||||
|
label: 'Merchant',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 1 },
|
||||||
|
{ key: 'fastTalk', mult: 2 },
|
||||||
|
{ key: 'orate', mult: 1 },
|
||||||
|
{ key: 'humanLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 5 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 2 },
|
||||||
|
{ key: 'speakOtherLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine_or_sorcery', basePoints: 1 },
|
||||||
|
equipment: 'Pack animal or cart, trade goods worth 500L, knife',
|
||||||
|
},
|
||||||
|
noble: {
|
||||||
|
label: 'Noble',
|
||||||
|
skills: [
|
||||||
|
{ key: 'ride', mult: 2 },
|
||||||
|
{ key: 'fastTalk', mult: 1 },
|
||||||
|
{ key: 'orate', mult: 3 },
|
||||||
|
{ key: 'humanLore', mult: 2 },
|
||||||
|
{ key: 'worldLore', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 2 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 3 },
|
||||||
|
{ key: 'speakOtherLanguage', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'attack:primary', mult: 3 },
|
||||||
|
{ key: 'parry:weapon', mult: 2, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 2, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 2, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine', basePoints: 2 },
|
||||||
|
equipment: 'Primary weapon, shield, medium to heavy armor, riding animal',
|
||||||
|
},
|
||||||
|
sailor: {
|
||||||
|
label: 'Sailor',
|
||||||
|
skills: [
|
||||||
|
{ key: 'boat', mult: 5 },
|
||||||
|
{ key: 'climb', mult: 2 },
|
||||||
|
{ key: 'swim', mult: 3 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'speakOtherLanguage', mult: 1 },
|
||||||
|
{ key: 'attack:fist', mult: 2 },
|
||||||
|
{ key: 'attack:dagger', mult: 2 },
|
||||||
|
{ key: 'attack:primary', mult: 2 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine', basePoints: 1 },
|
||||||
|
equipment: "Primary weapon, knife, sailor's gear",
|
||||||
|
},
|
||||||
|
scribe: {
|
||||||
|
label: 'Scribe',
|
||||||
|
skills: [
|
||||||
|
{ key: 'devise', mult: 1 },
|
||||||
|
{ key: 'humanLore', mult: 3 },
|
||||||
|
{ key: 'mineralLore', mult: 1 },
|
||||||
|
{ key: 'worldLore', mult: 3 },
|
||||||
|
{ key: 'search', mult: 2 },
|
||||||
|
{ key: 'evaluate', mult: 2 },
|
||||||
|
{ key: 'speakOwnLanguage', mult: 3 },
|
||||||
|
{ key: 'speakOtherLanguage', mult: 3 },
|
||||||
|
{ key: 'attack:fist', mult: 1 },
|
||||||
|
{ key: 'attack:dagger', mult: 1 },
|
||||||
|
{ key: 'parry:weapon', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'parry:shield', mult: 1, group: 'parryChoice' },
|
||||||
|
{ key: 'dodge', mult: 1, group: 'parryChoice' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine_or_sorcery', basePoints: 2 },
|
||||||
|
equipment: 'Writing materials, knife, access to library',
|
||||||
|
},
|
||||||
|
soldier: {
|
||||||
|
label: 'Soldier',
|
||||||
|
skills: [
|
||||||
|
{ key: 'firstAid', mult: 1 },
|
||||||
|
{ key: 'worldLore', mult: 1 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'attack:fist', mult: 2 },
|
||||||
|
{ key: 'attack:dagger', mult: 2 },
|
||||||
|
{ key: 'attack:missile', mult: 2 },
|
||||||
|
{ key: 'attack:spear1h', mult: 3, group: 'weaponStyle' },
|
||||||
|
{ key: 'parry:shield', mult: 3, group: 'weaponStyle' },
|
||||||
|
{ key: 'attack:2hweapon', mult: 3, group: 'weaponStyle' },
|
||||||
|
{ key: 'parry:weapon', mult: 3, group: 'weaponStyle' },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine', basePoints: 1 },
|
||||||
|
equipment: 'Primary weapon, shield or two-handed weapon, medium armor',
|
||||||
|
},
|
||||||
|
thief: {
|
||||||
|
label: 'Thief',
|
||||||
|
skills: [
|
||||||
|
{ key: 'climb', mult: 3 },
|
||||||
|
{ key: 'jump', mult: 2 },
|
||||||
|
{ key: 'dodge', mult: 2 },
|
||||||
|
{ key: 'fastTalk', mult: 2 },
|
||||||
|
{ key: 'conceal', mult: 3 },
|
||||||
|
{ key: 'sleight', mult: 3 },
|
||||||
|
{ key: 'devise', mult: 3 },
|
||||||
|
{ key: 'listen', mult: 2 },
|
||||||
|
{ key: 'scan', mult: 2 },
|
||||||
|
{ key: 'search', mult: 2 },
|
||||||
|
{ key: 'hide', mult: 3 },
|
||||||
|
{ key: 'sneak', mult: 3 },
|
||||||
|
{ key: 'attack:fist', mult: 2 },
|
||||||
|
{ key: 'attack:dagger', mult: 3 },
|
||||||
|
{ key: 'attack:missile', mult: 1 },
|
||||||
|
],
|
||||||
|
magic: { type: 'divine_or_sorcery', basePoints: 1 },
|
||||||
|
equipment: 'Dagger, climbing gear, lockpicks, dark clothing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- computeOccupationSkills ----------
|
||||||
|
// Returns:
|
||||||
|
// skills: {} general skill key → total % (base + category mod + occ bonus)
|
||||||
|
// craftBonuses: {} craft key → occupation bonus (years * mult)
|
||||||
|
// ritualBonuses: {} ritual key → occupation bonus
|
||||||
|
// weaponBonuses: {} weapon key → occupation bonus
|
||||||
|
// choiceGroups: {} groupName → [{ key, mult, bonus, total?, note }]
|
||||||
|
// occupation: {} the occupation object
|
||||||
|
|
||||||
|
function computeOccupationSkills(chars, culture, occupationKey, years) {
|
||||||
|
const occ = (OCCUPATIONS[culture] || {})[occupationKey];
|
||||||
|
if (!occ) throw new Error(`Unknown occupation "${occupationKey}" for culture "${culture}"`);
|
||||||
|
|
||||||
|
const mods = computeSkillCategoryModifiers(chars);
|
||||||
|
const baseSkills = computeBaseSkills(chars);
|
||||||
|
|
||||||
|
const skills = {};
|
||||||
|
const craftBonuses = {};
|
||||||
|
const ritualBonuses = {};
|
||||||
|
const weaponBonuses = {};
|
||||||
|
const choiceGroups = {};
|
||||||
|
|
||||||
|
// Extended base values for skills not in BASE_SKILLS
|
||||||
|
function extendedBase(key) {
|
||||||
|
if (key in baseSkills) return baseSkills[key];
|
||||||
|
if (key === 'evaluate') return Math.max(0, 5 + (mods.knowledge || 0));
|
||||||
|
if (key === 'speakOwnLanguage') return 30;
|
||||||
|
if (key === 'speakOtherLanguage') return 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of occ.skills) {
|
||||||
|
const { key, mult, group } = entry;
|
||||||
|
const bonus = years * mult;
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
if (!choiceGroups[group]) choiceGroups[group] = [];
|
||||||
|
if (key.startsWith('attack:') || key.startsWith('parry:')) {
|
||||||
|
choiceGroups[group].push({ key, mult, bonus, note: 'weapon' });
|
||||||
|
} else if (key.startsWith('craft:')) {
|
||||||
|
choiceGroups[group].push({ key, mult, bonus, note: 'craft' });
|
||||||
|
} else {
|
||||||
|
const base = extendedBase(key);
|
||||||
|
choiceGroups[group].push({ key, mult, bonus, total: Math.max(0, base + bonus), note: 'skill' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-grouped entries
|
||||||
|
if (key.startsWith('attack:') || key.startsWith('parry:')) {
|
||||||
|
weaponBonuses[key] = (weaponBonuses[key] || 0) + bonus;
|
||||||
|
} else if (key.startsWith('craft:')) {
|
||||||
|
craftBonuses[key] = (craftBonuses[key] || 0) + bonus;
|
||||||
|
} else if (key.startsWith('ritual:')) {
|
||||||
|
ritualBonuses[key] = (ritualBonuses[key] || 0) + bonus;
|
||||||
|
} else {
|
||||||
|
const base = extendedBase(key);
|
||||||
|
skills[key] = Math.max(0, (skills[key] !== undefined ? skills[key] : base) + bonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skills, craftBonuses, ritualBonuses, weaponBonuses, choiceGroups, occupation: occ };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rollDie,
|
rollDie,
|
||||||
rollDice,
|
rollDice,
|
||||||
@@ -1091,6 +1830,12 @@ module.exports = {
|
|||||||
computeBaseSkills,
|
computeBaseSkills,
|
||||||
SCENE_CHOICES,
|
SCENE_CHOICES,
|
||||||
resolveSceneChoice,
|
resolveSceneChoice,
|
||||||
|
PERSONALITY_TRAITS,
|
||||||
|
TRAIT_ACTION_BIAS,
|
||||||
|
rollPersonalityAction,
|
||||||
|
rollAge,
|
||||||
|
OCCUPATIONS,
|
||||||
|
computeOccupationSkills,
|
||||||
dexStrikeRank,
|
dexStrikeRank,
|
||||||
sizStrikeRankModifier,
|
sizStrikeRankModifier,
|
||||||
baseStrikeRank,
|
baseStrikeRank,
|
||||||
|
|||||||
@@ -107,6 +107,54 @@ function resolveLayer1Links() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CULTURE_TABLE_LINKS = [
|
||||||
|
[/primitive/i, 'Primitive Occupation Table (d100)'],
|
||||||
|
[/nomad/i, 'Nomad Occupation Table (d100)'],
|
||||||
|
[/barbarian/i, 'Barbarian Occupation Table (d100)'],
|
||||||
|
[/civilized/i, 'Civilized Occupation Table (d100)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const OCCUPATION_CRAFTER_LINKS = [
|
||||||
|
['Barbarian Occupation Table (d100)', 'Barbarian Craft Table (d100)'],
|
||||||
|
['Civilized Occupation Table (d100)', 'Urban Crafts Table (d100)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveCharacterTableLinks() {
|
||||||
|
const sourceFile = 'rq3_character_tables.md';
|
||||||
|
|
||||||
|
// Culture Table → Occupation Tables
|
||||||
|
const cultureTable = dbApi.tables.findTableByHeading(sourceFile, 'Culture Table (d8)');
|
||||||
|
if (cultureTable) {
|
||||||
|
const rows = dbApi.tables.getRowsForTable(cultureTable.id);
|
||||||
|
for (const row of rows) {
|
||||||
|
const text = row.cells[0] || '';
|
||||||
|
const match = CULTURE_TABLE_LINKS.find(([pattern]) => pattern.test(text));
|
||||||
|
if (!match) continue;
|
||||||
|
const [, targetHeading] = match;
|
||||||
|
const target = dbApi.tables.findTableByHeading(sourceFile, targetHeading);
|
||||||
|
if (!target) { console.warn(`Culture link target not found: "${targetHeading}"`); continue; }
|
||||||
|
dbApi.tables.insertLink({ table_id: cultureTable.id, row_id: row.id, target_table_id: target.id });
|
||||||
|
stats.links++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Could not find Culture Table for link resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupation Tables → Craft Sub-tables (Crafter rows)
|
||||||
|
for (const [occHeading, craftHeading] of OCCUPATION_CRAFTER_LINKS) {
|
||||||
|
const occTable = dbApi.tables.findTableByHeading(sourceFile, occHeading);
|
||||||
|
const craftTable = dbApi.tables.findTableByHeading(sourceFile, craftHeading);
|
||||||
|
if (!occTable || !craftTable) continue;
|
||||||
|
const rows = dbApi.tables.getRowsForTable(occTable.id);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (/^crafter$/i.test((row.cells[0] || '').trim())) {
|
||||||
|
dbApi.tables.insertLink({ table_id: occTable.id, row_id: row.id, target_table_id: craftTable.id });
|
||||||
|
stats.links++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
dbApi.tables.clearImported();
|
dbApi.tables.clearImported();
|
||||||
|
|
||||||
@@ -116,6 +164,7 @@ function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolveLayer1Links();
|
resolveLayer1Links();
|
||||||
|
resolveCharacterTableLinks();
|
||||||
|
|
||||||
console.log('---');
|
console.log('---');
|
||||||
console.log(`Files: ${stats.files}`);
|
console.log(`Files: ${stats.files}`);
|
||||||
|
|||||||
@@ -250,8 +250,67 @@ app.get('/api/export/log', (req, res) => {
|
|||||||
|
|
||||||
// ---------- Player Characters ----------
|
// ---------- Player Characters ----------
|
||||||
|
|
||||||
|
function enrichCharacter(pc) {
|
||||||
|
if (!pc || !pc.stat_block) return pc;
|
||||||
|
const sb = pc.stat_block;
|
||||||
|
const chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
|
||||||
|
pc.derived = rq3.deriveCharacterStats(chars);
|
||||||
|
if (pc.culture && pc.occupation) {
|
||||||
|
const years = Math.max(0, (pc.age || 21) - 15);
|
||||||
|
const occ = rq3.computeOccupationSkills(chars, pc.culture, pc.occupation, years);
|
||||||
|
if (occ) {
|
||||||
|
pc.skills = occ.skills;
|
||||||
|
pc.craftBonuses = occ.craftBonuses;
|
||||||
|
pc.ritualBonuses = occ.ritualBonuses;
|
||||||
|
pc.occupation_label = occ.occupation && occ.occupation.label;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pc.skills = rq3.computeBaseSkills(chars);
|
||||||
|
}
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/characters', (req, res) => {
|
app.get('/api/characters', (req, res) => {
|
||||||
res.json(dbApi.playerCharacters.list());
|
res.json(dbApi.playerCharacters.list().map(enrichCharacter));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/characters/:id', (req, res) => {
|
||||||
|
const pc = dbApi.playerCharacters.get(Number(req.params.id));
|
||||||
|
if (!pc) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
res.json(enrichCharacter(pc));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/characters/:id/location', (req, res) => {
|
||||||
|
const { current_location, destination } = req.body || {};
|
||||||
|
const pc = dbApi.playerCharacters.updateLocation(Number(req.params.id), { current_location, destination });
|
||||||
|
if (!pc) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
res.json({ current_location: pc.current_location, destination: pc.destination });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/roll-age', (req, res) => {
|
||||||
|
res.json({ age: rq3.rollAge() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/compute-occupation', (req, res) => {
|
||||||
|
const { characterId, culture, occupation, years } = req.body || {};
|
||||||
|
if (!culture || !occupation) return res.status(400).json({ error: 'culture and occupation are required' });
|
||||||
|
let chars;
|
||||||
|
if (characterId) {
|
||||||
|
const pc = dbApi.playerCharacters.get(Number(characterId));
|
||||||
|
if (!pc) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
const sb = pc.stat_block;
|
||||||
|
chars = { str: sb.str, con: sb.con, siz: sb.siz, int: sb.int, pow: sb.pow, dex: sb.dex, app: sb.app };
|
||||||
|
} else if (req.body.chars) {
|
||||||
|
chars = req.body.chars;
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'characterId or chars is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = rq3.computeOccupationSkills(chars, culture, occupation, Number(years) || 0);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/characters/roll', (req, res) => {
|
app.post('/api/characters/roll', (req, res) => {
|
||||||
@@ -279,7 +338,7 @@ app.post('/api/characters/validate', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/characters', (req, res) => {
|
app.post('/api/characters', (req, res) => {
|
||||||
const { name, generation_method, chars } = req.body || {};
|
const { name, generation_method, chars, age, culture, occupation, weapons } = req.body || {};
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
if (!chars) return res.status(400).json({ error: 'chars is required' });
|
if (!chars) return res.status(400).json({ error: 'chars is required' });
|
||||||
|
|
||||||
@@ -289,10 +348,14 @@ app.post('/api/characters', (req, res) => {
|
|||||||
pow: chars.pow, dex: chars.dex, app: chars.app,
|
pow: chars.pow, dex: chars.dex, app: chars.app,
|
||||||
max_hp: derived.totalHp, current_hp: derived.totalHp,
|
max_hp: derived.totalHp, current_hp: derived.totalHp,
|
||||||
magic_points_max: derived.magicPoints, magic_points_current: derived.magicPoints,
|
magic_points_max: derived.magicPoints, magic_points_current: derived.magicPoints,
|
||||||
|
culture: culture ?? null,
|
||||||
};
|
};
|
||||||
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock });
|
const pc = dbApi.playerCharacters.create({ name, generation_method, stat_block: statBlock, age, culture, occupation });
|
||||||
dbApi.statBlocks.setHitLocations(pc.stat_block_id, derived.hitLocations);
|
dbApi.statBlocks.setHitLocations(pc.stat_block_id, derived.hitLocations);
|
||||||
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived } });
|
if (weapons && Array.isArray(weapons) && weapons.length) {
|
||||||
|
dbApi.statBlocks.setWeapons(pc.stat_block_id, weapons);
|
||||||
|
}
|
||||||
|
dbApi.log.append({ type: 'character', summary: `Created PC: ${name} (${generation_method})`, details: { chars, derived, age, culture, occupation } });
|
||||||
res.status(201).json(dbApi.playerCharacters.get(pc.id));
|
res.status(201).json(dbApi.playerCharacters.get(pc.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,6 +365,38 @@ app.delete('/api/characters/:id', (req, res) => {
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/characters/:id/traits', (req, res) => {
|
||||||
|
const traits = req.body && Array.isArray(req.body.traits) ? req.body.traits : [];
|
||||||
|
const pc = dbApi.playerCharacters.updateTraits(Number(req.params.id), traits);
|
||||||
|
if (!pc) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
res.json({ traits: pc.traits || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/npcs/:id/traits', (req, res) => {
|
||||||
|
const traits = req.body && Array.isArray(req.body.traits) ? req.body.traits : [];
|
||||||
|
const npc = dbApi.npcs.updateTraits(Number(req.params.id), traits);
|
||||||
|
if (!npc) return res.status(404).json({ error: 'NPC not found' });
|
||||||
|
res.json({ traits: npc.traits || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/rules/personality-traits', (req, res) => {
|
||||||
|
res.json({ traits: rq3.PERSONALITY_TRAITS, biases: rq3.TRAIT_ACTION_BIAS });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/adventure/personality-roll', (req, res) => {
|
||||||
|
const state = dbApi.adventure.get();
|
||||||
|
if (!state) return res.status(400).json({ error: 'No active adventure scene' });
|
||||||
|
const pc = dbApi.playerCharacters.get(state.characterId);
|
||||||
|
if (!pc) return res.status(400).json({ error: 'No character for active adventure' });
|
||||||
|
const result = rq3.rollPersonalityAction(pc.traits || []);
|
||||||
|
dbApi.log.append({
|
||||||
|
type: 'adventure',
|
||||||
|
summary: `Personality roll for ${pc.name}: suggested ${result.suggested || 'none'} (${result.firedTraits.map(t => t.name).join(', ') || 'no traits fired'})`,
|
||||||
|
details: result,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Inventory ----------
|
// ---------- Inventory ----------
|
||||||
|
|
||||||
app.get('/api/characters/:id/inventory', (req, res) => {
|
app.get('/api/characters/:id/inventory', (req, res) => {
|
||||||
@@ -747,6 +842,11 @@ app.post('/api/import', (req, res) => {
|
|||||||
res.json(dump);
|
res.json(dump);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/clear-all', (req, res) => {
|
||||||
|
dbApi.clearAll();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Errors ----------
|
// ---------- Errors ----------
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
|
|||||||
@@ -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 |
|
||||||
Reference in New Issue
Block a user