- Implemented `validatePlayer` and `normalizeTabInfo` functions in `database/validate.js` for player data validation and normalization. - Added error handling for required fields and enforced data types and constraints. - Introduced standardization for characteristics and skills. feat: Create script for extracting rules from PDF files - Developed `extract-rules.js` to extract text from PDF rulebooks and categorize rules. - Integrated `pdftotext` for PDF processing and created a searchable rules database. - Implemented rule categorization and indexing for efficient searching. feat: Implement RulesTab component for rule searching - Created `RulesTab.jsx` for searching and displaying rules with filtering options. - Added recent searches and quick reference buttons for user convenience. - Integrated API calls for fetching rules and displaying results dynamically. feat: Add test script for Rules API - Created `test-rules.js` to test the functionality of the Rules API endpoints. - Included tests for search and stats endpoints to ensure proper response handling.
383 lines
13 KiB
JavaScript
Executable File
383 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { exec } = require('child_process');
|
|
|
|
// This script will extract text from PDF files and create a searchable rules database
|
|
// It requires pdftotext (from poppler-utils) to be installed
|
|
|
|
const dataDir = path.join(__dirname, '../data');
|
|
const outputDir = path.join(__dirname, '../database/rules');
|
|
|
|
// Ensure output directory exists
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
const rulebooks = [
|
|
{
|
|
filename: 'Deathwatch_Core_Rulebook_copy.pdf',
|
|
name: 'Core Rulebook',
|
|
abbreviation: 'CR',
|
|
categories: ['combat', 'weapons', 'armor', 'skills', 'talents', 'psychic', 'equipment']
|
|
},
|
|
{
|
|
filename: 'Deathwatch - Game Master\'s Kit.pdf',
|
|
name: 'Game Master\'s Kit',
|
|
abbreviation: 'GMK',
|
|
categories: ['gm', 'enemies', 'vehicles', 'scenarios']
|
|
}
|
|
];
|
|
|
|
// Rule patterns to identify different types of content
|
|
const rulePatterns = [
|
|
// Combat rules
|
|
{ regex: /(?:attack|damage|wound|critical|hit|miss|dodge|parry)/i, category: 'combat' },
|
|
// Weapons
|
|
{ regex: /(?:bolt|plasma|melta|flame|las|chain|power)\s*(?:pistol|rifle|cannon|sword|axe|gun)/i, category: 'weapons' },
|
|
// Armor
|
|
{ regex: /(?:power\s*armor|carapace|flak|void\s*suit)/i, category: 'armor' },
|
|
// Skills
|
|
{ regex: /(?:awareness|ballistic|weapon\s*skill|acrobatics|athletics|command|medicae)/i, category: 'skills' },
|
|
// Talents
|
|
{ regex: /talent:?\s*([^.]+)/i, category: 'talents' },
|
|
// Psychic
|
|
{ regex: /(?:psychic|warp|perils|manifestation)/i, category: 'psychic' },
|
|
// Equipment
|
|
{ regex: /(?:equipment|gear|requisition|supply)/i, category: 'equipment' },
|
|
// GM Rules
|
|
{ regex: /(?:game\s*master|narrator|npc|scenario|adventure)/i, category: 'gm' }
|
|
];
|
|
|
|
// Common rule keywords for better extraction
|
|
const ruleKeywords = [
|
|
'Attack Roll', 'Damage Roll', 'Critical Hit', 'Righteous Fury',
|
|
'Dodge', 'Parry', 'Full Auto', 'Semi Auto', 'Single Shot',
|
|
'Power Armor', 'Requisition', 'Renown', 'Squad Mode', 'Cohesion',
|
|
'Hordes', 'Fear', 'Pinning', 'Suppression', 'Cover',
|
|
'Jamming', 'Overheating', 'Reload', 'Rate of Fire',
|
|
'Penetration', 'Tearing', 'Felling', 'Proven', 'Unreliable',
|
|
'Ballistic Skill', 'Weapon Skill', 'Strength', 'Toughness',
|
|
'Agility', 'Intelligence', 'Perception', 'Willpower', 'Fellowship'
|
|
];
|
|
|
|
async function extractPDFText(pdfPath, outputPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const command = `pdftotext -layout "${pdfPath}" "${outputPath}"`;
|
|
|
|
exec(command, (error, stdout, stderr) => {
|
|
if (error) {
|
|
console.error(`Error extracting ${pdfPath}:`, error);
|
|
reject(error);
|
|
} else {
|
|
console.log(`Successfully extracted text from ${pdfPath}`);
|
|
resolve(outputPath);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseRulesFromText(text, source) {
|
|
const rules = [];
|
|
const lines = text.split('\n');
|
|
let currentRule = null;
|
|
let pageNumber = 1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
|
|
// Skip empty lines
|
|
if (!line) continue;
|
|
|
|
// Track page numbers (look for page number patterns)
|
|
const pageMatch = line.match(/^\d+$/);
|
|
if (pageMatch && parseInt(pageMatch[0]) > pageNumber) {
|
|
pageNumber = parseInt(pageMatch[0]);
|
|
continue;
|
|
}
|
|
|
|
// Look for rule headers (usually bold or all caps)
|
|
const isRuleHeader = line.length > 3 && line.length < 50 &&
|
|
(line === line.toUpperCase() || ruleKeywords.some(keyword =>
|
|
line.toLowerCase().includes(keyword.toLowerCase())
|
|
));
|
|
|
|
if (isRuleHeader) {
|
|
// Save previous rule if it exists
|
|
if (currentRule && currentRule.content.trim()) {
|
|
rules.push(currentRule);
|
|
}
|
|
|
|
// Start new rule
|
|
currentRule = {
|
|
title: line,
|
|
content: '',
|
|
page: pageNumber,
|
|
source: source.name,
|
|
sourceAbbr: source.abbreviation,
|
|
category: categorizeRule(line)
|
|
};
|
|
} else if (currentRule) {
|
|
// Add content to current rule
|
|
currentRule.content += line + '\n';
|
|
|
|
// If content gets too long, split it
|
|
if (currentRule.content.length > 1500) {
|
|
rules.push(currentRule);
|
|
currentRule = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the last rule
|
|
if (currentRule && currentRule.content.trim()) {
|
|
rules.push(currentRule);
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
function categorizeRule(title) {
|
|
const titleLower = title.toLowerCase();
|
|
|
|
for (const pattern of rulePatterns) {
|
|
if (pattern.regex.test(title)) {
|
|
return pattern.category;
|
|
}
|
|
}
|
|
|
|
// Default categorization based on common terms
|
|
if (titleLower.includes('attack') || titleLower.includes('combat') || titleLower.includes('damage')) {
|
|
return 'combat';
|
|
} else if (titleLower.includes('weapon') || titleLower.includes('gun') || titleLower.includes('sword')) {
|
|
return 'weapons';
|
|
} else if (titleLower.includes('armor') || titleLower.includes('protection')) {
|
|
return 'armor';
|
|
} else if (titleLower.includes('skill')) {
|
|
return 'skills';
|
|
} else if (titleLower.includes('talent')) {
|
|
return 'talents';
|
|
} else if (titleLower.includes('psychic') || titleLower.includes('power')) {
|
|
return 'psychic';
|
|
} else if (titleLower.includes('equipment') || titleLower.includes('gear')) {
|
|
return 'equipment';
|
|
} else {
|
|
return 'general';
|
|
}
|
|
}
|
|
|
|
function createSearchIndex(rules) {
|
|
const index = {};
|
|
|
|
rules.forEach((rule, ruleIndex) => {
|
|
// Index by title words
|
|
const titleWords = rule.title.toLowerCase().split(/\s+/);
|
|
titleWords.forEach(word => {
|
|
if (word.length > 2) {
|
|
if (!index[word]) index[word] = [];
|
|
index[word].push(ruleIndex);
|
|
}
|
|
});
|
|
|
|
// Index by content words (first 100 words to avoid huge index)
|
|
const contentWords = rule.content.toLowerCase().split(/\s+/).slice(0, 100);
|
|
contentWords.forEach(word => {
|
|
const cleanWord = word.replace(/[^\w]/g, '');
|
|
if (cleanWord.length > 3) {
|
|
if (!index[cleanWord]) index[cleanWord] = [];
|
|
if (!index[cleanWord].includes(ruleIndex)) {
|
|
index[cleanWord].push(ruleIndex);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
return index;
|
|
}
|
|
|
|
async function processRulebooks() {
|
|
console.log('Starting PDF processing...');
|
|
|
|
let allRules = [];
|
|
|
|
for (const rulebook of rulebooks) {
|
|
const pdfPath = path.join(dataDir, rulebook.filename);
|
|
|
|
if (!fs.existsSync(pdfPath)) {
|
|
console.warn(`PDF not found: ${pdfPath}`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`Processing ${rulebook.name}...`);
|
|
|
|
try {
|
|
// Extract text
|
|
const txtPath = path.join(outputDir, `${rulebook.abbreviation}.txt`);
|
|
await extractPDFText(pdfPath, txtPath);
|
|
|
|
// Read extracted text
|
|
const text = fs.readFileSync(txtPath, 'utf8');
|
|
|
|
// Parse rules
|
|
const rules = parseRulesFromText(text, rulebook);
|
|
console.log(`Extracted ${rules.length} rules from ${rulebook.name}`);
|
|
|
|
allRules = allRules.concat(rules);
|
|
|
|
} catch (error) {
|
|
console.error(`Failed to process ${rulebook.name}:`, error);
|
|
}
|
|
}
|
|
|
|
if (allRules.length === 0) {
|
|
console.error('No rules extracted! Make sure pdftotext is installed (sudo apt install poppler-utils)');
|
|
return;
|
|
}
|
|
|
|
// Assign unique IDs
|
|
allRules.forEach((rule, index) => {
|
|
rule.id = `rule_${index + 1}`;
|
|
});
|
|
|
|
// Create search index
|
|
console.log('Creating search index...');
|
|
const searchIndex = createSearchIndex(allRules);
|
|
|
|
// Save rules database
|
|
const rulesDbPath = path.join(outputDir, 'rules-database.json');
|
|
fs.writeFileSync(rulesDbPath, JSON.stringify({
|
|
rules: allRules,
|
|
searchIndex: searchIndex,
|
|
metadata: {
|
|
totalRules: allRules.length,
|
|
sources: rulebooks.map(rb => ({ name: rb.name, abbr: rb.abbreviation })),
|
|
categories: [...new Set(allRules.map(r => r.category))],
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
}, null, 2));
|
|
|
|
console.log(`Rules database created: ${rulesDbPath}`);
|
|
console.log(`Total rules: ${allRules.length}`);
|
|
console.log(`Search terms indexed: ${Object.keys(searchIndex).length}`);
|
|
|
|
// Create a sample rules file for immediate testing
|
|
const sampleRules = createSampleRules();
|
|
const sampleDbPath = path.join(outputDir, 'sample-rules.json');
|
|
fs.writeFileSync(sampleDbPath, JSON.stringify({
|
|
rules: sampleRules,
|
|
searchIndex: createSearchIndex(sampleRules),
|
|
metadata: {
|
|
totalRules: sampleRules.length,
|
|
sources: [{ name: 'Sample Rules', abbr: 'SAMPLE' }],
|
|
categories: [...new Set(sampleRules.map(r => r.category))],
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
}, null, 2));
|
|
|
|
console.log(`Sample rules created for testing: ${sampleDbPath}`);
|
|
}
|
|
|
|
function createSampleRules() {
|
|
return [
|
|
{
|
|
id: 'rule_sample_1',
|
|
title: 'Attack Roll',
|
|
content: 'To make an attack, roll 1d100 and compare to your Weapon Skill (for melee) or Ballistic Skill (for ranged). If the result is equal to or less than your skill, you hit. Apply modifiers for difficulty, range, and circumstances.',
|
|
page: 45,
|
|
source: 'Core Rulebook',
|
|
sourceAbbr: 'CR',
|
|
category: 'combat',
|
|
examples: 'Brother Marcus has WS 40. Rolling 35 succeeds, rolling 45 fails.',
|
|
relatedRules: ['Damage Roll', 'Critical Hit', 'Dodge']
|
|
},
|
|
{
|
|
id: 'rule_sample_2',
|
|
title: 'Damage Roll',
|
|
content: 'When you successfully hit, roll damage using the weapon\'s damage dice plus your Strength Bonus (for melee) or the weapon\'s damage value (for ranged). Compare to the target\'s Toughness and armor.',
|
|
page: 48,
|
|
source: 'Core Rulebook',
|
|
sourceAbbr: 'CR',
|
|
category: 'combat',
|
|
examples: 'Chainsword deals 1d10+3 damage. With SB 4, total damage is 1d10+7.',
|
|
relatedRules: ['Attack Roll', 'Armor', 'Critical Hit']
|
|
},
|
|
{
|
|
id: 'rule_sample_3',
|
|
title: 'Power Armor',
|
|
content: 'Astartes Power Armor provides 8 points of armor to all locations and includes built-in systems like auto-senses, vox communications, and life support. It also provides immunity to environmental hazards.',
|
|
page: 150,
|
|
source: 'Core Rulebook',
|
|
sourceAbbr: 'CR',
|
|
category: 'armor',
|
|
examples: 'An attack dealing 12 damage is reduced to 4 damage (12-8=4).',
|
|
relatedRules: ['Armor', 'Equipment', 'Requisition']
|
|
},
|
|
{
|
|
id: 'rule_sample_4',
|
|
title: 'Full Auto',
|
|
content: 'Weapons with Full Auto can fire multiple shots in a single attack. Roll to hit as normal. For every 2 degrees of success, score one additional hit (maximum of half the weapon\'s Rate of Fire).',
|
|
page: 52,
|
|
source: 'Core Rulebook',
|
|
sourceAbbr: 'CR',
|
|
category: 'weapons',
|
|
examples: 'Bolter RoF 4, hitting by 20 (2 degrees) scores 2 hits total.',
|
|
relatedRules: ['Attack Roll', 'Semi Auto', 'Jamming']
|
|
},
|
|
{
|
|
id: 'rule_sample_5',
|
|
title: 'Requisition',
|
|
content: 'Space Marines acquire equipment through the Requisition system using Requisition Points. Standard equipment costs 1-5 points, rare items cost more. Points are awarded for mission success and renewed each mission.',
|
|
page: 130,
|
|
source: 'Core Rulebook',
|
|
sourceAbbr: 'CR',
|
|
category: 'equipment',
|
|
examples: 'Bolter costs 1 RP, Heavy Bolter costs 15 RP.',
|
|
relatedRules: ['Equipment', 'Renown', 'Supply']
|
|
},
|
|
{
|
|
id: 'rule_sample_6',
|
|
title: 'Squad Mode',
|
|
content: 'When Battle-Brothers maintain Cohesion, they can enter Squad Mode and use special abilities. Each round in Squad Mode, generate Cohesion points based on squad leadership and spend them on Squad Mode abilities.',
|
|
page: 210,
|
|
source: 'Core Rulebook',
|
|
sourceAbbr: 'CR',
|
|
category: 'squad',
|
|
examples: 'A 5-man squad generates 2 Cohesion points per round.',
|
|
relatedRules: ['Cohesion', 'Leadership', 'Tactics']
|
|
}
|
|
];
|
|
}
|
|
|
|
// Check if pdftotext is available
|
|
exec('which pdftotext', (error) => {
|
|
if (error) {
|
|
console.log('pdftotext not found. Installing...');
|
|
console.log('Run: sudo apt install poppler-utils');
|
|
console.log('For now, creating sample rules database...');
|
|
|
|
// Create sample rules even without PDF processing
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
const sampleRules = createSampleRules();
|
|
const sampleDbPath = path.join(outputDir, 'rules-database.json');
|
|
fs.writeFileSync(sampleDbPath, JSON.stringify({
|
|
rules: sampleRules,
|
|
searchIndex: createSearchIndex(sampleRules),
|
|
metadata: {
|
|
totalRules: sampleRules.length,
|
|
sources: [{ name: 'Sample Rules', abbr: 'SAMPLE' }],
|
|
categories: [...new Set(sampleRules.map(r => r.category))],
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
}, null, 2));
|
|
|
|
console.log('Sample rules database created for testing.');
|
|
} else {
|
|
// PDF processing is available
|
|
processRulebooks().catch(console.error);
|
|
}
|
|
});
|