Files
dwroller/scripts/refine-enemies.js
Alex caf8a03553 feat: Add scripts for upserting and validating bestiary data
- Implemented `upsert-allewis-from-5001.js` to fetch and update Allewis data from API.
- Created `upsert-missing-movement-from-5001.js` to fill missing movement and wounds data from API.
- Developed `upsert-missing-movement-from-pdfs.js` to extract movement and wounds from PDF text.
- Added `upsert-missing-profiles-from-pdfs.js` to fill missing profiles from PDF data.
- Introduced `upsert-missing-wounds-from-pdfs.js` to update wounds data from PDF sources.
- Created `validate-bestiary.js` to validate the structure of the bestiary JSON.
- Updated `BestiaryTab.jsx` component to display bestiary data with improved structure and search functionality.
- Added tests for `BestiaryTab` to ensure proper rendering and functionality.
2025-08-17 16:21:00 +02:00

129 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
// Refine and produce a high-confidence enemy DB from structured entries
const fs = require('fs');
const path = require('path');
const IN_FILE = path.join(__dirname, '..', 'database', 'deathwatch-enemies.json');
const OUT_FILE = path.join(__dirname, '..', 'database', 'deathwatch-enemies-highconfidence.json');
const OUT_SAMPLE = path.join(__dirname, '..', 'database', 'deathwatch-enemies-highconfidence-sample.json');
function strongNormalize(s) {
if (!s) return '';
let t = s.toString();
// remove parenthetical notes
t = t.replace(/\(.*?\)/g, '');
// remove descriptors after dash or colon
t = t.split(/[-:—–]/)[0];
t = t.replace(/[^\w\s'']/g, ' ');
t = t.replace(/\bthe\b\s*/gi, '');
// remove common suffix tokens
t = t.replace(/\b(elite|swarm|pack|horde|unit|squad|group|team|vehicle|daemon|daemonhost|host|variant)\b/gi, '');
t = t.replace(/\s+/g, ' ').trim().toLowerCase();
// simple singularize (if very likely plural)
if (t.length > 4 && t.endsWith('s') && !t.endsWith('ss')) {
t = t.slice(0, -1);
}
return t;
}
function mergeNumeric(a,b) {
// choose the max non-null
if (typeof a !== 'number') return b;
if (typeof b !== 'number') return a;
return Math.max(a,b);
}
function chooseRepresentative(namesMap) {
let bestName = null; let bestCount = -1;
for (const [name,count] of Object.entries(namesMap)) {
if (count > bestCount) { bestCount = count; bestName = name; }
}
return bestName || Object.keys(namesMap)[0] || '';
}
function main() {
if (!fs.existsSync(IN_FILE)) { console.error('Missing input', IN_FILE); process.exit(1); }
const raw = JSON.parse(fs.readFileSync(IN_FILE, 'utf8'));
const groups = new Map();
for (const e of raw) {
const name = (e.name || '').trim();
if (!name) continue;
const key = strongNormalize(name);
if (!key) continue;
if (!groups.has(key)) {
groups.set(key, {
key,
names: {},
count: 0,
sources: {},
snippets: new Set(),
wounds: null,
toughness: null,
armour: null,
armourByLoc: null,
pages: new Set()
});
}
const g = groups.get(key);
g.count += (e.count||1);
g.names[name] = (g.names[name]||0) + (e.count||1);
if (e.source) g.sources[e.source] = (g.sources[e.source]||0)+1;
for (const s of (e.snippets||[])) {
if (s && g.snippets.size < 10) g.snippets.add(s);
}
if (e.wounds) g.wounds = mergeNumeric(g.wounds, e.wounds);
if (e.toughness) g.toughness = mergeNumeric(g.toughness, e.toughness);
if (e.armour) g.armour = mergeNumeric(g.armour, e.armour);
if (e.armourByLoc && !g.armourByLoc) g.armourByLoc = e.armourByLoc;
if (e.page) g.pages.add(e.page);
}
// Score and filter
const preferredSources = ['core rulebook','rising tempest','rites of battle','the emperor protects','the emperors chosen','final sanction'];
const out = [];
for (const g of groups.values()) {
let score = g.count;
const hasStats = (typeof g.wounds === 'number') || (typeof g.toughness === 'number') || (typeof g.armour === 'number');
if (hasStats) score += 50;
if (g.pages.size) score += 10;
// source boost
for (const src of Object.keys(g.sources)) {
const lowered = src.toLowerCase();
for (const p of preferredSources) if (lowered.includes(p)) score += 5;
}
// name length penalty for very short keys
if (g.key.length < 3) score = 0;
// filter: require either stats or count>=2 or page hint
if (!(hasStats || g.count >= 2 || g.pages.size)) continue;
const repName = chooseRepresentative(g.names);
out.push({
id: g.key,
name: repName,
count: g.count,
score,
wounds: g.wounds || null,
toughness: g.toughness || null,
armour: g.armour || null,
armourByLoc: g.armourByLoc || null,
sources: Object.keys(g.sources),
pages: Array.from(g.pages),
snippets: Array.from(g.snippets).slice(0,5)
});
}
out.sort((a,b) => b.score - a.score || b.count - a.count);
// Keep top N high confidence — we'll keep all for now but the user expects ~300; further pruning below
// Additional pruning: drop low-score tail: keep only score >= 10 or top 1000
const final = out.filter((x,i)=> x.score >= 10 || i < 1000);
fs.writeFileSync(OUT_FILE, JSON.stringify(final, null, 2));
fs.writeFileSync(OUT_SAMPLE, JSON.stringify(final.slice(0,200), null, 2));
console.log('Wrote', OUT_FILE, 'with', final.length, 'high-confidence entries (out of', out.length, 'promising groups)');
}
if (require.main === module) main();