Files
dwroller/scripts/hydrate-bestiary-from-40krpgtools.js

328 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/*
* Hydrate staged 40k RPG Tools index rows from local PDFs into the compact live
* bestiary JSON. This does not scrape statblocks from the web; it uses the web
* index as a page map and extracts only compact combat fields from local PDFs.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const INDEX_PATH = path.join(ROOT, 'database', 'remote-extract', '40krpgtools-bestiary-index.json');
const PUBLIC_PATH = path.join(ROOT, 'public', 'deathwatch-bestiary-extracted.json');
const DB_PATH = path.join(ROOT, 'database', 'deathwatch-bestiary-extracted.json');
const BOOKS = {
'Mark of the Xenos': { pdf: path.join(ROOT, 'database', 'rules', 'MoX.pdf'), pageOffset: 6 },
'First Founding': { pdf: path.join(ROOT, 'database', 'rules', 'FF.pdf'), pageOffset: 8 },
'The Emperor Protects': { pdf: path.join(ROOT, 'database', 'rules', 'HtC.pdf'), pageOffset: 8 },
'Rites of Battle': { pdf: path.join(ROOT, 'database', 'rules', 'RoB.pdf'), pageOffset: 8 },
'Deathwatch Core Rulebook': { pdf: path.join(ROOT, 'database', 'rules', 'CR.pdf'), pageOffset: 0 },
};
const PROFILE_KEYS = ['ws', 'bs', 's', 't', 'ag', 'int', 'per', 'wp', 'fel'];
const RANK_RE = '(Troops?|Elite|Master|Minion|Personal|Vehicle|Horde)';
function argValue(name, fallback = null) {
const prefix = `--${name}=`;
const found = process.argv.find(arg => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : fallback;
}
function hasFlag(name) {
return process.argv.includes(`--${name}`);
}
function norm(value) {
return String(value || '')
.toLowerCase()
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function titleCase(value) {
return String(value || '').replace(/\b\w/g, c => c.toUpperCase());
}
function textForPage(book, page) {
const cfg = BOOKS[book];
const listedPage = Number(page);
const physicalStart = Math.max(1, listedPage);
const physicalEnd = listedPage + cfg.pageOffset + 2;
return execFileSync('pdftotext', ['-raw', '-f', String(physicalStart), '-l', String(physicalEnd), cfg.pdf, '-'], {
cwd: ROOT,
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 8,
});
}
function cleanText(value) {
return String(value || '')
.replace(/\u0008/g, '')
.replace(/[“”]/g, '"')
.replace(/[]/g, "'")
.replace(/\s+/g, ' ')
.replace(/\s+([,.;:])/g, '$1')
.trim();
}
function splitTopLevelList(text) {
const items = [];
let current = '';
let depth = 0;
for (const ch of cleanText(text)) {
if (ch === '(') depth += 1;
if (ch === ')' && depth > 0) depth -= 1;
if (ch === ',' && depth === 0) {
if (current.trim()) items.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) items.push(current.trim());
return items;
}
function compactList(text, maxItems = 10, maxChars = 260) {
const items = splitTopLevelList(text)
.map(item => item.replace(/[.;:\s]+$/, '').trim())
.filter(Boolean);
if (!items.length) return '';
const suffix = items.length > maxItems ? `, +${items.length - maxItems} more` : '';
const out = items.slice(0, maxItems).join(', ') + suffix;
return out.length > maxChars ? out.slice(0, maxChars - 1).trimEnd() + '...' : out;
}
function compactText(text, maxChars = 260) {
const out = cleanText(text).replace(/[.;:\s]+$/, '');
return out.length > maxChars ? out.slice(0, maxChars - 1).trimEnd() + '...' : out;
}
function section(block, start, stops) {
const startRe = new RegExp(`${start}\\s*:`, 'i');
const startMatch = startRe.exec(block);
if (!startMatch) return '';
let rest = block.slice(startMatch.index + startMatch[0].length);
let cut = -1;
for (const stop of stops) {
const looseStop = /^(SpecialRules|Using|Ranged Weapons|Melee Weapons|Table|The Headsman|Tides of|Mark of|Cloud of)$/i.test(stop);
const pattern = looseStop
? `(?:^|\\s)${stop}(?:\\s*:|\\b)`
: `(?:^|\\n)\\s*${stop}\\s*:`;
const m = new RegExp(pattern, 'i').exec(rest);
if (m && (cut === -1 || m.index < cut)) cut = m.index;
}
if (cut >= 0) rest = rest.slice(0, cut);
return rest;
}
function profileFromBlock(block) {
const rows = block.split(/\r?\n/);
for (const line of rows) {
const tokens = line.trim().split(/\s+/).filter(Boolean);
const nums = tokens
.map(token => token.replace(/[–—-]{1,2}/g, '0'))
.filter(token => /^\d{1,3}$/.test(token))
.map(Number);
if (nums.length >= 9) {
return Object.fromEntries(PROFILE_KEYS.map((key, idx) => [key, nums[idx]]));
}
}
return null;
}
function armourFromText(text) {
const cleaned = cleanText(text);
const all = /\bAll\s+(\d+)/i.exec(cleaned);
if (all) return { all: Number(all[1]) };
const body = /\bBody\s+(\d+)/i.exec(cleaned);
const head = /\bHead\s+(\d+)/i.exec(cleaned);
const arms = /\bArms?(?:\s+and\s+Legs)?\s+(\d+)/i.exec(cleaned);
const legs = /\bLegs?\s+(\d+)/i.exec(cleaned);
if (body || head || arms || legs) {
return {
head: head ? Number(head[1]) : (body ? Number(body[1]) : 0),
body: body ? Number(body[1]) : 0,
arm: arms ? Number(arms[1]) : (body ? Number(body[1]) : 0),
leg: legs ? Number(legs[1]) : (arms ? Number(arms[1]) : (body ? Number(body[1]) : 0)),
};
}
return null;
}
function tbFrom(profile, traitsText) {
const base = Math.floor((Number(profile.t) || 0) / 10);
const unnatural = /Unnatural Toughness\s*\((?:x|×)(\d+)\)/i.exec(traitsText || '');
if (unnatural) return base * Number(unnatural[1]);
const daemonic = /Daemonic\s*\(TB\s*(\d+)\)/i.exec(traitsText || '');
if (daemonic) return Number(daemonic[1]);
return base;
}
function parseProfilesFromPage(text) {
const matches = [];
const profileRe = new RegExp(`([^\\n\\r]{2,80}?)\\s+\\(${RANK_RE}\\)\\s+Profile`, 'ig');
let match;
while ((match = profileRe.exec(text))) {
matches.push({
name: cleanText(match[1]).replace(/^[^A-Za-z0-9]+/, ''),
rank: titleCase(match[2]),
index: match.index,
});
}
return matches.map((item, idx) => {
const nextStart = matches[idx + 1]?.index ?? text.length;
const afterProfile = text.slice(item.index, nextStart);
const searchableAfterProfile = afterProfile.replace(/\u0008/g, '');
const profile = profileFromBlock(afterProfile);
const woundsMatch = /Wounds\s*:?\s*(\d+)/i.exec(searchableAfterProfile);
if (!profile || !woundsMatch) return null;
const movementMatch = /\b(?:Movement|Move|Speed)\s*:?\s*([0-9/]+)/i.exec(searchableAfterProfile);
const skills = compactList(section(searchableAfterProfile, 'Skills', ['Talents', 'Traits', 'Armour', 'Armor', 'Weapons', 'Gear', 'SpecialRules', 'Special']));
const talents = compactList(section(searchableAfterProfile, 'Talents', ['Traits', 'Armour', 'Armor', 'Weapons', 'Gear', 'SpecialRules', 'Special']));
const traitsText = section(searchableAfterProfile, 'Traits', ['Armour', 'Armor', 'Weapons', 'Gear', 'SpecialRules', 'Special', 'Psy Rating', 'Psychic Powers']);
const traits = compactList(traitsText);
const armourText = section(searchableAfterProfile, 'Armou?r', ['Weapons', 'Gear', 'Special', 'Skills', 'Talents', 'Traits']);
const weaponsText = section(searchableAfterProfile, 'Weapons', [
'Gear',
'SpecialRules',
'Special',
'Skills',
'Talents',
'Traits',
'Armour',
'Armor',
'Mark of',
'Cloud of',
'The Headsman',
'Tides of',
'Using',
'Ranged Weapons',
'Melee Weapons',
'Table',
]);
return {
name: item.name,
rank: item.rank,
profile,
wounds: Number(woundsMatch[1]),
movement: movementMatch ? movementMatch[1] : '',
skills,
talents,
traits,
armour: armourFromText(armourText),
weapons: compactText(weaponsText),
toughnessBonus: tbFrom(profile, traitsText),
};
}).filter(Boolean);
}
function loadBestiary(file) {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const arr = Array.isArray(raw) ? raw : (raw.results || raw.entries || raw.items);
if (!Array.isArray(arr)) throw new Error(`Unsupported bestiary shape in ${file}`);
return { raw, arr };
}
function toEntry(parsed, indexRow, book) {
const stats = {
profile: parsed.profile,
movement: parsed.movement,
wounds: parsed.wounds,
toughnessBonus: parsed.toughnessBonus,
statSource: 'pdf',
needsReview: false,
sourceIndex: '40krpgtools',
threatTier: parsed.rank,
parsedName: parsed.name,
};
if (parsed.armour) stats.armour = parsed.armour;
if (parsed.weapons) stats.weapons = parsed.weapons;
if (parsed.skills) stats.skills = parsed.skills;
if (parsed.talents) stats.talents = parsed.talents;
if (parsed.traits) stats.traits = parsed.traits;
return {
bestiaryName: indexRow.name,
type: indexRow.type,
affiliation: indexRow.affiliation,
book,
page: indexRow.page,
pdf: path.basename(BOOKS[book].pdf),
sourceUrl: indexRow.sourceUrl,
wounds: parsed.wounds,
stats,
};
}
function main() {
const book = argValue('book', 'Mark of the Xenos');
const limit = Number(argValue('limit', '0'));
const dryRun = hasFlag('dry-run');
const replaceBook = hasFlag('replace-book');
if (!BOOKS[book]) throw new Error(`Unsupported book: ${book}`);
const index = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
const { raw, arr } = loadBestiary(PUBLIC_PATH);
if (replaceBook) {
for (let i = arr.length - 1; i >= 0; i -= 1) {
const entry = arr[i] || {};
if (entry.book === book && entry.stats?.sourceIndex === '40krpgtools') {
arr.splice(i, 1);
}
}
}
const existing = new Set(arr.map(entry => norm(entry.bestiaryName || entry.name)).filter(Boolean));
const rows = index.entries.filter(row => row.book === book && !existing.has(norm(row.name)));
const pageCache = new Map();
const parsedByPage = new Map();
const added = [];
const skipped = [];
for (const row of rows) {
if (limit && added.length >= limit) break;
if (!pageCache.has(row.page)) {
const text = textForPage(book, row.page);
pageCache.set(row.page, text);
parsedByPage.set(row.page, parseProfilesFromPage(text));
}
const parsed = parsedByPage.get(row.page);
const hit = parsed.find(candidate => {
const a = norm(candidate.name);
const b = norm(row.name);
return a === b || a.includes(b) || b.includes(a);
});
if (!hit) {
skipped.push(`${row.name} (${book} ${row.page})`);
continue;
}
arr.push(toEntry(hit, row, book));
existing.add(norm(row.name));
added.push(`${row.name} (${book} ${row.page})`);
}
if (!dryRun) {
if (!Array.isArray(raw)) raw.count = arr.length;
const serialized = JSON.stringify(raw, null, 2);
for (const file of [PUBLIC_PATH, DB_PATH]) {
if (fs.existsSync(file)) fs.writeFileSync(file + '.pre-hydrate.' + Date.now() + '.json', fs.readFileSync(file));
fs.writeFileSync(file, serialized);
}
}
console.log(`${dryRun ? 'Would add' : 'Added'} ${added.length} entries from ${book}`);
added.forEach(name => console.log(` + ${name}`));
console.log(`Skipped ${skipped.length} rows without a parsed profile match`);
skipped.slice(0, 20).forEach(name => console.log(` - ${name}`));
}
main();