- 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.
212 lines
6.8 KiB
JavaScript
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();
|