Remove orphaned SQLite-era scripts
Delete 17 one-off migration/admin scripts that imported the removed ./sqlite-db module. None are referenced by the app, package.json, or CI; they broke at the SQLite->MariaDB migration. No live code imports sqlite-db anymore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,227 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Simple DB cleaner/validator for SQLite players table
|
|
||||||
// Usage: node clean-database.js [--apply] [--delete-tests]
|
|
||||||
|
|
||||||
const { playerHelpers } = require('./sqlite-db');
|
|
||||||
|
|
||||||
function isObject(v) {
|
|
||||||
return v && typeof v === 'object' && !Array.isArray(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeName(name) {
|
|
||||||
if (!name) return '';
|
|
||||||
// trim and collapse spaces
|
|
||||||
return String(name).trim().replace(/\s+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeTestName(name) {
|
|
||||||
if (!name) return true;
|
|
||||||
return /test|dummy|sample|example/i.test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanPlayer(player) {
|
|
||||||
const issues = [];
|
|
||||||
const updates = {};
|
|
||||||
|
|
||||||
// name
|
|
||||||
const cleanName = normalizeName(player.name);
|
|
||||||
if (!cleanName) issues.push('empty name');
|
|
||||||
if (cleanName !== player.name) updates.name = cleanName;
|
|
||||||
if (cleanName.length > 100) issues.push('name too long');
|
|
||||||
|
|
||||||
// pw/pwHash
|
|
||||||
if (player.pw && player.pw.length > 0) issues.push('plain password present');
|
|
||||||
if (player.pwHash && typeof player.pwHash !== 'string') updates.pwHash = String(player.pwHash);
|
|
||||||
|
|
||||||
// rollerInfo / shopInfo / tabInfo should be objects
|
|
||||||
if (!isObject(player.rollerInfo)) {
|
|
||||||
issues.push('rollerInfo not object');
|
|
||||||
updates.rollerInfo = {};
|
|
||||||
}
|
|
||||||
if (!isObject(player.shopInfo)) {
|
|
||||||
issues.push('shopInfo not object');
|
|
||||||
updates.shopInfo = {};
|
|
||||||
}
|
|
||||||
if (!isObject(player.tabInfo)) {
|
|
||||||
issues.push('tabInfo not object');
|
|
||||||
updates.tabInfo = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flatten nested tabInfo.tabInfo
|
|
||||||
if (isObject(player.tabInfo) && isObject(player.tabInfo.tabInfo)) {
|
|
||||||
issues.push('nested tabInfo.tabInfo found; will be flattened');
|
|
||||||
updates.tabInfo = Object.assign({}, player.tabInfo.tabInfo, player.tabInfo);
|
|
||||||
delete updates.tabInfo.tabInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove mongo-specific fields inside tabInfo or rollerInfo
|
|
||||||
['mongoId', 'mongo_id', '_id', 'mongoIdString'].forEach(k => {
|
|
||||||
if (isObject(player.tabInfo) && player.tabInfo[k]) {
|
|
||||||
issues.push(`tabInfo contains ${k}; will be removed`);
|
|
||||||
const t = Object.assign({}, player.tabInfo);
|
|
||||||
delete t[k];
|
|
||||||
updates.tabInfo = Object.assign({}, updates.tabInfo || player.tabInfo || {}, t);
|
|
||||||
}
|
|
||||||
if (isObject(player.rollerInfo) && player.rollerInfo[k]) {
|
|
||||||
issues.push(`rollerInfo contains ${k}; will be removed`);
|
|
||||||
const r = Object.assign({}, player.rollerInfo);
|
|
||||||
delete r[k];
|
|
||||||
updates.rollerInfo = Object.assign({}, updates.rollerInfo || player.rollerInfo || {}, r);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Normalize common known fields types in tabInfo
|
|
||||||
const expectedTabFields = ['renown','rp','xp','xpSpent','wounds','movement','notes','playerName','charName','rank'];
|
|
||||||
if (isObject(player.tabInfo)) {
|
|
||||||
expectedTabFields.forEach(f => {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(player.tabInfo, f)) {
|
|
||||||
const val = player.tabInfo[f];
|
|
||||||
if (f === 'rp' || f === 'xp' || f === 'xpSpent' || f === 'wounds' || f === 'movement') {
|
|
||||||
// ensure numeric
|
|
||||||
const n = Number(val);
|
|
||||||
if (!Number.isFinite(n)) {
|
|
||||||
issues.push(`${f} not numeric; setting to 0`);
|
|
||||||
updates.tabInfo = Object.assign({}, updates.tabInfo || player.tabInfo || {}, { [f]: 0 });
|
|
||||||
} else if (n !== val) {
|
|
||||||
updates.tabInfo = Object.assign({}, updates.tabInfo || player.tabInfo || {}, { [f]: n });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (f === 'renown' && val && typeof val === 'string' && val.trim() === '') {
|
|
||||||
issues.push('renown blank string; normalizing to "None"');
|
|
||||||
updates.tabInfo = Object.assign({}, updates.tabInfo || player.tabInfo || {}, { renown: 'None' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect junk keys: very long strings or binary-like values
|
|
||||||
['rollerInfo','shopInfo','tabInfo'].forEach(k => {
|
|
||||||
const v = player[k];
|
|
||||||
if (isObject(v)) {
|
|
||||||
Object.keys(v).forEach(key => {
|
|
||||||
const val = v[key];
|
|
||||||
if (typeof val === 'string' && val.length > 2000) {
|
|
||||||
issues.push(`${k}.${key} unusually long (>${2000})`);
|
|
||||||
// truncate
|
|
||||||
updates[k] = Object.assign({}, updates[k] || v, { [key]: val.substring(0, 2000) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Identify obvious test users
|
|
||||||
if (looksLikeTestName(player.name)) {
|
|
||||||
issues.push('test/dummy/example user');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { issues, updates };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const apply = args.includes('--apply');
|
|
||||||
const deleteTests = args.includes('--delete-tests');
|
|
||||||
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
const report = {
|
|
||||||
total: players.length,
|
|
||||||
toDelete: [],
|
|
||||||
toUpdate: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const seenNames = new Map();
|
|
||||||
|
|
||||||
for (const pl of players) {
|
|
||||||
const { issues, updates } = cleanPlayer(pl);
|
|
||||||
|
|
||||||
// Duplicate name detection
|
|
||||||
const nameKey = normalizeName(pl.name).toLowerCase();
|
|
||||||
if (seenNames.has(nameKey)) {
|
|
||||||
issues.push('duplicate name');
|
|
||||||
report.toDelete.push({ name: pl.name, reason: 'duplicate' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seenNames.set(nameKey, true);
|
|
||||||
|
|
||||||
if (issues.length > 0) {
|
|
||||||
report.toUpdate.push({ name: pl.name, issues, updates });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (looksLikeTestName(pl.name) && deleteTests) {
|
|
||||||
report.toDelete.push({ name: pl.name, reason: 'test user' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print report
|
|
||||||
console.log('=== Clean DB Report (dry-run unless --apply) ===');
|
|
||||||
console.log('Total players:', report.total);
|
|
||||||
console.log('Candidates for update:', report.toUpdate.length);
|
|
||||||
console.log('Candidates for delete:', report.toDelete.length);
|
|
||||||
|
|
||||||
if (report.toUpdate.length > 0) {
|
|
||||||
console.log('\n--- Updates ---');
|
|
||||||
report.toUpdate.forEach(u => {
|
|
||||||
console.log('Player:', u.name);
|
|
||||||
console.log(' Issues:', u.issues.join('; '));
|
|
||||||
console.log(' Planned updates:', JSON.stringify(u.updates));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.toDelete.length > 0) {
|
|
||||||
console.log('\n--- Deletes ---');
|
|
||||||
report.toDelete.forEach(d => console.log('Player:', d.name, 'Reason:', d.reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apply) {
|
|
||||||
console.log('\nApplying fixes...');
|
|
||||||
let updated=0, deleted=0;
|
|
||||||
for (const u of report.toUpdate) {
|
|
||||||
try {
|
|
||||||
if (Object.keys(u.updates).length > 0) {
|
|
||||||
// If name is changing, perform delete/create to maintain unique constraint
|
|
||||||
if (u.updates.name) {
|
|
||||||
const orig = u.name;
|
|
||||||
const newName = u.updates.name;
|
|
||||||
const pl = playerHelpers.getByName(orig);
|
|
||||||
if (pl) {
|
|
||||||
// delete then recreate
|
|
||||||
playerHelpers.delete(orig);
|
|
||||||
playerHelpers.create(Object.assign({}, pl, { name: newName, rollerInfo: u.updates.rollerInfo || pl.rollerInfo, shopInfo: u.updates.shopInfo || pl.shopInfo, tabInfo: u.updates.tabInfo || pl.tabInfo, pw: pl.pw, pwHash: pl.pwHash }));
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// normal update
|
|
||||||
const pl = playerHelpers.getByName(u.name);
|
|
||||||
if (pl) {
|
|
||||||
const merged = {
|
|
||||||
rollerInfo: u.updates.rollerInfo || pl.rollerInfo,
|
|
||||||
shopInfo: u.updates.shopInfo || pl.shopInfo,
|
|
||||||
tabInfo: u.updates.tabInfo || pl.tabInfo,
|
|
||||||
pw: pl.pw,
|
|
||||||
pwHash: pl.pwHash
|
|
||||||
};
|
|
||||||
playerHelpers.update(u.name, merged);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to apply update for', u.name, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const d of report.toDelete) {
|
|
||||||
try {
|
|
||||||
if (playerHelpers.delete(d.name)) deleted++;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete', d.name, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Applied: ${updated} updates, ${deleted} deletes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nDone.');
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => { console.error('Clean script error', err); process.exit(1); });
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
// Script to ensure GM user exists
|
|
||||||
const { db } = require('./sqlite-db');
|
|
||||||
|
|
||||||
function logToFile(...args) {
|
|
||||||
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
|
|
||||||
require('fs').appendFileSync(require('path').join(__dirname, 'backend.log'), msg, { encoding: 'utf8' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if GM user exists
|
|
||||||
const existingGm = db.prepare('SELECT * FROM players WHERE name = ?').get('gm');
|
|
||||||
|
|
||||||
if (!existingGm) {
|
|
||||||
// Create GM user with password from environment variable
|
|
||||||
const gmPassword = process.env.GM_PASSWORD || 'defaultpassword';
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO players (name, pw, pw_hash, tab_info)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run('gm', gmPassword, gmPassword, JSON.stringify({
|
|
||||||
rp: 999999,
|
|
||||||
inventory: [],
|
|
||||||
renown: 'None'
|
|
||||||
}));
|
|
||||||
|
|
||||||
logToFile('Created GM user');
|
|
||||||
console.log('Created GM user');
|
|
||||||
} else {
|
|
||||||
// Update GM user password if needed
|
|
||||||
const gmPassword = process.env.GM_PASSWORD || 'defaultpassword';
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
UPDATE players
|
|
||||||
SET pw = ?, pw_hash = ?
|
|
||||||
WHERE name = 'gm'
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(gmPassword, gmPassword);
|
|
||||||
|
|
||||||
logToFile('Updated GM user');
|
|
||||||
console.log('Updated GM user');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure GM has admin privileges
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
UPDATE players
|
|
||||||
SET tab_info = ?
|
|
||||||
WHERE name = 'gm'
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(JSON.stringify({
|
|
||||||
rp: 999999,
|
|
||||||
inventory: [],
|
|
||||||
renown: 'None'
|
|
||||||
}));
|
|
||||||
|
|
||||||
logToFile('GM privileges ensured');
|
|
||||||
console.log('GM privileges ensured');
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
const { validatePlayer } = require('../validate');
|
|
||||||
|
|
||||||
function backupDb() {
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g,'');
|
|
||||||
const src = path.join(__dirname, '..', 'sqlite', 'deathwatch.db');
|
|
||||||
const dst = path.join(__dirname, '..', 'sqlite', `deathwatch.db.pre_normalize.${ts}.bak`);
|
|
||||||
if (fs.existsSync(src)) {
|
|
||||||
fs.copyFileSync(src, dst);
|
|
||||||
console.log('Backup created:', dst);
|
|
||||||
} else {
|
|
||||||
console.log('No sqlite DB found at', src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function apply() {
|
|
||||||
backupDb();
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
let applied = 0;
|
|
||||||
const details = [];
|
|
||||||
for (const p of players) {
|
|
||||||
const { valid, errors, normalized } = validatePlayer(p);
|
|
||||||
// Compare normalized to existing for keys we change (rollerInfo, shopInfo, tabInfo)
|
|
||||||
const changed = JSON.stringify({rollerInfo: p.rollerInfo, shopInfo: p.shopInfo, tabInfo: p.tabInfo}) !== JSON.stringify({rollerInfo: normalized.rollerInfo, shopInfo: normalized.shopInfo, tabInfo: normalized.tabInfo});
|
|
||||||
if (changed) {
|
|
||||||
try {
|
|
||||||
playerHelpers.update(p.name, normalized);
|
|
||||||
applied++;
|
|
||||||
details.push({ name: p.name, errors, appliedChanges: true });
|
|
||||||
} catch (err) {
|
|
||||||
details.push({ name: p.name, error: String(err) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`Applied normalization to ${applied} players`);
|
|
||||||
if (details.length) console.log('Details:', JSON.stringify(details, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
apply();
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
|
|
||||||
const armouryPath = path.join(__dirname, '..', '..', 'public', 'deathwatch-armoury.json');
|
|
||||||
const armoury = JSON.parse(fs.readFileSync(armouryPath, 'utf8'));
|
|
||||||
|
|
||||||
function findItemByName(name) {
|
|
||||||
const needle = String(name).toLowerCase();
|
|
||||||
for (const cat of Object.keys(armoury.items)) {
|
|
||||||
const arr = armoury.items[cat];
|
|
||||||
if (Array.isArray(arr)) {
|
|
||||||
let found = arr.find(i => String(i.name).toLowerCase() === needle);
|
|
||||||
if (found) return found;
|
|
||||||
found = arr.find(i => String(i.name).toLowerCase().includes(needle));
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
(function main(){
|
|
||||||
const name = 'andreas';
|
|
||||||
if (playerHelpers.getByName(name)) return console.error('Player already exists', name);
|
|
||||||
|
|
||||||
const charName = 'Brother Lucian';
|
|
||||||
const chapter = 'ultramarines';
|
|
||||||
const speciality = 'apothecary';
|
|
||||||
const rank = '1';
|
|
||||||
|
|
||||||
const characteristics = {
|
|
||||||
ws: 46,
|
|
||||||
bs: 40,
|
|
||||||
s: 41,
|
|
||||||
t: 40,
|
|
||||||
ag: 43,
|
|
||||||
int: 43,
|
|
||||||
per: 41,
|
|
||||||
wp: 42,
|
|
||||||
fel: 43
|
|
||||||
};
|
|
||||||
|
|
||||||
const canonicalSkills = [
|
|
||||||
'Acrobatics (Ag)', 'Awareness (Per)', 'Charm (Fel)', 'Climb (S)', 'Command (Fel)',
|
|
||||||
'Common Lore (Int)', 'Deathwatch', 'Imperium', 'War', 'Dodge (Ag)',
|
|
||||||
'Forbidden Lore (Int)', 'Xenos', 'Intimidate (S)', 'Literacy (Int)', 'Medicae (Int)',
|
|
||||||
'Navigation (Int)', 'Pilot (Ag)', 'Scholastic Lore (Int)', 'Codex Astartes',
|
|
||||||
'Scrutiny (Per)', 'Search (Per)', 'Silent Move (Ag)', 'Speak Language (Int)',
|
|
||||||
'High Gothic', 'Low Gothic', 'Survival (Int)', 'Tactics (Int)', 'Tracking (Int)',
|
|
||||||
'Tech-Use (Int)'
|
|
||||||
];
|
|
||||||
|
|
||||||
const skillMap = {};
|
|
||||||
for (const s of canonicalSkills) skillMap[s] = { trained: false, plus10: false, plus20: false };
|
|
||||||
// from the sheet: Common Lore, Medicae, Scholastic Lore, Awareness, Dodge maybe
|
|
||||||
['Common Lore (Int)', 'Medicae (Int)', 'Scholastic Lore (Int)', 'Awareness (Per)', 'Dodge (Ag)'].forEach(s => {
|
|
||||||
if (!skillMap[s]) skillMap[s] = { trained: true, plus10: false, plus20: false };
|
|
||||||
else skillMap[s].trained = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// weapons and gear from sheet: Mark VII power armour, chainsword, bolt pistol, 3 frag, 3 krak, combat knife
|
|
||||||
const weapons = [];
|
|
||||||
const wpNames = ['Chainsword', 'Astartes Bolt Pistol'];
|
|
||||||
for (const wn of wpNames) {
|
|
||||||
const item = findItemByName(wn);
|
|
||||||
if (item) {
|
|
||||||
const stats = item.stats || {};
|
|
||||||
const dmg = stats.damage || '';
|
|
||||||
const rangeMatch = dmg.match(/Range\s*([^;]+)/i);
|
|
||||||
const penMatch = dmg.match(/Pen\s*(\d+)/i);
|
|
||||||
const clipMatch = dmg.match(/Clip\s*(\d+)/i);
|
|
||||||
const rldMatch = dmg.match(/Reload\s*([^;]+)/i);
|
|
||||||
weapons.push({
|
|
||||||
name: item.name,
|
|
||||||
class: stats.class || '',
|
|
||||||
damage: dmg,
|
|
||||||
type: item.category || '',
|
|
||||||
pen: penMatch ? penMatch[1] : '',
|
|
||||||
range: rangeMatch ? rangeMatch[1].trim() : '',
|
|
||||||
rof: '',
|
|
||||||
clip: clipMatch ? clipMatch[1] : '',
|
|
||||||
rld: rldMatch ? rldMatch[1].trim() : '',
|
|
||||||
special: stats.source || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const frag = findItemByName('Astartes Frag Grenade') || { name: 'Frag Grenade' };
|
|
||||||
const krak = findItemByName('Astartes Krak Grenade') || { name: 'Krak Grenade' };
|
|
||||||
const inventory = [{ name: frag.name, count: 3 }, { name: krak.name, count: 3 }];
|
|
||||||
const gear = [{ name: frag.name, qty: 3 }, { name: krak.name, qty: 3 }];
|
|
||||||
const chains = findItemByName('Chainsword') || { name: 'Chainsword' };
|
|
||||||
gear.push({ name: chains.name, qty: 1 });
|
|
||||||
const powerArmourLookup = findItemByName('Mark VII Power Armour') || findItemByName('Astartes Power Armour');
|
|
||||||
gear.push({ name: powerArmourLookup ? powerArmourLookup.name : 'Mark VII Power Armour', qty: 1 });
|
|
||||||
|
|
||||||
const armourItem = powerArmourLookup;
|
|
||||||
const armour = armourItem ? {
|
|
||||||
body: armourItem.name,
|
|
||||||
ap: (armourItem.stats && armourItem.stats.protection && (armourItem.stats.protection.body || armourItem.stats.protection.total)) ? (armourItem.stats.protection.body || armourItem.stats.protection.total) : 8,
|
|
||||||
head: 0,
|
|
||||||
ra: 8,
|
|
||||||
la: 8,
|
|
||||||
rl: 8,
|
|
||||||
ll: 8
|
|
||||||
} : { body: 'Mark VII Power Armour', ap: 8, head: 0, ra: 8, la: 8, rl: 8, ll: 8 };
|
|
||||||
|
|
||||||
const talents = ['Enhance Healing', 'Deathwatch Training'];
|
|
||||||
const fate = { total: 2, current: 2 };
|
|
||||||
const wounds = { total: 19, current: 19, fatigue: 0 };
|
|
||||||
const movement = { half: 5, full: 10, charge: 15, halfAction: 5, fullAction: 10, run: 30 };
|
|
||||||
|
|
||||||
const tabInfo = {
|
|
||||||
charName,
|
|
||||||
playerName: name,
|
|
||||||
chapter,
|
|
||||||
speciality,
|
|
||||||
rank,
|
|
||||||
characteristics,
|
|
||||||
skills: skillMap,
|
|
||||||
weapons,
|
|
||||||
inventory,
|
|
||||||
gear,
|
|
||||||
armour,
|
|
||||||
talents,
|
|
||||||
fate,
|
|
||||||
wounds,
|
|
||||||
movement,
|
|
||||||
renown: 'Rank 1',
|
|
||||||
powerArmour: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const created = playerHelpers.create({ name, rollerInfo: {}, shopInfo: {}, tabInfo, pw: '', pwHash: '' });
|
|
||||||
console.log('Created player', name);
|
|
||||||
console.log(JSON.stringify(created, null, 2));
|
|
||||||
})();
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
|
|
||||||
const armouryPath = path.join(__dirname, '..', '..', 'public', 'deathwatch-armoury.json');
|
|
||||||
const armoury = JSON.parse(fs.readFileSync(armouryPath, 'utf8'));
|
|
||||||
|
|
||||||
function findItemByName(name) {
|
|
||||||
const needle = String(name).toLowerCase();
|
|
||||||
for (const cat of Object.keys(armoury.items)) {
|
|
||||||
const arr = armoury.items[cat];
|
|
||||||
if (Array.isArray(arr)) {
|
|
||||||
let found = arr.find(i => String(i.name).toLowerCase() === needle);
|
|
||||||
if (found) return found;
|
|
||||||
found = arr.find(i => String(i.name).toLowerCase().includes(needle));
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
(function main(){
|
|
||||||
const name = 'chris';
|
|
||||||
if (playerHelpers.getByName(name)) return console.error('Player already exists', name);
|
|
||||||
|
|
||||||
// Template for a Rank 1 Techmarine (inspired by Deathwatch rules and existing pregens)
|
|
||||||
const charName = 'Brother Corvin';
|
|
||||||
const chapter = 'iron hands';
|
|
||||||
const speciality = 'techmarine';
|
|
||||||
const rank = '1';
|
|
||||||
|
|
||||||
const characteristics = {
|
|
||||||
ws: 44,
|
|
||||||
bs: 40,
|
|
||||||
s: 42,
|
|
||||||
t: 41,
|
|
||||||
ag: 39,
|
|
||||||
int: 46,
|
|
||||||
per: 42,
|
|
||||||
wp: 43,
|
|
||||||
fel: 38
|
|
||||||
};
|
|
||||||
|
|
||||||
const canonicalSkills = [
|
|
||||||
'Acrobatics (Ag)', 'Awareness (Per)', 'Charm (Fel)', 'Climb (S)', 'Command (Fel)',
|
|
||||||
'Common Lore (Int)', 'Deathwatch', 'Imperium', 'War', 'Dodge (Ag)',
|
|
||||||
'Forbidden Lore (Int)', 'Xenos', 'Intimidate (S)', 'Literacy (Int)', 'Medicae (Int)',
|
|
||||||
'Navigation (Int)', 'Pilot (Ag)', 'Scholastic Lore (Int)', 'Codex Astartes',
|
|
||||||
'Scrutiny (Per)', 'Search (Per)', 'Silent Move (Ag)', 'Speak Language (Int)',
|
|
||||||
'High Gothic', 'Low Gothic', 'Survival (Int)', 'Tactics (Int)', 'Tracking (Int)',
|
|
||||||
'Tech-Use (Int)'
|
|
||||||
];
|
|
||||||
|
|
||||||
const skillMap = {};
|
|
||||||
for (const s of canonicalSkills) skillMap[s] = { trained: false, plus10: false, plus20: false };
|
|
||||||
|
|
||||||
// Techmarine-focused trained skills
|
|
||||||
['Tech-Use (Int)', 'Scholastic Lore (Int)', 'Common Lore (Int)', 'Tactics (Int)', 'Awareness (Per)', 'Guns (BS)', 'Melee (WS)'].forEach(s => {
|
|
||||||
if (!skillMap[s]) skillMap[s] = { trained: true, plus10: false, plus20: false };
|
|
||||||
else skillMap[s].trained = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Weapons: prefer Astartes Bolter and Bolt Pistol; include Chainsword as backup
|
|
||||||
const weapons = [];
|
|
||||||
const wpNames = ['Astartes Bolter', 'Astartes Bolt Pistol', 'Chainsword'];
|
|
||||||
for (const wn of wpNames) {
|
|
||||||
const item = findItemByName(wn);
|
|
||||||
if (item) {
|
|
||||||
const stats = item.stats || {};
|
|
||||||
const dmg = stats.damage || '';
|
|
||||||
const rangeMatch = dmg.match(/Range\s*([^;]+)/i);
|
|
||||||
const penMatch = dmg.match(/Pen\s*(\d+)/i);
|
|
||||||
const clipMatch = dmg.match(/Clip\s*(\d+)/i);
|
|
||||||
const rldMatch = dmg.match(/Reload\s*([^;]+)/i);
|
|
||||||
weapons.push({
|
|
||||||
name: item.name,
|
|
||||||
class: stats.class || '',
|
|
||||||
damage: dmg,
|
|
||||||
type: item.category || '',
|
|
||||||
pen: penMatch ? penMatch[1] : '',
|
|
||||||
range: rangeMatch ? rangeMatch[1].trim() : '',
|
|
||||||
rof: '',
|
|
||||||
clip: clipMatch ? clipMatch[1] : '',
|
|
||||||
rld: rldMatch ? rldMatch[1].trim() : '',
|
|
||||||
special: stats.source || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const frag = findItemByName('Astartes Frag Grenade') || { name: 'Astartes Frag Grenade' };
|
|
||||||
const krak = findItemByName('Astartes Krak Grenade') || { name: 'Astartes Krak Grenade' };
|
|
||||||
const combatKnife = findItemByName('Astartes Combat Knife') || { name: 'Astartes Combat Knife' };
|
|
||||||
|
|
||||||
const inventory = [{ name: frag.name, count: 3 }, { name: krak.name, count: 3 }];
|
|
||||||
const gear = [{ name: frag.name, qty: 3 }, { name: krak.name, qty: 3 }, { name: combatKnife.name, qty: 1 }];
|
|
||||||
const powerArmourLookup = findItemByName('Mark VII Power Armour') || findItemByName('Astartes Power Armour');
|
|
||||||
gear.push({ name: powerArmourLookup ? powerArmourLookup.name : 'Mark VII Power Armour', qty: 1 });
|
|
||||||
|
|
||||||
const armourItem = powerArmourLookup;
|
|
||||||
const armour = armourItem ? {
|
|
||||||
body: armourItem.name,
|
|
||||||
ap: (armourItem.stats && armourItem.stats.protection && (armourItem.stats.protection.body || armourItem.stats.protection.total)) ? (armourItem.stats.protection.body || armourItem.stats.protection.total) : 8,
|
|
||||||
head: 0,
|
|
||||||
ra: 8,
|
|
||||||
la: 8,
|
|
||||||
rl: 8,
|
|
||||||
ll: 8
|
|
||||||
} : { body: 'Mark VII Power Armour', ap: 8, head: 0, ra: 8, la: 8, rl: 8, ll: 8 };
|
|
||||||
|
|
||||||
const talents = ['Tech-Use Specialist', 'Deathwatch Training'];
|
|
||||||
const fate = { total: 2, current: 2 };
|
|
||||||
const wounds = { total: 22, current: 22, fatigue: 0 };
|
|
||||||
const movement = { half: 5, full: 10, charge: 15, halfAction: 5, fullAction: 10, run: 30 };
|
|
||||||
|
|
||||||
const tabInfo = {
|
|
||||||
charName,
|
|
||||||
playerName: name,
|
|
||||||
chapter,
|
|
||||||
speciality,
|
|
||||||
rank,
|
|
||||||
characteristics,
|
|
||||||
skills: skillMap,
|
|
||||||
weapons,
|
|
||||||
inventory,
|
|
||||||
gear,
|
|
||||||
armour,
|
|
||||||
talents,
|
|
||||||
fate,
|
|
||||||
wounds,
|
|
||||||
movement,
|
|
||||||
renown: 'Rank 1',
|
|
||||||
powerArmour: true
|
|
||||||
};
|
|
||||||
|
|
||||||
// create with backup
|
|
||||||
const ts = Date.now();
|
|
||||||
const backupsDir = path.join(__dirname, '..', 'backups');
|
|
||||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
|
||||||
const beforePath = path.join(backupsDir, `${name}.before.${ts}.json`);
|
|
||||||
fs.writeFileSync(beforePath, JSON.stringify({ note: 'creating new player', name }, null, 2), 'utf8');
|
|
||||||
|
|
||||||
const created = playerHelpers.create({ name, rollerInfo: {}, shopInfo: {}, tabInfo, pw: '', pwHash: '' });
|
|
||||||
const afterPath = path.join(backupsDir, `${name}.after.${ts}.json`);
|
|
||||||
fs.writeFileSync(afterPath, JSON.stringify(created, null, 2), 'utf8');
|
|
||||||
console.log('Created player', name);
|
|
||||||
console.log(JSON.stringify(created, null, 2));
|
|
||||||
})();
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
function backupDb() {
|
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g,'');
|
|
||||||
const src = path.join(__dirname, '..', 'sqlite', 'deathwatch.db');
|
|
||||||
const dst = path.join(__dirname, '..', 'sqlite', `deathwatch.db.pre_delete_tests.${ts}.bak`);
|
|
||||||
if (fs.existsSync(src)) {
|
|
||||||
fs.copyFileSync(src, dst);
|
|
||||||
console.log('Backup created:', dst);
|
|
||||||
return dst;
|
|
||||||
}
|
|
||||||
console.log('No sqlite DB found at', src);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTestName(name) {
|
|
||||||
if (!name) return false;
|
|
||||||
return /test|dummy|sample|example|dev/i.test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
const backup = backupDb();
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
const toDelete = players.filter(p => isTestName(p.name));
|
|
||||||
console.log('Found', toDelete.length, 'test-like players');
|
|
||||||
const deleted = [];
|
|
||||||
for (const p of toDelete) {
|
|
||||||
try {
|
|
||||||
const ok = playerHelpers.delete(p.name);
|
|
||||||
if (ok) deleted.push(p.name);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete', p.name, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('Deleted', deleted.length, 'players');
|
|
||||||
if (backup) console.log('DB backup at', backup);
|
|
||||||
if (deleted.length) console.log('Deleted names:', deleted.join(', '));
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const name = 'christoffer';
|
|
||||||
const player = playerHelpers.getByName(name);
|
|
||||||
if (!player) {
|
|
||||||
console.error('Player not found:', name);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupsDir = path.join(__dirname, '..', 'backups');
|
|
||||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
|
||||||
|
|
||||||
const beforePath = path.join(backupsDir, 'christoffer.before.json');
|
|
||||||
fs.writeFileSync(beforePath, JSON.stringify(player, null, 2));
|
|
||||||
console.log('Wrote', beforePath);
|
|
||||||
|
|
||||||
// Dedupe gear by name (sum qty)
|
|
||||||
const tab = player.tabInfo || {};
|
|
||||||
const gear = Array.isArray(tab.gear) ? tab.gear : [];
|
|
||||||
const map = new Map();
|
|
||||||
for (const g of gear) {
|
|
||||||
const key = String(g.name || g).trim();
|
|
||||||
if (!key) continue;
|
|
||||||
const existing = map.get(key.toLowerCase());
|
|
||||||
const qty = parseInt((g.qty !== undefined && g.qty !== null) ? g.qty : (g.count !== undefined ? g.count : 1), 10) || 1;
|
|
||||||
if (existing) {
|
|
||||||
existing.qty = (existing.qty || 0) + qty;
|
|
||||||
} else {
|
|
||||||
map.set(key.toLowerCase(), { name: key, qty });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dedupedGear = Array.from(map.values());
|
|
||||||
|
|
||||||
// Also dedupe inventory by name (sum counts)
|
|
||||||
const inventory = Array.isArray(tab.inventory) ? tab.inventory : [];
|
|
||||||
const invMap = new Map();
|
|
||||||
for (const it of inventory) {
|
|
||||||
const key = String(it.name || it).trim();
|
|
||||||
if (!key) continue;
|
|
||||||
const cnt = parseInt(it.count || 0, 10) || 0;
|
|
||||||
const existing = invMap.get(key.toLowerCase());
|
|
||||||
if (existing) existing.count += cnt; else invMap.set(key.toLowerCase(), { name: key, count: cnt });
|
|
||||||
}
|
|
||||||
const dedupedInventory = Array.from(invMap.values());
|
|
||||||
|
|
||||||
// Update player with deduped lists
|
|
||||||
const newTab = Object.assign({}, tab, { gear: dedupedGear, inventory: dedupedInventory });
|
|
||||||
const ok = playerHelpers.update(name, { name, rollerInfo: player.rollerInfo || {}, shopInfo: player.shopInfo || {}, tabInfo: newTab, pw: player.pw || '', pwHash: player.pwHash || '' });
|
|
||||||
if (!ok) {
|
|
||||||
console.error('Failed to update player');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = playerHelpers.getByName(name);
|
|
||||||
const afterPath = path.join(backupsDir, 'christoffer.after.json');
|
|
||||||
fs.writeFileSync(afterPath, JSON.stringify(updated, null, 2));
|
|
||||||
console.log('Wrote', afterPath);
|
|
||||||
|
|
||||||
// Create a simple HTML snapshot
|
|
||||||
const html = generateHtmlSnapshot(updated);
|
|
||||||
const htmlPath = path.join(backupsDir, 'christoffer.html');
|
|
||||||
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
||||||
console.log('Wrote', htmlPath);
|
|
||||||
|
|
||||||
// Try to take a PNG screenshot using puppeteer if available
|
|
||||||
try {
|
|
||||||
const puppeteer = require('puppeteer');
|
|
||||||
const browser = await puppeteer.launch({ args: ['--no-sandbox','--disable-setuid-sandbox'] });
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.goto('file://' + htmlPath);
|
|
||||||
const pngPath = path.join(backupsDir, 'christoffer.png');
|
|
||||||
await page.screenshot({ path: pngPath, fullPage: true });
|
|
||||||
await browser.close();
|
|
||||||
console.log('Wrote', pngPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Puppeteer not available or failed to run; skip PNG. Error:', err.message);
|
|
||||||
console.log('You can install puppeteer and re-run this script to generate a PNG.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateHtmlSnapshot(player) {
|
|
||||||
const t = player.tabInfo || {};
|
|
||||||
const safe = v => (v === undefined || v === null) ? '' : String(v);
|
|
||||||
const char = t.characteristics || {};
|
|
||||||
const weapons = Array.isArray(t.weapons) ? t.weapons : [];
|
|
||||||
const gear = Array.isArray(t.gear) ? t.gear : [];
|
|
||||||
const inventory = Array.isArray(t.inventory) ? t.inventory : [];
|
|
||||||
return `<!doctype html>
|
|
||||||
<html><head><meta charset="utf-8"><title>${safe(player.name)} - snapshot</title>
|
|
||||||
<style>body{font-family:Arial,Helvetica,sans-serif;color:#111;background:#fff;padding:20px}h1{font-size:20px}table{border-collapse:collapse;margin-bottom:12px}td,th{border:1px solid #ccc;padding:6px}</style>
|
|
||||||
</head><body>
|
|
||||||
<h1>${escapeHtml(safe(t.charName || player.name))} (${escapeHtml(safe(player.name))})</h1>
|
|
||||||
<h2>Characteristics</h2>
|
|
||||||
<table><tr>${Object.keys(char).map(k=>`<th>${escapeHtml(k)}</th>`).join('')}</tr>
|
|
||||||
<tr>${Object.keys(char).map(k=>`<td>${escapeHtml(safe(char[k]))}</td>`).join('')}</tr></table>
|
|
||||||
<h2>Weapons</h2>
|
|
||||||
${weapons.map(w=>`<div><strong>${escapeHtml(safe(w.name))}</strong> — ${escapeHtml(safe(w.damage||w.special||''))}</div>`).join('')}
|
|
||||||
<h2>Gear</h2>
|
|
||||||
<ul>${gear.map(g=>`<li>${escapeHtml(safe(g.name))} x${escapeHtml(safe(g.qty||g.count||1))}</li>`).join('')}</ul>
|
|
||||||
<h2>Inventory</h2>
|
|
||||||
<ul>${inventory.map(i=>`<li>${escapeHtml(safe(i.name))} x${escapeHtml(safe(i.count||0))}</li>`).join('')}</ul>
|
|
||||||
<h2>Fate / Wounds</h2>
|
|
||||||
<div>Fate: ${escapeHtml(safe((t.fate||{}).total))} (current ${escapeHtml(safe((t.fate||{}).current))})</div>
|
|
||||||
<div>Wounds: ${escapeHtml(safe((t.wounds||{}).total))} (current ${escapeHtml(safe((t.wounds||{}).current))})</div>
|
|
||||||
<h2>Talents</h2>
|
|
||||||
<div>${escapeHtml((t.talents||[]).join(', '))}</div>
|
|
||||||
</body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
||||||
|
|
||||||
main().catch(err=>{ console.error(err); process.exit(1); });
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
const outPath = path.join(__dirname, '..', 'backups');
|
|
||||||
if (!fs.existsSync(outPath)) fs.mkdirSync(outPath, { recursive: true });
|
|
||||||
const file = path.join(outPath, `players-after-clean.${new Date().toISOString().replace(/[:.]/g,'')}.json`);
|
|
||||||
fs.writeFileSync(file, JSON.stringify(players, null, 2), 'utf8');
|
|
||||||
console.log('Exported players to', file);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
let changed = 0;
|
|
||||||
for (const p of players) {
|
|
||||||
try {
|
|
||||||
if (p.pw && p.pw.length > 0) {
|
|
||||||
const hash = await bcrypt.hash(p.pw, 10);
|
|
||||||
playerHelpers.update(p.name, { rollerInfo: p.rollerInfo, shopInfo: p.shopInfo, tabInfo: p.tabInfo, pw: '', pwHash: hash });
|
|
||||||
changed++;
|
|
||||||
console.log('Hashed pw for:', p.name);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to hash for', p.name, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('Completed hashing. Total changed:', changed);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err=>console.error(err));
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
console.log(JSON.stringify(players.map(u=>({name:u.name, pw: !!u.pw, hasPwHash: !!u.pwHash, tabInfoKeys: Object.keys(u.tabInfo||{}).length})), null, 2));
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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 });
|
|
||||||
|
|
||||||
function timestamp() { return new Date().toISOString().replace(/[:.]/g,''); }
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const doApply = args.includes('--apply');
|
|
||||||
|
|
||||||
(async function main(){
|
|
||||||
console.log('Starting migration: apply defaults to existing players');
|
|
||||||
|
|
||||||
// backup DB first if applying
|
|
||||||
if (doApply) {
|
|
||||||
const bak = path.join(dbDir, `deathwatch.db.pre_migrate.${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 { valid, errors, normalized } = validatePlayer({ name: p.name, tabInfo: before, rollerInfo: p.rollerInfo, shopInfo: p.shopInfo });
|
|
||||||
const after = normalized.tabInfo;
|
|
||||||
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
||||||
results.push({ name: p.name, changed, before, after, errors });
|
|
||||||
|
|
||||||
if (doApply && changed) {
|
|
||||||
// apply update via playerHelpers.update
|
|
||||||
const updated = playerHelpers.update(p.name, { name: p.name, rollerInfo: p.rollerInfo, shopInfo: p.shopInfo, tabInfo: after, pw: p.pw, pwHash: p.pwHash });
|
|
||||||
if (!updated) console.error('Failed to update', p.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = { applied: !!doApply, timestamp: new Date().toISOString(), results };
|
|
||||||
const outFile = path.join(backupsDir, `migrate_apply_defaults.${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);
|
|
||||||
})();
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
})();
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
|
|
||||||
(function main(){
|
|
||||||
const name = 'phillip';
|
|
||||||
const player = playerHelpers.getByName(name);
|
|
||||||
if (!player) return console.error('Player not found', name);
|
|
||||||
|
|
||||||
const ts = Date.now();
|
|
||||||
const backupsDir = path.join(__dirname, '..', 'backups');
|
|
||||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
|
||||||
const beforePath = path.join(backupsDir, `${name}.before.${ts}.json`);
|
|
||||||
fs.writeFileSync(beforePath, JSON.stringify(player, null, 2), 'utf8');
|
|
||||||
console.log('Backup written:', beforePath);
|
|
||||||
|
|
||||||
// Tactical sheet characteristic values (from attached Tactical image)
|
|
||||||
const characteristics = {
|
|
||||||
ws: 42,
|
|
||||||
bs: 42,
|
|
||||||
s: 42,
|
|
||||||
t: 41,
|
|
||||||
ag: 40,
|
|
||||||
int: 41,
|
|
||||||
per: 46,
|
|
||||||
wp: 42,
|
|
||||||
fel: 43
|
|
||||||
};
|
|
||||||
|
|
||||||
const newTab = Object.assign({}, player.tabInfo || {}, { characteristics });
|
|
||||||
const ok = playerHelpers.update(name, { name, rollerInfo: player.rollerInfo || {}, shopInfo: player.shopInfo || {}, tabInfo: newTab, pw: player.pw || '', pwHash: player.pwHash || '' });
|
|
||||||
if (!ok) return console.error('Failed to update player');
|
|
||||||
|
|
||||||
const updated = playerHelpers.getByName(name);
|
|
||||||
const afterPath = path.join(backupsDir, `${name}.after.${ts}.json`);
|
|
||||||
fs.writeFileSync(afterPath, JSON.stringify(updated, null, 2), 'utf8');
|
|
||||||
console.log('After backup written:', afterPath);
|
|
||||||
console.log(JSON.stringify(updated, null, 2));
|
|
||||||
})();
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
const total = players.length;
|
|
||||||
const names = players.map(p=>p.name).sort();
|
|
||||||
|
|
||||||
const renownCounts = {};
|
|
||||||
let rpSum=0, xpSum=0, xpSpentSum=0;
|
|
||||||
let rpMin=Infinity, rpMax=-Infinity, xpMin=Infinity, xpMax=-Infinity;
|
|
||||||
let charStats = {}; // key -> {min,max,sum,count}
|
|
||||||
|
|
||||||
players.forEach(p=>{
|
|
||||||
const t = p.tabInfo || {};
|
|
||||||
const ren = t.renown || 'None';
|
|
||||||
renownCounts[ren] = (renownCounts[ren]||0)+1;
|
|
||||||
const rp = Number(t.rp||0); rpSum+=rp; rpMin=Math.min(rpMin,rp); rpMax=Math.max(rpMax,rp);
|
|
||||||
const xp = Number(t.xp||0); xpSum+=xp; xpMin=Math.min(xpMin,xp); xpMax=Math.max(xpMax,xp);
|
|
||||||
const xps = Number(t.xpSpent||0); xpSpentSum+=xps;
|
|
||||||
const chars = t.characteristics || {};
|
|
||||||
Object.keys(chars).forEach(k=>{
|
|
||||||
const v = Number(chars[k]||0);
|
|
||||||
if (!charStats[k]) charStats[k]={min:Infinity,max:-Infinity,sum:0,count:0};
|
|
||||||
charStats[k].min=Math.min(charStats[k].min,v);
|
|
||||||
charStats[k].max=Math.max(charStats[k].max,v);
|
|
||||||
charStats[k].sum+=v; charStats[k].count++;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = {
|
|
||||||
totalPlayers: total,
|
|
||||||
names,
|
|
||||||
renownCounts,
|
|
||||||
rp: { sum: rpSum, avg: total? rpSum/total:0, min: rpMin===Infinity?0:rpMin, max: rpMax===-Infinity?0:rpMax },
|
|
||||||
xp: { sum: xpSum, avg: total? xpSum/total:0, min: xpMin===Infinity?0:xpMin, max: xpMax===-Infinity?0:xpMax },
|
|
||||||
xpSpent: { sum: xpSpentSum, avg: total? xpSpentSum/total:0 },
|
|
||||||
characteristics: Object.fromEntries(Object.entries(charStats).map(([k,v])=>[k,{min:v.min===Infinity?0:v.min,max:v.max===-Infinity?0:v.max,avg:v.count? v.sum/v.count:0,count:v.count}])),
|
|
||||||
missingPwHash: players.filter(p=>!p.pwHash||p.pwHash.length===0).map(p=>p.name),
|
|
||||||
samplePlayers: players.slice(0,5).map(p=>({name:p.name, tabInfo:p.tabInfo, rollerInfoKeys: Object.keys(p.rollerInfo||{}).length}))
|
|
||||||
};
|
|
||||||
|
|
||||||
const outDir = path.join(__dirname,'..','backups'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir,{recursive:true});
|
|
||||||
const fname = `db-summary.${new Date().toISOString().replace(/[:.]/g,'')}.json`;
|
|
||||||
const outPath = path.join(outDir,fname);
|
|
||||||
fs.writeFileSync(outPath, JSON.stringify(summary,null,2),'utf8');
|
|
||||||
console.log('Wrote summary to', outPath);
|
|
||||||
console.log('Summary:', JSON.stringify(summary, null, 2));
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const { playerHelpers } = require('../sqlite-db');
|
|
||||||
|
|
||||||
(async function main(){
|
|
||||||
const names = ['andreas','chris'];
|
|
||||||
const backupsDir = path.join(__dirname, '..', 'backups');
|
|
||||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
|
||||||
const ts = Date.now();
|
|
||||||
for (const name of names) {
|
|
||||||
const player = playerHelpers.getByName(name);
|
|
||||||
if (!player) {
|
|
||||||
console.log('Player not found, skipping:', name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const beforePath = path.join(backupsDir, `${name}.pw.before.${ts}.json`);
|
|
||||||
fs.writeFileSync(beforePath, JSON.stringify(player, null, 2), 'utf8');
|
|
||||||
console.log('Backup written:', beforePath);
|
|
||||||
|
|
||||||
const plain = process.env.PLAYER_PASSWORD || 'defaultpassword';
|
|
||||||
const hash = await bcrypt.hash(plain, 10);
|
|
||||||
const ok = playerHelpers.update(name, { name, rollerInfo: player.rollerInfo || {}, shopInfo: player.shopInfo || {}, tabInfo: player.tabInfo || {}, pw: '', pwHash: hash });
|
|
||||||
if (!ok) {
|
|
||||||
console.error('Failed to update pw for', name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const updated = playerHelpers.getByName(name);
|
|
||||||
const afterPath = path.join(backupsDir, `${name}.pw.after.${ts}.json`);
|
|
||||||
fs.writeFileSync(afterPath, JSON.stringify(updated, null, 2), 'utf8');
|
|
||||||
console.log('Updated player:', name, 'pwHash set. After backup:', afterPath);
|
|
||||||
console.log(JSON.stringify({ name: updated.name, pwHashPresent: !!updated.pwHash, _id: updated._id }, null, 2));
|
|
||||||
}
|
|
||||||
console.log('All done. Password for andreas and chris set to environment default (hashed).');
|
|
||||||
})();
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const { db, playerHelpers } = require('./sqlite-db');
|
|
||||||
|
|
||||||
// Update all player passwords to environment default
|
|
||||||
function updateAllPasswords() {
|
|
||||||
try {
|
|
||||||
// First check the table structure
|
|
||||||
const columns = db.prepare("PRAGMA table_info(players)").all();
|
|
||||||
console.log('Table columns:', columns.map(c => c.name));
|
|
||||||
|
|
||||||
// Update only the pw column (pwHash might not exist)
|
|
||||||
const defaultPassword = process.env.PLAYER_PASSWORD || 'defaultpassword';
|
|
||||||
const updateStmt = db.prepare('UPDATE players SET pw = ?');
|
|
||||||
const result = updateStmt.run(defaultPassword);
|
|
||||||
|
|
||||||
console.log(`Updated ${result.changes} player passwords to environment default`);
|
|
||||||
|
|
||||||
// Verify the changes
|
|
||||||
const players = playerHelpers.getAll();
|
|
||||||
console.log('Current players:');
|
|
||||||
players.forEach(player => {
|
|
||||||
console.log(`- ${player.name}: pw="${player.pw}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating passwords:', error);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAllPasswords();
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const https = require('https');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { db } = require('../database/sqlite-db');
|
|
||||||
|
|
||||||
function slugify(s) {
|
|
||||||
return String(s || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[^\x00-\x7F]/g, '') // strip non-ascii
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.trim()
|
|
||||||
.replace(/[-\s]+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchUrl(url) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
https.get(url, { headers: { 'User-Agent': 'dwroller-bot/1.0' } }, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', (c) => data += c);
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractContentFromFandom(html) {
|
|
||||||
// extract content inside mw-parser-output; fall back to whole html
|
|
||||||
const out = { paragraphs: [], headings: [], sourceLines: [] };
|
|
||||||
const m = html.match(/<div[^>]+class="mw-parser-output"[^>]*>([\s\S]*?)<div class="printfooter">/i);
|
|
||||||
const block = m ? m[1] : html;
|
|
||||||
|
|
||||||
// helper to push cleaned text and deduplicate
|
|
||||||
const pushText = (txt) => {
|
|
||||||
if (!txt) return;
|
|
||||||
let clean = txt.replace(/<[^>]+>/g, '')
|
|
||||||
.replace(/\[\d+\]/g, '')
|
|
||||||
.replace(/\s+/g, ' ').trim();
|
|
||||||
if (!clean) return;
|
|
||||||
// detect source lines like 'Source: ...'
|
|
||||||
if (/^Source[:\s]/i.test(clean)) {
|
|
||||||
out.sourceLines.push(clean);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out.paragraphs.push(clean);
|
|
||||||
};
|
|
||||||
|
|
||||||
// paragraphs
|
|
||||||
const paraRe = /<p[^>]*>([\s\S]*?)<\/p>/ig;
|
|
||||||
let p;
|
|
||||||
while ((p = paraRe.exec(block)) !== null) pushText(p[1]);
|
|
||||||
|
|
||||||
// fallback: list items
|
|
||||||
if (out.paragraphs.length < 2) {
|
|
||||||
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/ig;
|
|
||||||
while ((p = liRe.exec(block)) !== null) pushText(p[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback: definition descriptions
|
|
||||||
if (out.paragraphs.length < 2) {
|
|
||||||
const ddRe = /<dd[^>]*>([\s\S]*?)<\/dd>/ig;
|
|
||||||
while ((p = ddRe.exec(block)) !== null) pushText(p[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback: table cells
|
|
||||||
if (out.paragraphs.length < 2) {
|
|
||||||
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/ig;
|
|
||||||
while ((p = tdRe.exec(block)) !== null) pushText(p[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// headings (h2/h3)
|
|
||||||
const hRe = /<h[2-3][^>]*>([\s\S]*?)<\/h[2-3]>/ig;
|
|
||||||
let h;
|
|
||||||
while ((h = hRe.exec(block)) !== null) {
|
|
||||||
const ht = h[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
||||||
if (ht.length) out.headings.push(ht);
|
|
||||||
}
|
|
||||||
|
|
||||||
// filter out common wiki/header/footer noise and very short items
|
|
||||||
const noiseRe = /(?:Explore|Skip to content|Advertisement|History|Main Page|Discuss|Community|Interactive Maps|Recently Changed|Explore More|All Pages|Pages|Recent Blog Posts|Recently Changed Pages|Explore Main Page)/i;
|
|
||||||
out.paragraphs = out.paragraphs.filter(p => {
|
|
||||||
if (noiseRe.test(p)) return false;
|
|
||||||
if (p.length < 30) return false; // require a bit more substance
|
|
||||||
if (/^\s*\w+(\s+\w+){0,2}\s*$/.test(p) && p.split(' ').length <= 3) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// final dedupe
|
|
||||||
out.paragraphs = Array.from(new Set(out.paragraphs));
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findUseText(paragraphs) {
|
|
||||||
for (const p of paragraphs) {
|
|
||||||
if (/^(Use|Usage)[:\s]/i.test(p) || /\bUse[:\s]/i.test(p)) return p;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeText(s) {
|
|
||||||
if (!s) return '';
|
|
||||||
let t = String(s);
|
|
||||||
// remove common wiki boilerplate and nav strings
|
|
||||||
t = t.replace(/Explore More/ig, '');
|
|
||||||
t = t.replace(/Skip to content/ig, '');
|
|
||||||
t = t.replace(/40k-?RPG-?FFG Wiki/ig, '');
|
|
||||||
t = t.replace(/Explore Main Page/ig, '');
|
|
||||||
// remove category/maintenance lines
|
|
||||||
t = t.replace(/^(Category:|Special:|Local sitemap).*/gi, '');
|
|
||||||
// remove trailing references like [1]
|
|
||||||
t = t.replace(/\[\d+\]/g, '');
|
|
||||||
// collapse whitespace
|
|
||||||
t = t.replace(/\s+/g, ' ').trim();
|
|
||||||
// strip leading 'From the' boilerplate
|
|
||||||
t = t.replace(/^This (article|page) .*/i, '');
|
|
||||||
return t.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCategoryMembers(categoryUrl) {
|
|
||||||
console.log('Fetching category members from', categoryUrl);
|
|
||||||
try {
|
|
||||||
const res = await fetchUrl(categoryUrl);
|
|
||||||
if (res.status !== 200) {
|
|
||||||
console.warn('Failed to fetch category page', categoryUrl, 'status', res.status);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const html = res.body;
|
|
||||||
// simple extraction of /wiki/Title links from the category members block
|
|
||||||
const members = new Set();
|
|
||||||
// try the modern category page members container
|
|
||||||
const blockMatch = html.match(/<div[^>]+class="category-page__members"[^>]*>([\s\S]*?)<\/div>/i);
|
|
||||||
const block = blockMatch ? blockMatch[1] : html;
|
|
||||||
const linkRe = /href="([^"]+\/wiki\/([^"#?]+))"/ig;
|
|
||||||
let m;
|
|
||||||
while ((m = linkRe.exec(block)) !== null) {
|
|
||||||
try {
|
|
||||||
const href = decodeURIComponent(m[2].replace(/_/g, ' '));
|
|
||||||
// ignore files and special pages
|
|
||||||
if (/^File:/i.test(href) || /^Special:/i.test(href)) continue;
|
|
||||||
members.add(href);
|
|
||||||
} catch (e) { /* ignore decode errors */ }
|
|
||||||
}
|
|
||||||
return Array.from(members);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error fetching category members', e && e.message ? e.message : e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API fallback: query MediaWiki for category members if scraping yields nothing
|
|
||||||
async function fetchCategoryMembersApiFallback(categoryUrl) {
|
|
||||||
try {
|
|
||||||
const m = categoryUrl.match(/Category:([^?#/]+)/i);
|
|
||||||
if (!m) return [];
|
|
||||||
const category = decodeURIComponent(m[1]);
|
|
||||||
const apiUrl = `https://40k-rpg-ffg.fandom.com/api.php?action=query&list=categorymembers&cmtitle=Category:${encodeURIComponent(category)}&cmlimit=500&format=json`;
|
|
||||||
console.log('Falling back to API:', apiUrl);
|
|
||||||
const res = await fetchUrl(apiUrl);
|
|
||||||
if (res.status !== 200) {
|
|
||||||
console.warn('API fetch failed', res.status);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
let json;
|
|
||||||
try { json = JSON.parse(res.body); } catch (e) { console.error('Failed to parse API JSON', e && e.message); return []; }
|
|
||||||
const items = (json && json.query && json.query.categorymembers) ? json.query.categorymembers.map(c => c.title).filter(Boolean) : [];
|
|
||||||
// filter out files and special
|
|
||||||
return items.filter(t => !/^File:/i.test(t) && !/^Special:/i.test(t));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('API fallback error', e && e.message ? e.message : e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processList(listPath, mode = 'preview', category = 'skills') {
|
|
||||||
if (!listPath) {
|
|
||||||
console.error('No list path provided');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines;
|
|
||||||
if (/^https?:\/\//i.test(listPath)) {
|
|
||||||
// treat as a category or index URL
|
|
||||||
lines = await fetchCategoryMembers(listPath);
|
|
||||||
if (!lines || lines.length === 0) {
|
|
||||||
// try API fallback for category members
|
|
||||||
lines = await fetchCategoryMembersApiFallback(listPath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!fs.existsSync(listPath)) {
|
|
||||||
console.error('List file not found:', listPath);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
lines = fs.readFileSync(listPath, 'utf8').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Processing', lines.length, 'items (mode:', mode + ', category:', category + ')');
|
|
||||||
|
|
||||||
const existsRules = db.prepare('SELECT COUNT(*) as c FROM rules WHERE rule_id = ?');
|
|
||||||
const existsStaging = db.prepare('SELECT COUNT(*) as c FROM rules_staging WHERE title = ?');
|
|
||||||
const insertStaging = db.prepare('INSERT INTO rules_staging (title, content, category, page, original_json) VALUES (?, ?, ?, ?, ?)');
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
for (const rawName of lines) {
|
|
||||||
try {
|
|
||||||
const title = rawName;
|
|
||||||
const rule_id = slugify(title);
|
|
||||||
|
|
||||||
// skip if already in rules
|
|
||||||
if (existsRules.get(rule_id).c) { console.log('Already in rules, skipping:', title); continue; }
|
|
||||||
if (existsStaging.get(title).c) { console.log('Already in staging, skipping:', title); continue; }
|
|
||||||
|
|
||||||
const urlTitle = encodeURIComponent(title.replace(/ /g, '_'));
|
|
||||||
const url = `https://40k-rpg-ffg.fandom.com/wiki/${urlTitle}`;
|
|
||||||
console.log('Fetching', title);
|
|
||||||
const res = await fetchUrl(url);
|
|
||||||
if (res.status !== 200) {
|
|
||||||
console.warn('Failed to fetch', title, 'status', res.status);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentBlock = extractContentFromFandom(res.body);
|
|
||||||
const origParagraphs = contentBlock.paragraphs || [];
|
|
||||||
const paragraphs = origParagraphs.map(p => sanitizeText(p)).filter(Boolean);
|
|
||||||
|
|
||||||
const skill_text = paragraphs.length ? paragraphs[0] : '';
|
|
||||||
const skill_use = findUseText(paragraphs) || '';
|
|
||||||
// remove the use paragraph from description list
|
|
||||||
const descParts = paragraphs.filter(p => p !== skill_text && p !== skill_use);
|
|
||||||
const skill_description = descParts.join('\n\n');
|
|
||||||
|
|
||||||
// Build sanitized content matching DB expectations
|
|
||||||
const content = [skill_text, skill_description, skill_use ? `Use: ${skill_use.replace(/^Use[:\s]*/i,'')}` : '']
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(s => s.trim())
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
const original = { url, source: 'https://40k-rpg-ffg.fandom.com', source_abbr: 'fandom', paragraphs: origParagraphs, extracted_paragraphs: paragraphs, use: skill_use };
|
|
||||||
|
|
||||||
results.push({ rule_id, title, content, page: null, original });
|
|
||||||
|
|
||||||
// polite delay
|
|
||||||
await new Promise(r => setTimeout(r, 250));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error processing', rawName, e && e.stack ? e.stack : e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const previewPath = path.join('/tmp', `fandom_${category}_preview_${ts}.json`);
|
|
||||||
fs.writeFileSync(previewPath, JSON.stringify({ generatedAt: new Date().toISOString(), count: results.length, items: results }, null, 2), 'utf8');
|
|
||||||
console.log('Wrote preview to', previewPath);
|
|
||||||
|
|
||||||
if (mode === 'preview') {
|
|
||||||
console.log('Preview mode: no DB changes. Run with "commit" to stage items into rules_staging.');
|
|
||||||
db.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// commit to staging
|
|
||||||
let inserted = 0;
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const it of results) {
|
|
||||||
try {
|
|
||||||
insertStaging.run(it.title, it.content || '', category, it.page || null, JSON.stringify(it.original || {}));
|
|
||||||
inserted++;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Insert staging failed for', it.title, e && e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const commitPath = path.join('/tmp', `fandom_${category}_committed_${ts}.json`);
|
|
||||||
fs.writeFileSync(commitPath, JSON.stringify({ committedAt: new Date().toISOString(), inserted, items: results.map(r=>({rule_id:r.rule_id,title:r.title})) }, null, 2), 'utf8');
|
|
||||||
console.log('Committed', inserted, 'items to rules_staging. Details in', commitPath);
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
const listFile = process.argv[2] || '/tmp/fandom_only_skills.txt';
|
|
||||||
const mode = (process.argv[3] || 'preview').toLowerCase();
|
|
||||||
const category = (process.argv[4] || 'skills').toLowerCase();
|
|
||||||
if (!['preview','commit'].includes(mode)) { console.error('Mode must be preview or commit'); process.exit(1); }
|
|
||||||
processList(listFile, mode, category).then(() => process.exit(0)).catch(err => { console.error(err); db.close(); process.exit(1); });
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user