From e511bff8821859652b1115308ec3260366c16090 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 1 Mar 2026 22:50:50 +0100 Subject: [PATCH] Add weapon images, full rule scraping, and expanded quick reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- database/routes/rulesRoutes.js | 85 +++++++- database/server.js | 5 + package.json | 3 + public/cards.html | 34 +++- public/print.html | 317 ++++++++++++++++++++++++++++-- scripts/generate-weapon-images.js | 228 +++++++++++++++++++++ scripts/scrape-rules-from-pdfs.py | 258 ++++++++++++++++++++++++ scripts/tag-and-dedup-rules.js | 154 +++++++++++++++ src/components/RulesTab.jsx | 15 +- 9 files changed, 1075 insertions(+), 24 deletions(-) create mode 100644 scripts/generate-weapon-images.js create mode 100644 scripts/scrape-rules-from-pdfs.py create mode 100644 scripts/tag-and-dedup-rules.js diff --git a/database/routes/rulesRoutes.js b/database/routes/rulesRoutes.js index 9ddad11..995f9e6 100644 --- a/database/routes/rulesRoutes.js +++ b/database/routes/rulesRoutes.js @@ -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; diff --git a/database/server.js b/database/server.js index 0ae8d9e..54eeeec 100755 --- a/database/server.js +++ b/database/server.js @@ -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)) { diff --git a/package.json b/package.json index 918dcf7..8ffab74 100755 --- a/package.json +++ b/package.json @@ -40,6 +40,9 @@ "pm2:stop": "pm2 stop deathwatch-server || true", "pm2:reload": "cd database && pm2 reload pm2.config.js --env production --update-env", "sanitize:openai": "node scripts/sanitize-rules-openai.js", + "generate:images": "node scripts/generate-weapon-images.js --provider huggingface", + "generate:images:test": "node scripts/generate-weapon-images.js --provider huggingface --limit 3", + "generate:images:openai": "node scripts/generate-weapon-images.js --provider openai", "prestart": "", "preserver": "" }, diff --git a/public/cards.html b/public/cards.html index 24f5582..8ac4dcd 100644 --- a/public/cards.html +++ b/public/cards.html @@ -144,6 +144,17 @@ inset: 0; background: radial-gradient(ellipse 85% 80% at 50% 50%, transparent 40%, rgba(0,0,0,0.8) 100%); z-index: 2; + pointer-events: none; + } + /* AI-generated image fills the art box */ + .card-art-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + z-index: 1; } .card-art-inner { position: relative; @@ -154,6 +165,9 @@ justify-content: center; gap: 1mm; } + /* hide emoji fallback when image loaded */ + .card-art.has-image .card-art-icon { display: none; } + .card-art.has-image .card-art-class { display: none; } .card-art-icon { font-size: 22pt; filter: drop-shadow(0 0 3px rgba(200,169,110,0.8)); @@ -292,6 +306,16 @@