Files
dwroller/scripts/merge-validate-upsert-npcs.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

212 lines
6.8 KiB
JavaScript

// merge-validate-upsert-npcs.js
// Fetch NPCs from local API, normalize to bestiary schema, validate and upsert into database/deathwatch-bestiary-extracted.json
const fs = require('fs');
const path = require('path');
const http = require('http');
const API_URL = 'http://192.168.1.144:5000/api/npcs';
const BESTIARY_PATH = path.join(__dirname, '../database/deathwatch-bestiary-extracted.json');
const BACKUP_PATH = path.join(__dirname, `../database/deathwatch-bestiary-extracted.backup.${Date.now()}.json`);
function fetchAPI(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Failed to parse API response: ' + e.message + '\n' + data.slice(0,2000)));
}
});
}).on('error', reject);
});
}
function asIntOrNull(v) {
if (v === null || v === undefined) return null;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
}
function normalizeApiNpc(apiNpc) {
const s = apiNpc.stats || {};
const profile = {
ws: asIntOrNull(s.ws),
bs: asIntOrNull(s.bs),
s: asIntOrNull(s.s),
t: asIntOrNull(s.t),
ag: asIntOrNull(s.ag),
int: asIntOrNull(s.int),
per: asIntOrNull(s.per),
wp: asIntOrNull(s.wp),
fel: asIntOrNull(s.fel),
modifiers: null
};
// compact movement if present
const movement = s.movement || null;
const wounds = asIntOrNull(s.wounds);
const armour = (s.armor || s.armour) ? String(s.armor || s.armour) : null;
// equipment array -> gear string
const gearArr = Array.isArray(apiNpc.equipment) ? apiNpc.equipment : [];
const gear = gearArr.length ? gearArr.join('; ') : null;
// try to pick out obvious weapons from equipment by keywords
const weaponsCandidates = gearArr.filter(it => /bolt|las|plasma|sword|knife|axe|grenade|blade|pistol|rifle|power|monoblade|stikk|autogun/i.test(it));
const weapons = weaponsCandidates.length ? weaponsCandidates.join('; ') : null;
const normalized = {
bestiaryName: apiNpc.name || apiNpc.bestiaryName || null,
book: apiNpc.source || null,
page: apiNpc.page || null,
pdf: apiNpc.source ? apiNpc.source.replace(/[:\s]/g, '_') + '.pdf' : null,
pageText: null,
// preserve some meta
source: apiNpc.source || null,
apiId: apiNpc.id || null,
description: apiNpc.description || null,
type: apiNpc.type || null,
faction: apiNpc.faction || null,
abilities: Array.isArray(apiNpc.abilities) ? apiNpc.abilities : null,
stats: {
profile,
movement,
wounds,
toughness: null,
skills: null,
talents: null,
traits: null,
armour,
weapons,
gear,
snippet: null,
chosenOffset: null
}
};
return normalized;
}
function mergeRecords(existing, incoming) {
// shallow merge: prefer existing non-null values, otherwise take incoming
const out = { ...existing };
// top-level fields
['bestiaryName','book','page','pdf','pageText','source','apiId','description','type','faction','abilities'].forEach(k=>{
if ((!out[k] || out[k] === null) && incoming[k] !== undefined) out[k] = incoming[k];
});
// merge stats
out.stats = out.stats || {};
const ex = existing.stats || {};
const inS = incoming.stats || {};
// profile
out.stats.profile = out.stats.profile || {};
const exP = ex.profile || {};
const inP = inS.profile || {};
['ws','bs','s','t','ag','int','per','wp','fel','modifiers'].forEach(k=>{
const exV = exP[k];
const inV = inP[k];
out.stats.profile[k] = (exV !== null && exV !== undefined) ? exV : (inV !== null && inV !== undefined ? inV : null);
});
// other stat fields
['movement','wounds','toughness','skills','talents','traits','armour','weapons','gear','snippet','chosenOffset'].forEach(k=>{
const exV = (ex && ex[k] !== undefined) ? ex[k] : undefined;
const inV = (inS && inS[k] !== undefined) ? inS[k] : undefined;
out.stats[k] = (exV !== undefined && exV !== null) ? exV : (inV !== undefined ? inV : null);
});
return out;
}
async function main() {
// backup bestiary
if (fs.existsSync(BESTIARY_PATH)) {
fs.copyFileSync(BESTIARY_PATH, BACKUP_PATH);
console.log('Backed up bestiary to', BACKUP_PATH);
} else {
console.log('No existing bestiary found at', BESTIARY_PATH, ' - a new file will be created');
}
let bestiaryObj = { results: [] };
try {
if (fs.existsSync(BESTIARY_PATH)) {
bestiaryObj = JSON.parse(fs.readFileSync(BESTIARY_PATH, 'utf8'));
}
} catch (e) {
console.error('Failed to read existing bestiary:', e.message);
bestiaryObj = { results: [] };
}
if (!Array.isArray(bestiaryObj.results)) bestiaryObj.results = [];
// fetch API NPCs
let apiNpcs = [];
try {
const j = await fetchAPI(API_URL);
apiNpcs = j.npcs || [];
} catch (e) {
console.error('Failed to fetch API NPCs:', e.message);
return;
}
const added = [];
const updated = [];
for (const apiNpc of apiNpcs) {
const norm = normalizeApiNpc(apiNpc);
// find existing by bestiaryName or apiId or name
const idx = bestiaryObj.results.findIndex(r => {
if (!r) return false;
if (r.apiId && norm.apiId && r.apiId === norm.apiId) return true;
const rn = (r.bestiaryName || r.name || '').toString().trim().toLowerCase();
const nn = (norm.bestiaryName || '').toString().trim().toLowerCase();
return rn && nn && rn === nn;
});
if (idx === -1) {
// new entry
// ensure top-level required keys exist
const toInsert = {
bestiaryName: norm.bestiaryName,
book: norm.book,
page: norm.page,
pdf: norm.pdf,
pageText: norm.pageText,
stats: norm.stats,
description: norm.description,
source: norm.source,
apiId: norm.apiId,
type: norm.type,
faction: norm.faction,
abilities: norm.abilities || null,
insertedAt: new Date().toISOString()
};
bestiaryObj.results.push(toInsert);
added.push(norm.bestiaryName || norm.apiId || 'unknown');
} else {
const existing = bestiaryObj.results[idx];
const merged = mergeRecords(existing, norm);
merged.updatedAt = new Date().toISOString();
bestiaryObj.results[idx] = merged;
updated.push(merged.bestiaryName || merged.apiId || 'unknown');
}
}
bestiaryObj.generatedAt = new Date().toISOString();
bestiaryObj.count = bestiaryObj.results.length;
fs.writeFileSync(BESTIARY_PATH, JSON.stringify(bestiaryObj, null, 2));
console.log(`Merge complete. Added: ${added.length}, Updated: ${updated.length}`);
if (added.length) console.log('Added:', added.join(', '));
if (updated.length) console.log('Updated:', updated.join(', '));
}
main();