Files
dwroller/public/cards.html
Alex e511bff882 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>
2026-03-01 22:50:50 +01:00

516 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Deathwatch Card Sheets</title>
<style>
/* ═══ RESET ═══ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ═══ SCREEN CHROME ═══ */
body {
background: #2a2a2a;
font-family: 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
color: #111;
padding: 20px;
}
.controls {
background: #1a1a2e;
color: #c8a96e;
padding: 16px 24px;
margin-bottom: 20px;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.controls h1 { font-size: 18pt; letter-spacing: 2px; text-transform: uppercase; margin-right: auto; }
.btn {
padding: 8px 18px;
border: none;
border-radius: 4px;
font-size: 10pt;
cursor: pointer;
font-family: Arial, sans-serif;
font-weight: bold;
}
.btn-print { background: #c8a96e; color: #1a1a2e; }
.btn-print:hover { background: #e0c080; }
.filter-group { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-btn {
padding: 6px 12px;
border-radius: 3px;
font-size: 9pt;
cursor: pointer;
border: 2px solid transparent;
font-family: Arial, sans-serif;
}
.filter-btn.active { border-color: #c8a96e; }
.filter-btn[data-cat="Melee Weapon"] { background: #5a1a1a; color: #ffbbbb; }
.filter-btn[data-cat="Ranged Weapon"] { background: #1a2a4a; color: #bbccff; }
.filter-btn[data-cat="Grenade"] { background: #3a2a0a; color: #ffdd88; }
.filter-btn[data-cat="Armour"] { background: #1a3a1a; color: #aaffaa; }
.filter-btn[data-cat="Other"] { background: #2a1a3a; color: #ddaaff; }
.filter-btn[data-cat="all"] { background: #333; color: #ccc; }
#loading { color: #c8a96e; text-align: center; padding: 40px; font-size: 14pt; }
/* ═══ SHEET = one printable A4 page ═══ */
.sheet {
background: #fff;
width: 210mm;
min-height: 297mm;
margin: 0 auto 20px;
padding: 6mm;
display: grid;
grid-template-columns: repeat(3, 63mm);
grid-template-rows: repeat(3, 88mm);
gap: 3mm;
justify-content: center;
align-content: start;
}
/* ═══ THE CARD ═══ */
.card {
width: 63mm;
height: 88mm;
border: 2.5pt solid #111;
border-radius: 3mm;
background: #f0e8d8;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
font-size: 6pt;
}
/* req badge */
.req-badge {
position: absolute;
top: 1.5mm;
left: 1.5mm;
width: 6mm;
height: 6mm;
border-radius: 50%;
background: #1a1a2e;
border: 1pt solid #c8a96e;
color: #c8a96e;
display: flex;
align-items: center;
justify-content: center;
font-size: 6.5pt;
font-weight: bold;
font-family: Arial, sans-serif;
z-index: 10;
line-height: 1;
}
/* title bar */
.card-title-bar {
background: #e8dcc8;
border-bottom: 1.5pt solid #555;
padding: 1.5mm 2mm 1mm 8mm; /* left pad for req badge */
min-height: 8mm;
display: flex;
align-items: center;
flex-shrink: 0;
}
.card-title {
font-size: 7.5pt;
font-weight: bold;
color: #1a1a1a;
letter-spacing: 0.2pt;
line-height: 1.2;
}
/* art box */
.card-art {
flex: 0 0 26mm;
margin: 1.5mm 2mm 1mm;
border-radius: 4mm;
border: 1.5pt solid #333;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* oval vignette inside art box */
.card-art::before {
content: '';
position: absolute;
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;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
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));
line-height: 1;
}
.card-art-class {
font-size: 5.5pt;
color: #c8a96e;
font-family: Arial, sans-serif;
letter-spacing: 1pt;
text-transform: uppercase;
}
/* type line */
.card-type-line {
text-align: center;
padding: 0.5mm 2mm;
font-size: 6pt;
font-style: italic;
color: #333;
border-top: 0.75pt solid #888;
border-bottom: 0.75pt solid #888;
background: #e0d4c0;
flex-shrink: 0;
}
/* stats block */
.card-stats {
padding: 1.5mm 2mm 1mm;
flex: 1;
display: flex;
flex-direction: column;
gap: 1mm;
overflow: hidden;
}
.stat-row {
display: flex;
flex-wrap: wrap;
gap: 2mm;
line-height: 1.6;
}
.stat-item {
white-space: nowrap;
font-size: 7pt;
}
.stat-label {
font-weight: bold;
color: #333;
font-family: Arial, sans-serif;
}
.stat-value {
color: #111;
}
.card-rules {
font-size: 6pt;
font-style: italic;
color: #444;
margin-top: 1mm;
line-height: 1.4;
border-top: 0.5pt solid #aaa;
padding-top: 1mm;
}
/* bottom bar */
.card-footer {
flex-shrink: 0;
background: #d8ccb8;
border-top: 1pt solid #888;
padding: 1mm 2mm;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 5.5pt;
font-family: Arial, sans-serif;
color: #555;
font-style: italic;
}
.card[data-cat="Melee Weapon"] .card-footer { background: #d8c4b4; }
.card[data-cat="Ranged Weapon"] .card-footer { background: #bcc8d8; }
.card[data-cat="Grenade"] .card-footer { background: #d8d0b4; }
.card[data-cat="Armour"] .card-footer { background: #bcd8bc; }
.card[data-cat="Other"] .card-footer { background: #cebcd8; }
/* category colour accents */
.card[data-cat="Melee Weapon"] .card-title-bar { background: #e8d0c0; border-bottom-color: #7a2a1a; }
.card[data-cat="Melee Weapon"] .card-type-line { color: #7a2a1a; }
.card[data-cat="Melee Weapon"] .card-art { background: linear-gradient(160deg,#2a0a0a,#1a1a1a); }
.card[data-cat="Ranged Weapon"] .card-title-bar { background: #c8d4e8; border-bottom-color: #1a2a5a; }
.card[data-cat="Ranged Weapon"] .card-type-line { color: #1a2a5a; }
.card[data-cat="Ranged Weapon"] .card-art { background: linear-gradient(160deg,#0a0a2a,#1a1a1a); }
.card[data-cat="Grenade"] .card-title-bar { background: #e8e0c0; border-bottom-color: #5a3a00; }
.card[data-cat="Grenade"] .card-type-line { color: #5a3a00; }
.card[data-cat="Grenade"] .card-art { background: linear-gradient(160deg,#1a1000,#1a1a1a); }
.card[data-cat="Armour"] .card-title-bar { background: #c8e0c8; border-bottom-color: #1a4a1a; }
.card[data-cat="Armour"] .card-type-line { color: #1a4a1a; }
.card[data-cat="Armour"] .card-art { background: linear-gradient(160deg,#001a00,#1a1a1a); }
.card[data-cat="Other"] .card-title-bar { background: #dcc8e8; border-bottom-color: #3a1a5a; }
.card[data-cat="Other"] .card-type-line { color: #3a1a5a; }
.card[data-cat="Other"] .card-art { background: linear-gradient(160deg,#0a001a,#1a1a1a); }
/* ═══ PRINT ═══ */
@media print {
body { background: #fff; padding: 0; }
.controls { display: none; }
.sheet {
margin: 0;
page-break-after: always;
box-shadow: none;
padding: 5mm;
}
.sheet:last-child { page-break-after: avoid; }
.card { box-shadow: none; }
}
</style>
</head>
<body>
<div class="controls">
<h1>⚔ Deathwatch Card Sheets</h1>
<div class="filter-group">
<button class="filter-btn active" data-cat="all" onclick="setFilter('all')">All</button>
<button class="filter-btn" data-cat="Melee Weapon" onclick="setFilter('Melee Weapon')">Melee</button>
<button class="filter-btn" data-cat="Ranged Weapon" onclick="setFilter('Ranged Weapon')">Ranged</button>
<button class="filter-btn" data-cat="Grenade" onclick="setFilter('Grenade')">Grenades</button>
<button class="filter-btn" data-cat="Armour" onclick="setFilter('Armour')">Armour</button>
<button class="filter-btn" data-cat="Other" onclick="setFilter('Other')">Other</button>
</div>
<button class="btn btn-print" onclick="window.print()">🖨 Print</button>
</div>
<div id="loading">Loading cards from database…</div>
<div id="sheets"></div>
<script>
// ── AI image manifest (populated by generate-weapon-images.js)
let imageManifest = {} // id → { path }
async function loadManifest() {
try {
const res = await fetch('/weapon-images/manifest.json')
if (res.ok) imageManifest = await res.json()
} catch { /* no manifest yet use emoji fallback */ }
}
// ── Icons by category/class
const ICONS = {
'Melee Weapon': { default: '⚔', 'Chain': '⛓', 'Power': '⚡', 'Force': '🔮', 'Combat': '🗡' },
'Ranged Weapon': { default: '🔫', 'Heavy': '💥', 'Plasma': '🟢', 'Melta': '🔴', 'Flamer': '🔥', 'Sniper': '🎯', 'Bolt': '💠' },
'Grenade': { default: '💣' },
'Armour': { default: '🛡' },
'Other': { default: '⚙' },
}
function getIcon(cat, name) {
const map = ICONS[cat] || { default: '📦' }
for (const [key, icon] of Object.entries(map)) {
if (key !== 'default' && name.toLowerCase().includes(key.toLowerCase())) return icon
}
return map.default
}
function parseRanged(dmgStr) {
if (!dmgStr) return {}
const r = {}
for (const p of dmgStr.split(';').map(s => s.trim())) {
if (/^Range /i.test(p)) r.range = p.replace(/^Range /i,'')
else if (/^Pen /i.test(p)) r.pen = p.replace(/^Pen /i,'')
else if (/^Clip /i.test(p))r.clip = p.replace(/^Clip /i,'')
else if (/^Reload /i.test(p)) r.reload = p.replace(/^Reload /i,'')
else if (/^[S-]\/[0-9-]/.test(p)) r.rof = p
else if (/\dd\d/.test(p)) r.damage = p
else if (p) r.special = (r.special ? r.special+', ' : '') + p
}
return r
}
function parseMelee(s) {
// Some melee have everything in damage string with semicolons
let dmg = s.damage || '', pen = s.pen, special = s.special
if (dmg.includes(';')) {
const bits = dmg.split(';').map(x=>x.trim())
dmg = bits[0]
for (const b of bits.slice(1)) {
if (/^Pen \d/i.test(b)) pen = b.replace(/^Pen /i,'')
else if (b && b !== '-') special = (special ? special+', ' : '') + b
}
}
return { dmg, pen: pen||s.pen, special: special||s.special }
}
function esc(s) {
if (!s) return ''
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
}
function classShort(s) {
if (!s) return ''
return s.split(' - ').pop()
}
function buildCard(w) {
const s = w.stats || {}
const cat = w.category
const icon = getIcon(cat, w.name + ' ' + (s.class||''))
const req = s.req || ''
const renown = s.renown && s.renown !== '-' ? s.renown : ''
const cls = classShort(s.class) || cat.replace(' Weapon','')
// Build stat lines
let statsHtml = ''
let typeLine = cat
if (cat === 'Ranged Weapon' || (cat === 'Grenade' && s.damage && s.damage.includes(';'))) {
const p = parseRanged(s.damage)
typeLine = 'Weapon Ranged'
const rows = []
if (p.range) rows.push(`<span class="stat-item"><span class="stat-label">Range:</span> <span class="stat-value">${esc(p.range)}</span></span>`)
if (p.rof) rows.push(`<span class="stat-item"><span class="stat-label">ROF:</span> <span class="stat-value">${esc(p.rof)}</span></span>`)
if (p.damage)rows.push(`<span class="stat-item"><span class="stat-label">Dmg:</span> <span class="stat-value">${esc(p.damage)}</span></span>`)
if (p.pen) rows.push(`<span class="stat-item"><span class="stat-label">Pen:</span> <span class="stat-value">${esc(p.pen)}</span></span>`)
if (p.clip) rows.push(`<span class="stat-item"><span class="stat-label">Clip:</span> <span class="stat-value">${esc(p.clip)}</span></span>`)
if (p.reload)rows.push(`<span class="stat-item"><span class="stat-label">Rld:</span> <span class="stat-value">${esc(p.reload)}</span></span>`)
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
if (p.special) statsHtml += `<div class="stat-row"><span class="stat-item"><span class="stat-label">Special:</span> <span class="stat-value">${esc(p.special)}</span></span></div>`
} else if (cat === 'Melee Weapon') {
const m = parseMelee(s)
typeLine = 'Weapon Melee'
const rows = []
if (m.dmg) rows.push(`<span class="stat-item"><span class="stat-label">Dmg:</span> <span class="stat-value">${esc(m.dmg)}</span></span>`)
if (m.pen) rows.push(`<span class="stat-item"><span class="stat-label">Pen:</span> <span class="stat-value">${esc(m.pen)}</span></span>`)
if (s.wt && s.wt !== '-') rows.push(`<span class="stat-item"><span class="stat-label">Wt:</span> <span class="stat-value">${esc(s.wt)}</span></span>`)
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
if (m.special && m.special !== '') statsHtml += `<div class="stat-row"><span class="stat-item"><span class="stat-label">Special:</span> <span class="stat-value">${esc(m.special)}</span></span></div>`
} else if (cat === 'Grenade') {
typeLine = 'Weapon Ranged'
const bits = (s.damage||'').split(';').map(x=>x.trim())
const rows = []
for (const b of bits) {
if (/\dd\d/.test(b)) rows.push(`<span class="stat-item"><span class="stat-label">Dmg:</span> <span class="stat-value">${esc(b)}</span></span>`)
else if (/^Pen /i.test(b)) rows.push(`<span class="stat-item"><span class="stat-label">Pen:</span> <span class="stat-value">${esc(b.replace(/^Pen /i,''))}</span></span>`)
else if (b) rows.push(`<span class="stat-item"><span class="stat-value">${esc(b)}</span></span>`)
}
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
if (s.class) statsHtml += `<div class="stat-row"><span class="stat-item"><span class="stat-label">Class:</span> <span class="stat-value">${esc(classShort(s.class))}</span></span></div>`
} else if (cat === 'Armour') {
typeLine = 'Armour'
const rows = []
if (s.damage) rows.push(`<span class="stat-item"><span class="stat-value">${esc(s.damage)}</span></span>`)
statsHtml = `<div class="stat-row">${rows.join(' ')}</div>`
} else {
typeLine = 'Equipment'
if (s.damage) statsHtml = `<div class="stat-row"><span class="stat-item"><span class="stat-value">${esc(s.damage)}</span></span></div>`
}
const srcShort = (w.source||'').replace('Deathwatch ','').replace('Core Rulebook','CR').replace('First Founding','FF').replace('Rites of Battle','RoB').replace('Mark of the Xenos','MoX').replace('Honour the Chapter','HtC').replace('Living Errata v1.1','Errata')
const imgEntry = imageManifest[w.id]
const imgTag = imgEntry
? `<img class="card-art-img" src="${esc(imgEntry.path)}" alt="${esc(w.name)}" loading="lazy">`
: ''
const artClass = imgEntry ? 'card-art has-image' : 'card-art'
return `
<div class="card" data-cat="${esc(cat)}">
${req ? `<div class="req-badge">${req}</div>` : ''}
<div class="card-title-bar">
<div class="card-title">${esc(w.name)}</div>
</div>
<div class="${artClass}">
${imgTag}
<div class="card-art-inner">
<div class="card-art-icon">${icon}</div>
<div class="card-art-class">${esc(cls)}</div>
</div>
</div>
<div class="card-type-line">${esc(typeLine)}</div>
<div class="card-stats">
${statsHtml}
</div>
<div class="card-footer">
<span>${renown ? 'Renown: ' + esc(renown) : ''}</span>
<span>${esc(srcShort)}</span>
</div>
</div>`
}
let allWeapons = []
let currentFilter = 'all'
function setFilter(cat) {
currentFilter = cat
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.cat === cat)
})
renderSheets()
}
function renderSheets() {
const weapons = currentFilter === 'all'
? allWeapons
: allWeapons.filter(w => w.category === currentFilter)
const sheetsEl = document.getElementById('sheets')
sheetsEl.innerHTML = ''
// 9 cards per A4 sheet
for (let i = 0; i < weapons.length; i += 9) {
const chunk = weapons.slice(i, i + 9)
const sheetDiv = document.createElement('div')
sheetDiv.className = 'sheet'
sheetDiv.innerHTML = chunk.map(buildCard).join('')
sheetsEl.appendChild(sheetDiv)
}
}
async function loadWeapons() {
try {
await loadManifest()
const res = await fetch('/api/weapons')
allWeapons = await res.json()
// Sort: melee first, then ranged, then grenades, armour, other
const order = { 'Melee Weapon': 0, 'Ranged Weapon': 1, 'Grenade': 2, 'Armour': 3, 'Other': 4 }
allWeapons.sort((a, b) => {
const od = (order[a.category]||9) - (order[b.category]||9)
return od !== 0 ? od : a.name.localeCompare(b.name)
})
document.getElementById('loading').style.display = 'none'
renderSheets()
} catch(e) {
document.getElementById('loading').textContent = 'Failed to load — is the server running?'
}
}
loadWeapons()
</script>
</body>
</html>