- New database/auth.js: shared server-side GM check using session store - database/server.js: crypto-random session generation, server-side session storage, GM auth from session instead of x-gm-secret header - database/routes/playerRoutes.js: GM-only endpoints use server-side auth - database/routes/sessionRoutes.js: session creation/lookup endpoints - database/routes/bestiaryRoutes.js: GM auth from server-side session - database/routes/rulesRoutes.js: GM auth from server-side session - src/App.js: send x-session-id header with API requests - src/components/BestiaryTab.jsx: use session-based GM auth
221 lines
7.4 KiB
JavaScript
221 lines
7.4 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { bestiaryHelpers, logToFile } = require('../mariadb');
|
|
const { requireGM } = require('../auth');
|
|
const router = express.Router();
|
|
|
|
const BESTIARY_FALLBACK_PATH = path.join(__dirname, '..', '..', 'public', 'deathwatch-bestiary-extracted.json');
|
|
|
|
function loadBestiaryFallback() {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(BESTIARY_FALLBACK_PATH, 'utf8'));
|
|
if (Array.isArray(raw)) return raw;
|
|
if (Array.isArray(raw.results)) return raw.results;
|
|
if (Array.isArray(raw.entries)) return raw.entries;
|
|
if (Array.isArray(raw.items)) return raw.items;
|
|
} catch (error) {
|
|
logToFile('Error loading bestiary fallback:', error);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Read bestiary from MariaDB table `bestiary`
|
|
async function loadBestiaryData() {
|
|
try {
|
|
const rows = await bestiaryHelpers.getAll();
|
|
if (!rows.length) return loadBestiaryFallback();
|
|
return rows.map(r => {
|
|
let stats = {};
|
|
try {
|
|
stats = typeof r.stats === 'string' ? JSON.parse(r.stats) : r.stats || {};
|
|
} catch(e){
|
|
logToFile('Error parsing bestiary stats for', r.name, e);
|
|
}
|
|
let profile = {};
|
|
try {
|
|
profile = typeof r.profile === 'string' ? JSON.parse(r.profile) : r.profile || {};
|
|
} catch(e){
|
|
logToFile('Error parsing bestiary profile for', r.name, e);
|
|
}
|
|
return {
|
|
_id: r.id,
|
|
bestiaryName: r.name,
|
|
book: r.book,
|
|
page: r.page,
|
|
pdf: r.pdf,
|
|
stats: stats,
|
|
profile: profile,
|
|
snippet: r.snippet
|
|
};
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to load bestiary from MariaDB:', e);
|
|
logToFile('Error loading bestiary:', e);
|
|
return loadBestiaryFallback();
|
|
}
|
|
}
|
|
|
|
// Transform bestiary entry to dice roller format
|
|
function transformBestiaryEntry(entry) {
|
|
const stats = entry.stats || {};
|
|
const profile = stats.profile || {};
|
|
const isHorde = !!stats.horde || /horde/i.test(String(entry.bestiaryName || entry.name || ''));
|
|
const magnitudeRaw = stats.magnitude ?? entry.magnitude ?? entry.wounds ?? stats.wounds ?? profile.wounds;
|
|
|
|
const parseWeaponDamage = (value) => {
|
|
const text = String(value || '');
|
|
const damage = text.match(/\b\d+d\d+(?:\s*[+-]\s*\d+)?\s*[EIRX]?\b/i);
|
|
const pen = text.match(/\bPen(?:etration)?\s*(\d+)/i);
|
|
return {
|
|
damage: damage ? damage[0].replace(/\s+/g, '') : null,
|
|
pen: pen ? parseInt(pen[1], 10) : null
|
|
};
|
|
};
|
|
|
|
// Calculate toughness bonus (TB = T/10 rounded down)
|
|
const toughness = profile.t || profile.toughness || 0;
|
|
const tb = Number(stats.toughnessBonus) || Math.floor(toughness / 10);
|
|
|
|
// Extract armor from various possible locations
|
|
let armour = 0;
|
|
let armourByLoc = null;
|
|
|
|
if (stats.armour) {
|
|
if (typeof stats.armour === 'number') {
|
|
armour = stats.armour;
|
|
} else if (stats.armour.all !== undefined) {
|
|
armour = Number(stats.armour.all) || 0;
|
|
} else if (stats.armour.body !== undefined) {
|
|
armourByLoc = {
|
|
'Head': stats.armour.head || stats.armour.body || 0,
|
|
'Body': stats.armour.body || 0,
|
|
'Left Arm': stats.armour.leftArm || stats.armour.la || stats.armour.arm || stats.armour.body || 0,
|
|
'Right Arm': stats.armour.rightArm || stats.armour.ra || stats.armour.arm || stats.armour.body || 0,
|
|
'Left Leg': stats.armour.leftLeg || stats.armour.ll || stats.armour.leg || stats.armour.body || 0,
|
|
'Right Leg': stats.armour.rightLeg || stats.armour.rl || stats.armour.leg || stats.armour.body || 0
|
|
};
|
|
}
|
|
}
|
|
|
|
// Extract wounds, coercing strings like "29" or "29 (X)" to a number and
|
|
// falling back to a usable default so the roller's wounds tracker works.
|
|
const woundsRaw = isHorde ? magnitudeRaw : (entry.wounds ?? stats.wounds ?? profile.wounds);
|
|
let wounds = null;
|
|
if (typeof woundsRaw === 'number' && Number.isFinite(woundsRaw)) {
|
|
wounds = woundsRaw;
|
|
} else if (typeof woundsRaw === 'string') {
|
|
const m = woundsRaw.match(/\d+/);
|
|
if (m) wounds = parseInt(m[0], 10);
|
|
}
|
|
if (!Number.isFinite(wounds) || wounds <= 0) wounds = 15;
|
|
|
|
// Extract characteristics for defense
|
|
const ag = profile.ag || profile.agility || null;
|
|
const ws = profile.ws || profile.weaponSkill || null;
|
|
const bs = profile.bs || profile.ballisticSkill || null;
|
|
|
|
const transformed = {
|
|
name: entry.bestiaryName || entry.name || 'Unknown',
|
|
horde: isHorde,
|
|
magnitude: isHorde ? wounds : undefined,
|
|
tb: tb || 4,
|
|
wounds: wounds,
|
|
book: entry.book || '',
|
|
page: entry.page || '',
|
|
// Include characteristics for enemy selection
|
|
ag: ag,
|
|
ws: ws,
|
|
bs: bs
|
|
};
|
|
|
|
const weaponDefaults = parseWeaponDamage(stats.weapons || entry.weapons);
|
|
if (weaponDefaults.damage) transformed.damage = weaponDefaults.damage;
|
|
if (weaponDefaults.pen != null) transformed.pen = weaponDefaults.pen;
|
|
|
|
if (armourByLoc) {
|
|
transformed.armourByLoc = armourByLoc;
|
|
} else {
|
|
transformed.armour = armour;
|
|
}
|
|
|
|
return transformed;
|
|
}
|
|
|
|
// Get all bestiary entries formatted for dice roller
|
|
router.get('/enemies', async (req, res) => {
|
|
try {
|
|
const entries = await loadBestiaryData();
|
|
|
|
// Transform entries for dice roller format
|
|
// Expose the full bestiary to the roller. transformBestiaryEntry supplies
|
|
// sensible fallbacks (TB, wounds) so entries with sparse stats still work.
|
|
const enemies = entries
|
|
.filter(entry => entry && (entry.bestiaryName || entry.name))
|
|
.map(transformBestiaryEntry);
|
|
|
|
console.log(`Returning ${enemies.length} enemies for dice roller`);
|
|
res.json(enemies);
|
|
} catch (error) {
|
|
console.error('Bestiary enemies error:', error);
|
|
logToFile('Error getting bestiary enemies:', error);
|
|
res.status(500).json({ error: 'Failed to get enemies' });
|
|
}
|
|
});
|
|
|
|
// Get full bestiary data (for bestiary tab)
|
|
router.get('/full', async (req, res) => {
|
|
try {
|
|
const entries = await loadBestiaryData();
|
|
res.json(entries);
|
|
} catch (error) {
|
|
console.error('Bestiary full error:', error);
|
|
logToFile('Error getting full bestiary:', error);
|
|
res.status(500).json({ error: 'Failed to get bestiary data' });
|
|
}
|
|
});
|
|
|
|
// Get bestiary statistics
|
|
router.get('/stats', async (req, res) => {
|
|
try {
|
|
const entries = await loadBestiaryData();
|
|
|
|
const stats = {
|
|
totalEntries: entries.length,
|
|
withValidStats: entries.filter(e => e.stats?.profile?.t).length,
|
|
withWounds: entries.filter(e => e.wounds || e.stats?.wounds).length,
|
|
books: [...new Set(entries.map(e => e.book).filter(Boolean))],
|
|
lastUpdated: new Date().toISOString()
|
|
};
|
|
|
|
res.json(stats);
|
|
} catch (error) {
|
|
console.error('Bestiary stats error:', error);
|
|
logToFile('Error getting bestiary stats:', error);
|
|
res.status(500).json({ error: 'Failed to get bestiary stats' });
|
|
}
|
|
});
|
|
|
|
// Force reload bestiary data (GM only)
|
|
router.post('/reload', async (req, res) => {
|
|
try {
|
|
if (!(await requireGM(req, res))) return;
|
|
|
|
// No cache to reset since we query MariaDB directly
|
|
const entries = await loadBestiaryData();
|
|
res.json({
|
|
success: true,
|
|
totalEntries: entries.length,
|
|
message: 'Bestiary data reloaded from MariaDB successfully'
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Bestiary reload error:', error);
|
|
logToFile('Error reloading bestiary:', error);
|
|
res.status(500).json({ error: 'Failed to reload bestiary data' });
|
|
}
|
|
});
|
|
|
|
console.log('Bestiary routes registered');
|
|
module.exports = router;
|