- scripts/generate-weapon-images.js: AI image generation for weapon cards supporting HuggingFace FLUX.1-schnell, OpenAI DALL-E 3, and Gemini Imagen; images served from public/weapon-images/ via new static route in server.js - public/cards.html: load manifest.json and show AI art on weapon cards - scripts/scrape-rules-from-pdfs.py: PyMuPDF scraper extracting full rule text from all five rulebook PDFs with bold-span heading detection - scripts/tag-and-dedup-rules.js: standalone dedup helper (JS-side grouping) - database/routes/rulesRoutes.js: remove content truncation in search/random, add admin dedup endpoint (JS-side, fast), fix RulesTab to fetch full content - src/components/RulesTab.jsx: fetch full rule content on modal open - public/print.html: expanded quick reference with pre-gen character stat blocks (Sepheran + Lucian), Astartes traits sheet with correct derived SBs and TBs, full Critical Hit Tables for all 4 locations × 4 damage types Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
const express = require('express');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { rulesHelpers, logToFile } = require('../mariadb');
|
||
const router = express.Router();
|
||
|
||
console.log('Rules routes registered (MariaDB)');
|
||
|
||
// Helpers to clean up OCR/extracted text
|
||
function cleanText(s) {
|
||
if (!s) return s;
|
||
// Replace Windows newlines, collapse multi-whitespace, trim
|
||
let out = s.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n');
|
||
out = out.replace(/[ \t]+/g, ' ');
|
||
out = out.replace(/\n\s+/g, '\n');
|
||
out = out.replace(/\s+\n/g, '\n');
|
||
// Remove weird control characters
|
||
out = out.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F]/g, '');
|
||
return out.trim();
|
||
}
|
||
|
||
function cleanTitle(t) {
|
||
if (!t) return t;
|
||
let s = String(t).trim();
|
||
// If title is all-caps, make sentence case-ish
|
||
if (s === s.toUpperCase() && s.length > 1) {
|
||
s = s.toLowerCase();
|
||
s = s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
// Collapse whitespace
|
||
s = s.replace(/\s+/g, ' ');
|
||
return s;
|
||
}
|
||
|
||
// Fetch all rules from MariaDB
|
||
async function getAllRules() {
|
||
try {
|
||
const rows = await rulesHelpers.getAll();
|
||
return rows.map(r => ({
|
||
id: r.id,
|
||
rule_id: r.rule_id,
|
||
title: cleanTitle(r.title),
|
||
content: cleanText(r.content),
|
||
page: r.page,
|
||
source: r.source,
|
||
sourceAbbr: r.source_abbr,
|
||
category: r.category
|
||
}));
|
||
} catch (e) {
|
||
console.error('Failed to read rules from MariaDB:', e);
|
||
logToFile('Error getting all rules:', e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function getRuleById(ruleId) {
|
||
try {
|
||
const rows = await rulesHelpers.getAll();
|
||
const row = rows.find(r => r.rule_id === ruleId || r.id === ruleId);
|
||
if (!row) return null;
|
||
return {
|
||
id: row.rule_id || row.id,
|
||
title: cleanTitle(row.title),
|
||
content: cleanText(row.content),
|
||
page: row.page,
|
||
source: row.source,
|
||
sourceAbbr: row.source_abbr,
|
||
category: row.category
|
||
};
|
||
} catch (e) {
|
||
console.error('Failed to read rule by id:', e);
|
||
logToFile('Error getting rule by id:', ruleId, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Get all rule categories
|
||
router.get('/categories', async (req, res) => {
|
||
try {
|
||
const rows = await getAllRules();
|
||
const categories = [...new Set(rows.map(r => r.category).filter(Boolean))];
|
||
const categoryList = categories.map(cat => ({ id: cat, name: cleanTitle(cat) }));
|
||
res.json([{ id: 'all', name: 'All Rules' }, ...categoryList]);
|
||
} catch (error) {
|
||
console.error('Categories error:', error);
|
||
res.status(500).json({ error: 'Failed to get categories' });
|
||
}
|
||
});
|
||
|
||
// Search rules (MariaDB)
|
||
router.get('/search', async (req, res) => {
|
||
try {
|
||
const { q: query, category, limit = 20 } = req.query;
|
||
if (!query || !query.trim()) return res.json([]);
|
||
const limitInt = Math.max(1, parseInt(limit) || 20);
|
||
|
||
const allRules = await getAllRules();
|
||
const term = query.toLowerCase();
|
||
|
||
const score = (rule, t) => {
|
||
const title = (rule.title || '').toLowerCase();
|
||
const content = (rule.content || '').toLowerCase();
|
||
const exactTitle = title === t;
|
||
const titleStarts = title.startsWith(t);
|
||
const titleContains = title.includes(t);
|
||
const isCanonical = rule.source === 'Core Rulebook';
|
||
// Higher = shown first
|
||
return (exactTitle ? 100 : 0) +
|
||
(titleStarts ? 50 : 0) +
|
||
(titleContains ? 20 : 0) +
|
||
(isCanonical ? 10 : 0) +
|
||
(content.includes(t) ? 1 : 0);
|
||
};
|
||
|
||
let filtered = allRules.filter(rule => {
|
||
const titleMatch = rule.title && rule.title.toLowerCase().includes(term);
|
||
const contentMatch = rule.content && rule.content.toLowerCase().includes(term);
|
||
const categoryMatch = !category || category === 'all' || rule.category === category;
|
||
return (titleMatch || contentMatch) && categoryMatch;
|
||
});
|
||
|
||
// If category filtering yielded no results, try without category filter
|
||
if (filtered.length === 0 && category && category !== 'all') {
|
||
filtered = allRules.filter(rule => {
|
||
const titleMatch = rule.title && rule.title.toLowerCase().includes(term);
|
||
const contentMatch = rule.content && rule.content.toLowerCase().includes(term);
|
||
return titleMatch || contentMatch;
|
||
});
|
||
}
|
||
|
||
// Sort: title matches first, then Core Rulebook source, then content matches
|
||
filtered.sort((a, b) => score(b, term) - score(a, term));
|
||
|
||
// Limit results (content kept full – modal fetches via /rule/:id anyway)
|
||
const results = filtered.slice(0, limitInt);
|
||
|
||
res.json(results);
|
||
} catch (error) {
|
||
console.error('Search error:', error);
|
||
res.status(500).json({ error: 'Search failed' });
|
||
}
|
||
});
|
||
|
||
// Get a specific rule by ID
|
||
router.get('/rule/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const rule = await getRuleById(id);
|
||
if (!rule) return res.status(404).json({ error: 'Rule not found' });
|
||
res.json(rule);
|
||
} catch (error) {
|
||
console.error('Get rule error:', error);
|
||
res.status(500).json({ error: 'Failed to get rule' });
|
||
}
|
||
});
|
||
|
||
// Get random rules for discovery
|
||
router.get('/random', async (req, res) => {
|
||
try {
|
||
const { count = 5, category } = req.query;
|
||
const max = Math.max(1, parseInt(count) || 5);
|
||
|
||
const allRules = await getAllRules();
|
||
let filtered = allRules;
|
||
|
||
if (category && category !== 'all') {
|
||
filtered = allRules.filter(rule => rule.category === category);
|
||
}
|
||
|
||
// Shuffle and pick random rules
|
||
const shuffled = filtered.sort(() => 0.5 - Math.random());
|
||
const randomRules = shuffled.slice(0, max);
|
||
|
||
res.json(randomRules);
|
||
} catch (error) {
|
||
console.error('Random rules error:', error);
|
||
res.status(500).json({ error: 'Failed to get random rules' });
|
||
}
|
||
});
|
||
|
||
// Get rules statistics
|
||
router.get('/stats', async (req, res) => {
|
||
try {
|
||
const allRules = await getAllRules();
|
||
const totalRules = allRules.length;
|
||
|
||
const categoryCount = {};
|
||
const sourceCount = {};
|
||
|
||
allRules.forEach(rule => {
|
||
if (rule.category) {
|
||
categoryCount[rule.category] = (categoryCount[rule.category] || 0) + 1;
|
||
}
|
||
if (rule.source) {
|
||
sourceCount[rule.source] = (sourceCount[rule.source] || 0) + 1;
|
||
}
|
||
});
|
||
|
||
const categories = Object.entries(categoryCount).map(([category, c]) => ({ category, c }));
|
||
const sources = Object.entries(sourceCount).map(([source, c]) => ({ source, c }));
|
||
|
||
res.json({ totalRules, categories, sources, searchTerms: 0 });
|
||
} catch (error) {
|
||
console.error('Stats error:', error);
|
||
res.status(500).json({ error: 'Failed to get stats' });
|
||
}
|
||
});
|
||
|
||
// Reload the rules database (admin only)
|
||
router.post('/reload', async (req, res) => {
|
||
try {
|
||
const gmSecret = req.headers['x-gm-secret'];
|
||
const expectedGmSecret = process.env.GM_SECRET || 'defaultsecret';
|
||
if (gmSecret !== expectedGmSecret) return res.status(403).json({ error: 'Unauthorized' });
|
||
|
||
const allRules = await getAllRules();
|
||
const totalRules = allRules.length;
|
||
|
||
res.json({ success: true, totalRules, message: 'Rules are MariaDB-backed; already loaded' });
|
||
} catch (error) {
|
||
console.error('Reload error:', error);
|
||
res.status(500).json({ error: 'Failed to reload database' });
|
||
}
|
||
});
|
||
|
||
// ── Dedup rules (GM-only) ─────────────────────────────────────────────────────
|
||
router.post('/admin/tag-and-dedup', async (req, res) => {
|
||
const gmSecret = req.headers['x-gm-secret'];
|
||
if (gmSecret !== (process.env.GM_SECRET || 'defaultsecret'))
|
||
return res.status(403).json({ error: 'Unauthorized' });
|
||
|
||
const { pool } = require('../mariadb');
|
||
|
||
try {
|
||
const results = {};
|
||
|
||
// 1. Re-tag sanitized → Core Rulebook
|
||
const [r1] = await pool.execute(
|
||
`UPDATE rules SET source='Core Rulebook', source_abbr='CR' WHERE source='sanitized'`
|
||
);
|
||
results.retagged_sanitized = r1.affectedRows;
|
||
|
||
// 2. Re-tag fandom/url → Core Rulebook
|
||
const [r2] = await pool.execute(
|
||
`UPDATE rules SET source='Core Rulebook', source_abbr='CR' WHERE source LIKE '%fandom%' OR source LIKE '%40k-rpg%'`
|
||
);
|
||
results.retagged_fandom = r2.affectedRows;
|
||
|
||
// 3. Dedup in JS — load titles+ids, group by normalised title, delete inferior copies
|
||
const SOURCE_PRIORITY = ['Core Rulebook','First Founding','Rites of Battle','Mark of the Xenos','Honour the Chapter'];
|
||
const srcPrio = s => { const i = SOURCE_PRIORITY.indexOf(s); return i === -1 ? 99 : i; };
|
||
const normTitle = t => (t||'').toLowerCase().replace(/[^a-z0-9 ]/g,' ').replace(/\s+/g,' ').trim();
|
||
|
||
const [allRows] = await pool.execute('SELECT id, title, source, LENGTH(content) as clen FROM rules');
|
||
const byTitle = {};
|
||
for (const r of allRows) {
|
||
const k = normTitle(r.title);
|
||
if (!k) continue;
|
||
if (!byTitle[k]) byTitle[k] = [];
|
||
byTitle[k].push(r);
|
||
}
|
||
|
||
const toDelete = [];
|
||
for (const group of Object.values(byTitle)) {
|
||
if (group.length < 2) continue;
|
||
group.sort((a, b) => {
|
||
const pd = srcPrio(a.source) - srcPrio(b.source);
|
||
return pd !== 0 ? pd : b.clen - a.clen;
|
||
});
|
||
for (const r of group.slice(1)) toDelete.push(r.id);
|
||
}
|
||
results.duplicates_found = toDelete.length;
|
||
|
||
if (toDelete.length > 0) {
|
||
for (let i = 0; i < toDelete.length; i += 500) {
|
||
const batch = toDelete.slice(i, i + 500);
|
||
const ph = batch.map(() => '?').join(',');
|
||
await pool.execute(`DELETE FROM rules WHERE id IN (${ph})`, batch);
|
||
}
|
||
}
|
||
results.deleted = toDelete.length;
|
||
|
||
// 4. Final counts
|
||
const [stats] = await pool.execute(
|
||
`SELECT source, COUNT(*) as cnt FROM rules GROUP BY source ORDER BY cnt DESC`
|
||
);
|
||
results.sources = stats;
|
||
|
||
const [total] = await pool.execute('SELECT COUNT(*) as cnt FROM rules');
|
||
results.total = total[0].cnt;
|
||
|
||
res.json({ success: true, ...results });
|
||
} catch (e) {
|
||
console.error('tag-and-dedup error:', e);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
console.log('Rules routes registered');
|
||
module.exports = router;
|