#!/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();