Files
dwroller/database/routes/bestiaryRoutes.js
Alex ad7ce9dc22 Security fix: GM authorized from server-side session via x-session-id header
- 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
2026-07-16 23:03:52 +02:00

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;