Files
dwroller/database/scripts/migrate_transform_sheet_fields.js
Alex dde3abda55 feat: Add validation and normalization utilities for player records
- Implemented `validatePlayer` and `normalizeTabInfo` functions in `database/validate.js` for player data validation and normalization.
- Added error handling for required fields and enforced data types and constraints.
- Introduced standardization for characteristics and skills.

feat: Create script for extracting rules from PDF files

- Developed `extract-rules.js` to extract text from PDF rulebooks and categorize rules.
- Integrated `pdftotext` for PDF processing and created a searchable rules database.
- Implemented rule categorization and indexing for efficient searching.

feat: Implement RulesTab component for rule searching

- Created `RulesTab.jsx` for searching and displaying rules with filtering options.
- Added recent searches and quick reference buttons for user convenience.
- Integrated API calls for fetching rules and displaying results dynamically.

feat: Add test script for Rules API

- Created `test-rules.js` to test the functionality of the Rules API endpoints.
- Included tests for search and stats endpoints to ensure proper response handling.
2025-08-16 12:15:32 +02:00

210 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { playerHelpers } = require('../sqlite-db');
const { validatePlayer } = require('../validate');
const dbDir = path.join(__dirname, '..', 'sqlite');
const dbPath = path.join(dbDir, 'deathwatch.db');
const backupsDir = path.join(__dirname, '..', 'backups');
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
const armouryPath = path.join(__dirname, '..', '..', 'public', 'deathwatch-armoury.json');
let armoury = {};
try { armoury = JSON.parse(fs.readFileSync(armouryPath, 'utf8')); } catch (e) { console.warn('Armoury not found or invalid'); }
function timestamp() { return new Date().toISOString().replace(/[:.]/g,''); }
const args = process.argv.slice(2);
const doApply = args.includes('--apply');
// canonical skill list (from PlayerTab)
const SKILLS = [
'Acrobatics (Ag)','Awareness (Per)','Barter (Fel)','Blather (Fel)','Carouse (Fel)','Charm (Fel)','Chem-Use (Int)','Ciphers (Int)','Chapter Runes','Climb (S)','Command (Fel)','Common Lore (Int)','Adeptus Astartes','Deathwatch','Imperium','War','Concealment (Ag)','Contortionist (Ag)','Deceive (Fel)','Demolition (Int)','Disguise (Fel)','Dodge (Ag)','Drive (Ag)','Evaluate','Forbidden Lore (Int)','Xenos','Gamble (Int)','Inquiry (Fel)','Interrogation (WP)','Intimidate (S)','Invocation (WP)','Lip Reading (Per)','Literacy (Int)','Logic (Int)','Medicae (Int)','Navigation (Int)','Surface','Performer (Fel)','Pilot (Ag)','Psyniscience (Per)','Scholastic Lore (Int)','Codex Astartes','Scrutiny (Per)','Search (Per)','Secret Tongue (Int)','Security (Ag)','Shadowing (Ag)','Silent Move (Ag)','Sleight of Hand (Ag)','Speak Language (Int)','Survival (Int)','Swim (S)','Tactics (Int)','Tech-Use (Int)','Tracking (Int)','Trade (Int)','Wrangling (Int)'
];
function mapCharacteristics(chars) {
// produce lower-case keys expected by frontend
const keys = { ws: 'WS', bs: 'BS', s: 'S', t: 'T', ag: 'Ag', int: 'Int', per: 'Per', wp: 'Wp', fel: 'Fel' };
const out = {};
if (!chars || typeof chars !== 'object') return Object.fromEntries(Object.keys(keys).map(k=>[k,0]));
// copy by case-insensitive match
const lookup = {};
Object.keys(chars).forEach(k => { lookup[k.toLowerCase()] = chars[k]; lookup[k.toUpperCase()] = chars[k]; });
Object.entries(keys).forEach(([lk, up]) => {
let val = 0;
if (chars[lk] !== undefined) val = Number(chars[lk]) || 0;
else if (chars[up] !== undefined) val = Number(chars[up]) || 0;
else if (chars[lk.toLowerCase()] !== undefined) val = Number(chars[lk.toLowerCase()]) || 0;
else if (chars[up.toLowerCase()] !== undefined) val = Number(chars[up.toLowerCase()]) || 0;
out[lk] = val;
});
return out;
}
function convertSkills(skillsIn) {
// skillsIn may be array of strings or object map
const out = {};
for (const s of SKILLS) out[s] = { trained: false, plus10: false, plus20: false };
if (!skillsIn) return out;
if (Array.isArray(skillsIn)) {
for (const s of skillsIn) {
const key = SKILLS.find(k => k.toLowerCase() === String(s).toLowerCase());
if (key) out[key].trained = true;
}
return out;
}
if (typeof skillsIn === 'object') {
// assume already in map form
for (const k of Object.keys(skillsIn)) {
const key = SKILLS.find(s => s.toLowerCase() === k.toLowerCase()) || k;
out[key] = Object.assign({ trained:false, plus10:false, plus20:false }, skillsIn[k]);
}
// ensure all standard skills exist
for (const s of SKILLS) if (!out[s]) out[s] = { trained:false, plus10:false, plus20:false };
return out;
}
return out;
}
function resolveInventoryToWeaponsAndArmour(inv, existingWeapons, existingArmour) {
const weapons = Array.isArray(existingWeapons) ? [...existingWeapons] : [];
const armour = Object.assign({}, existingArmour || {});
if (!Array.isArray(inv)) return { weapons, armour };
for (const it of inv) {
if (!it || !it.name) continue;
const name = it.name;
// armoury.items is an object keyed by category; search each category array
let fromArmoury = null;
if (armoury && armoury.items) {
for (const cat of Object.keys(armoury.items)) {
const arr = armoury.items[cat];
if (Array.isArray(arr)) {
const found = arr.find(i => i.name === name || String(i.name).toLowerCase() === String(name).toLowerCase());
if (found) { fromArmoury = found; break; }
}
}
}
if (fromArmoury) {
const item = fromArmoury;
// stats may be in item.stats: { damage, class, source } or similar
const stats = item.stats || {};
const damageStr = stats.damage || '';
const cls = stats.class || '';
// heuristics: if stats.damage looks like weapon or category contains 'Weapon'
const isWeapon = /Range|Pen|Clip|Reload|RoF|Tearing|Melta|Flame|Shotgun|Bolt|Pistol|Rifle|Cannon|Gun/i.test(damageStr) || /weapon/i.test(item.category || '');
const isArmour = /Armor|Armour|Power Armor|Carapace|Shield/i.test(item.category || '') || /armou?r/i.test(name) || /power armour/i.test(name);
if (isWeapon) {
// extract simple fields from damageStr
const rangeMatch = damageStr.match(/Range\s*([^;]+)/i);
const penMatch = damageStr.match(/Pen\s*(\d+)/i);
const clipMatch = damageStr.match(/Clip\s*(\d+)/i);
const rldMatch = damageStr.match(/Reload\s*([^;]+)/i);
const rofMatch = damageStr.match(/RoF/i);
const specialParts = [];
if (damageStr) specialParts.push(damageStr);
if (stats.source) specialParts.push(stats.source);
weapons.push({
name: item.name || name,
class: cls || '',
damage: damageStr || '',
type: item.category || '',
pen: penMatch ? penMatch[1] : '',
range: rangeMatch ? rangeMatch[1].trim() : '',
rof: rofMatch ? 'RoF' : '',
clip: clipMatch ? clipMatch[1] : '',
rld: rldMatch ? rldMatch[1].trim() : '',
special: specialParts.join(' | ')
});
} else if (isArmour) {
// place power/carapace armour into body slot by default
armour.body = armour.body || item.name || name;
// if protection object present, map head/arms/legs
if (stats.protection && typeof stats.protection === 'object') {
if (stats.protection.head !== undefined) armour.head = armour.head || stats.protection.head;
if (stats.protection.arms !== undefined) armour.ra = armour.ra || stats.protection.arms;
if (stats.protection.body !== undefined) armour.body = armour.body || stats.protection.body || item.name;
if (stats.protection.legs !== undefined) armour.rl = armour.rl || stats.protection.legs;
}
}
}
}
return { weapons, armour };
}
// Add pre-gen names if requested via a special flag file
function ensurePregens() {
const pregensFile = path.join(__dirname, '..', 'pregen_names.json');
if (!fs.existsSync(pregensFile)) {
const sample = ["Brother-1","Brother-2","Brother-3","Brother-4","Brother-5"];
fs.writeFileSync(pregensFile, JSON.stringify(sample, null, 2), 'utf8');
return sample;
}
try { return JSON.parse(fs.readFileSync(pregensFile, 'utf8')); } catch { return []; }
}
(async function main(){
console.log('Starting sheet transform migration');
if (doApply) {
const bak = path.join(dbDir, `deathwatch.db.pre_transform.${timestamp()}.bak`);
fs.copyFileSync(dbPath, bak);
console.log('Backup created at', bak);
}
const players = playerHelpers.getAll();
const results = [];
for (const p of players) {
const before = JSON.parse(JSON.stringify(p.tabInfo || {}));
const tab = Object.assign({}, before);
// characteristics -> lowercase keys
tab.characteristics = mapCharacteristics(tab.characteristics || {});
// skills -> map form
tab.skills = convertSkills(tab.skills || []);
// fate defaults
if (!tab.fate || typeof tab.fate !== 'object') tab.fate = { total: 1, current: 1 };
else {
tab.fate.total = Number(tab.fate.total) || 1;
tab.fate.current = Number(tab.fate.current) || tab.fate.total || 1;
}
// ensure renown normalized via validate
const validated = validatePlayer({ name: p.name, tabInfo: tab });
tab.renown = validated.normalized.tabInfo.renown;
// powerArmour normalization
if (tab.powerArmour === 'true' || tab.powerArmour === true) tab.powerArmour = true;
else tab.powerArmour = !!tab.powerArmour;
// resolve inventory items into weapons/armour fields where possible
const { weapons: newWeapons, armour: newArmour } = resolveInventoryToWeaponsAndArmour(tab.inventory || [], tab.weapons || [], tab.armour || {});
if (newWeapons.length > 0) tab.weapons = newWeapons;
tab.armour = Object.assign({}, tab.armour || {}, newArmour);
// ensure talents is string
if (!tab.talents) tab.talents = tab.talents || '';
const after = tab;
const changed = JSON.stringify(before) !== JSON.stringify(after);
results.push({ name: p.name, changed, before, after });
if (doApply && changed) {
const ok = playerHelpers.update(p.name, { name: p.name, rollerInfo: p.rollerInfo, shopInfo: p.shopInfo, tabInfo: after, pw: p.pw, pwHash: p.pwHash });
if (!ok) console.error('Failed to update', p.name);
}
}
const out = { applied: !!doApply, timestamp: new Date().toISOString(), results };
const outFile = path.join(backupsDir, `migrate_transform_sheet_fields.${doApply ? 'applied' : 'dryrun'}.${timestamp()}.json`);
fs.writeFileSync(outFile, JSON.stringify(out, null, 2), 'utf8');
console.log((doApply ? 'Applied' : 'Dry-run complete') + ' - report written to', outFile);
})();