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:
2026-03-01 22:50:50 +01:00
parent dc1c13620f
commit e511bff882
9 changed files with 1075 additions and 24 deletions

View File

@@ -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;

View File

@@ -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)) {