Add weapon images, full rule scraping, and expanded quick reference
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -131,11 +131,8 @@ router.get('/search', async (req, res) => {
|
||||
// Sort: title matches first, then Core Rulebook source, then content matches
|
||||
filtered.sort((a, b) => score(b, term) - score(a, term));
|
||||
|
||||
// Limit results and truncate content
|
||||
const results = filtered.slice(0, limitInt).map(r => ({
|
||||
...r,
|
||||
content: r.content && r.content.length > 300 ? r.content.substring(0, 300) + '...' : r.content
|
||||
}));
|
||||
// Limit results (content kept full – modal fetches via /rule/:id anyway)
|
||||
const results = filtered.slice(0, limitInt);
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
@@ -172,10 +169,7 @@ router.get('/random', async (req, res) => {
|
||||
|
||||
// Shuffle and pick random rules
|
||||
const shuffled = filtered.sort(() => 0.5 - Math.random());
|
||||
const randomRules = shuffled.slice(0, max).map(r => ({
|
||||
...r,
|
||||
content: r.content && r.content.length > 200 ? r.content.substring(0, 200) + '...' : r.content
|
||||
}));
|
||||
const randomRules = shuffled.slice(0, max);
|
||||
|
||||
res.json(randomRules);
|
||||
} catch (error) {
|
||||
@@ -229,5 +223,78 @@ router.post('/reload', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -166,6 +166,11 @@ app.post('/api/copy-bestiary', (req, res) => {
|
||||
const buildDir = path.join(__dirname, '..', 'build');
|
||||
app.use(express.static(buildDir));
|
||||
|
||||
// Serve weapon images directly from public/ so new images appear without rebuild
|
||||
const weaponImagesDir = path.join(__dirname, '..', 'public', 'weapon-images');
|
||||
fs.mkdirSync(weaponImagesDir, { recursive: true });
|
||||
app.use('/weapon-images', express.static(weaponImagesDir));
|
||||
|
||||
// Serve uploaded avatars from public/avatars
|
||||
const avatarsDir = path.join(__dirname, '..', 'public', 'avatars');
|
||||
if (!fs.existsSync(avatarsDir)) {
|
||||
|
||||
Reference in New Issue
Block a user