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
178 lines
6.3 KiB
JavaScript
178 lines
6.3 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const dbApi = require('../db.js');
|
|
const { parseMarkdownFile } = require('../lib/markdownTableParser.js');
|
|
|
|
const TABLES_DIR = path.join(__dirname, '..', 'tables');
|
|
|
|
const NPC_CORE_COLUMNS = [
|
|
'first name', 'last name', 'brief description', 'wants and needs', 'secret or obstacle', 'also carrying',
|
|
];
|
|
|
|
const NPC_ATTRIBUTE_PATTERNS = [
|
|
[/\bpronoun/i, 'npc_pronouns'],
|
|
[/\bage\b/i, 'npc_age'],
|
|
[/\bintelligence\b/i, 'npc_intelligence'],
|
|
[/^hair$/i, 'npc_hair'],
|
|
[/^build$/i, 'npc_build'],
|
|
[/^race$/i, 'npc_race'],
|
|
];
|
|
|
|
const LAYER1_LINKS = [
|
|
[/weather turns/i, 'Layer 2C: Weather Turns (d20)'],
|
|
[/encounter a person/i, 'Layer 2A: Person Encountered (d20)'],
|
|
[/encounter a group/i, 'Layer 2B: Group Encountered (d20)'],
|
|
[/find something/i, 'Layer 2D: Find Something (d100)'],
|
|
[/natural hazard/i, 'Layer 2E: Natural Hazard (d20)'],
|
|
[/signs of recent violence/i, 'Layer 2F: Signs of Recent Violence (d20)'],
|
|
[/pursuit or being followed/i, 'Layer 2G: Pursuit \/ Being Followed (d20)'],
|
|
[/physical hardship/i, 'Layer 2H: Physical Hardship (d20)'],
|
|
[/something uncanny/i, 'Layer 2I: Something Uncanny (d20)'],
|
|
];
|
|
|
|
function isNpcCoreTable(columns) {
|
|
const norm = columns.map((c) => c.toLowerCase().trim());
|
|
return NPC_CORE_COLUMNS.every((req) => norm.includes(req));
|
|
}
|
|
|
|
function detectNpcRole(headingText, columns, insideNpcSection) {
|
|
if (isNpcCoreTable(columns)) return 'npc_core';
|
|
if (!insideNpcSection) return null;
|
|
const trimmed = headingText.trim();
|
|
for (const [pattern, role] of NPC_ATTRIBUTE_PATTERNS) {
|
|
if (pattern.test(trimmed)) return role;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const stats = { files: 0, nodes: 0, tables: 0, rows: 0, links: 0 };
|
|
|
|
function insertNode(node, sourceFile, parentId, sortOrder, insideNpcSection) {
|
|
const treeNodeId = dbApi.tables.insertTreeNode({
|
|
source_file: sourceFile,
|
|
parent_id: parentId,
|
|
heading_text: node.headingText,
|
|
heading_level: node.level,
|
|
sort_order: sortOrder,
|
|
});
|
|
stats.nodes++;
|
|
|
|
const childInsideNpc = insideNpcSection || /random npc generator/i.test(node.headingText || '');
|
|
|
|
if (node.table) {
|
|
const npcRole = detectNpcRole(node.headingText, node.table.columns, childInsideNpc);
|
|
const tableId = dbApi.tables.insertTable({
|
|
tree_node_id: treeNodeId,
|
|
dice_notation: node.table.diceNotation,
|
|
table_type: node.table.tableType,
|
|
npc_role: npcRole,
|
|
});
|
|
stats.tables++;
|
|
dbApi.tables.insertColumns(tableId, node.table.columns);
|
|
const rowIds = dbApi.tables.insertRows(tableId, node.table.rows);
|
|
stats.rows += rowIds.length;
|
|
}
|
|
|
|
node.children.forEach((child, idx) => insertNode(child, sourceFile, treeNodeId, idx, childInsideNpc));
|
|
}
|
|
|
|
function importFile(filePath) {
|
|
const sourceFile = path.basename(filePath);
|
|
const roots = parseMarkdownFile(filePath);
|
|
roots.forEach((node, idx) => insertNode(node, sourceFile, null, idx, false));
|
|
stats.files++;
|
|
console.log(`Imported ${sourceFile}`);
|
|
}
|
|
|
|
function resolveLayer1Links() {
|
|
const sourceFile = 'norse_encounter_tables.md';
|
|
const layer1Table = dbApi.tables.findTableByHeading(sourceFile, 'Layer 1: What Happens? (d20)');
|
|
if (!layer1Table) {
|
|
console.warn('Could not find Layer 1 table for link resolution');
|
|
return;
|
|
}
|
|
const rows = dbApi.tables.getRowsForTable(layer1Table.id);
|
|
for (const row of rows) {
|
|
const text = row.cells[0] || '';
|
|
const match = LAYER1_LINKS.find(([pattern]) => pattern.test(text));
|
|
if (!match) continue;
|
|
const [, targetHeading] = match;
|
|
const targetTable = dbApi.tables.findTableByHeading(sourceFile, targetHeading);
|
|
if (!targetTable) {
|
|
console.warn(`Layer 1 link target not found: "${targetHeading}" (row: "${text}")`);
|
|
continue;
|
|
}
|
|
dbApi.tables.insertLink({ table_id: layer1Table.id, row_id: row.id, target_table_id: targetTable.id });
|
|
stats.links++;
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
const files = fs.readdirSync(TABLES_DIR).filter((f) => f.endsWith('.md'));
|
|
for (const file of files) {
|
|
importFile(path.join(TABLES_DIR, file));
|
|
}
|
|
|
|
resolveLayer1Links();
|
|
resolveCharacterTableLinks();
|
|
|
|
console.log('---');
|
|
console.log(`Files: ${stats.files}`);
|
|
console.log(`Tree nodes: ${stats.nodes}`);
|
|
console.log(`Tables: ${stats.tables}`);
|
|
console.log(`Rows: ${stats.rows}`);
|
|
console.log(`Layer links: ${stats.links}`);
|
|
}
|
|
|
|
main();
|