Files
dwroller/src/components/DeathwatchRoller.jsx
T
alex 4c1b19fb8c feat: Implement bestiary routes for data retrieval and management
- Added bestiaryRoutes.js to handle API endpoints for fetching enemies, full bestiary data, and statistics.
- Implemented caching mechanism for bestiary data with a 5-minute expiration.
- Created transformation functions for bestiary entries to standardize data format for the dice roller.
- Added admin-only endpoint to force reload bestiary data with GM authentication.

chore: Create scripts for analyzing and cleaning bestiary data

- Developed analyze-bestiary-quality.js to review data quality and identify duplicates or inconsistencies.
- Implemented clean-bestiary-fields.js to extract and clean specific fields from bestiary entries, reducing text duplication.
- Created clean-corrupted-bestiary.js to remove corrupted entries based on defined patterns.
- Generated a comprehensive database-cleanup-report.md summarizing the cleaning process and results.

build: Add fast build script for streamlined deployment

- Introduced fast-build.sh to facilitate quick builds and PM2 reloads without reinstalling dependencies.

test: Add GM authentication logic test

- Created test-gm-auth.js to verify the correctness of GM authentication logic with various test cases.
2025-08-17 18:15:39 +02:00

1444 lines
76 KiB
React
Executable File

const { useEffect, useMemo, useState } = require('react')
// Tooltip component for abbreviations
function Tooltip({ children, text }) {
return (
<span className="group relative inline-block cursor-help border-b border-dotted border-white/30 hover:border-white/60">
{children}
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-black text-white rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50">
{text}
</span>
</span>
)
}
function simpleCSVParse(txt) {
const lines = String(txt ?? '').split(/\r?\n|\r/).filter(l=>l.trim().length>0)
if (lines.length===0) return []
const headers = lines[0].split(',').map(h=>h.trim())
const rows = []
for (let i=1;i<lines.length;i++) {
const cells = lines[i].split(',')
const obj = {}
for (let j=0;j<headers.length;j++) obj[headers[j]] = (cells[j]||'').trim()
rows.push(obj)
}
return rows
}
function parseDice(spec) {
const s = String(spec || '').replace(/\s+/g, '').toLowerCase()
const parts = s.split('+')
let flat = 0
let terms = []
for (const p of parts) {
if (!p) continue
const m = p.match(/^(\d+)d(\d+)$/)
if (m) {
const c = parseInt(m[1], 10)
const f = parseInt(m[2], 10)
if (!Number.isFinite(c) || !Number.isFinite(f) || c < 1 || f < 2) throw new Error('Invalid dice bounds')
terms.push({ count: c, faces: f })
} else {
const n = Number(p)
if (!Number.isNaN(n)) flat += n
else throw new Error(`Invalid dice term: ${p}`)
}
}
if (terms.length === 0) terms = [{ count: 1, faces: 10 }]
return { terms, flat }
}
function rollDie(faces) { return Math.floor(Math.random()*faces)+1 }
function rollDice(terms, opts) {
const tearing = !!(opts && opts.tearing)
const proven = Math.max(0, opts && typeof opts.proven==='number' ? opts.proven : 0)
const out = []
for (const t of terms) {
for (let i=0;i<t.count;i++) {
let r = rollDie(t.faces)
if (tearing) { const alt = rollDie(t.faces); r = Math.max(r, alt) }
if (proven>0 && t.faces===10) r = Math.max(r, proven)
out.push(r)
}
}
return { rolls: out, total: out.reduce((a,b)=>a+b,0) }
}
function d100() { return Math.floor(Math.random()*100)+1 }
function degrees(target, roll) {
const success = roll <= target
if (success) { const diff = target - roll; const dos = 1 + Math.floor(diff/10); return { success, dos, dof: 0 } }
const diff = roll - target; const dof = 1 + Math.floor(diff/10); return { success, dos: 0, dof }
}
function hitsFromDoS(mode, dos, rof) {
const r = rof && rof>0 ? rof : 1
if (mode === 'single') return Math.min(1, r)
if (mode === 'semi') return Math.max(1, Math.min(1 + Math.floor(dos/2), r))
return Math.max(1, Math.min(1 + dos, r))
}
function hitLocationFromRoll(roll) {
const rev = Number(String(roll).padStart(2,'0').split('').reverse().join(''))
if (rev >= 1 && rev <= 10) return 'Head'
if (rev <= 20) return 'Right Arm'
if (rev <= 30) return 'Left Arm'
if (rev <= 70) return 'Body'
if (rev <= 85) return 'Right Leg'
return 'Left Leg'
}
function mitigateDamage(dmg, tb, armour) { return Math.max(0, dmg - tb - armour) }
const STORAGE_PRESETS = 'dw:presets:v3'
const STORAGE_HISTORY = 'dw:history:v2'
const STORAGE_WEAPONS = 'dw:weapons:v3'
const STORAGE_TRACKER = 'dw:tracker:v1'
const BODY_PARTS = ['Head','Body','Left Arm','Right Arm','Left Leg','Right Leg']
// Note: don't require files outside `src/` (CRA will fail the production build).
// We'll attempt to fetch any packaged JSON at runtime from known public paths.
let _builtWeapons = null
function mapBuildEntryToWeapon(entry) {
const name = String(entry.name || '').trim()
const stats = entry.stats || {}
const dmgText = String(stats.damage || '')
const rawDamage = dmgText
// Extract first dice expression like 2d10+5
const diceMatch = dmgText.match(/(\d+d\d+(?:\+\d+)?)/i)
const damage = diceMatch ? diceMatch[1] : '1d10'
const tearing = /tearing/i.test(dmgText) || /tearing/i.test(stats.source || '')
const reliable = /reliable/i.test(dmgText) || /reliable/i.test(stats.source || '')
const penMatch = dmgText.match(/Pen\s*([0-9]+)/i)
const pen = penMatch ? Math.max(0, parseInt(penMatch[1], 10)) : 0
// Try to extract RoF from explicit token, otherwise approximate from Clip
let rof = 1
const rofMatch = dmgText.match(/RoF\s*[:\s]*([0-9]+)/i) || dmgText.match(/rof\s*[:\s]*([0-9]+)/i)
if (rofMatch) rof = Math.max(1, parseInt(rofMatch[1], 10))
else {
const clipMatch = dmgText.match(/Clip\s*([0-9]+)/i)
if (clipMatch) {
const clip = Math.max(0, parseInt(clipMatch[1], 10))
if (clip >= 100) rof = 10
else if (clip >= 30) rof = 4
else if (clip >= 14) rof = 3
else if (clip >= 6) rof = 2
else rof = 1
}
}
// Map class string into roller classes
const clsText = String(stats.class || entry.category || '').toLowerCase()
let cls = 'other'
if (clsText.includes('melee')) cls = 'melee'
else if (clsText.includes('pistol')) cls = 'pistol'
else if (clsText.includes('basic')) cls = 'basic'
else if (clsText.includes('heavy')) cls = 'heavy'
// Derive modes from class heuristics
let modes = []
if (cls === 'melee') modes = ['single']
else if (cls === 'pistol') modes = ['single','semi']
else if (cls === 'basic') modes = ['single','semi','full']
else if (cls === 'heavy') modes = ['full']
else modes = ['single']
return {
name,
class: cls,
damage,
rawDamage,
pen,
rof,
modes,
tearing,
reliable
}
}
// Normalize bestiary entry shapes (same logic as BestiaryTab) so different consumers get a consistent shape
// Removed normalizeEntry function - now using database API transformation
function buildWeaponsList() {
if (!_builtWeapons) return []
const out = []
if (Array.isArray(_builtWeapons.rangedWeapons)) out.push(..._builtWeapons.rangedWeapons.map(mapBuildEntryToWeapon))
if (Array.isArray(_builtWeapons.meleeWeapons)) out.push(..._builtWeapons.meleeWeapons.map(mapBuildEntryToWeapon))
if (Array.isArray(_builtWeapons.grenades)) out.push(..._builtWeapons.grenades.map(mapBuildEntryToWeapon))
if (Array.isArray(_builtWeapons.other)) out.push(..._builtWeapons.other.map(mapBuildEntryToWeapon))
// Preserve additional metadata (req, renown, source) onto mapped entries when available
for (const cat of ['rangedWeapons','meleeWeapons','grenades','other']) {
if (!Array.isArray(_builtWeapons[cat])) continue
for (const e of _builtWeapons[cat]) {
const key = String(e.name || '').trim().toLowerCase()
const mapped = out.find(o => o.name.toLowerCase() === key)
if (!mapped) continue
if (typeof e.req !== 'undefined') mapped.req = e.req
if (typeof e.renown !== 'undefined') mapped.renown = e.renown
if (e.stats && e.stats.source) mapped.source = e.stats.source
else if (e.source) mapped.source = e.source
}
}
// Deduplicate by name (case-insensitive)
const byName = new Map()
for (const w of out) {
if (!w.name) continue
byName.set(w.name.toLowerCase(), w)
}
return Array.from(byName.values()).sort((a,b)=>a.name.localeCompare(b.name))
}
// Merge helper moved to module scope so top-level import helpers can use it
function mergeWeapons(existing, incoming) {
const byName = new Map((Array.isArray(existing) ? existing : []).map(w=>[w.name.toLowerCase(), w]))
for (const w of (Array.isArray(incoming) ? incoming : [])) {
if (!w || !w.name) continue
byName.set(w.name.toLowerCase(), w)
}
return Array.from(byName.values()).sort((a,b)=>a.name.localeCompare(b.name))
}
// Import built DB into weapons (merge with existing)
function importBuildWeapons(existing) {
const built = buildWeaponsList()
if (!built || built.length===0) return existing
// Attach metadata for display; merge preserving existing items
const merged = mergeWeapons(existing, built)
return merged
}
function safeGet(key) {
try {
if (typeof window === 'undefined' || !('localStorage' in window)) return null
const raw = window.localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
} catch { return null }
}
function safeSet(key, val) { try { if (typeof window !== 'undefined' && 'localStorage' in window) window.localStorage.setItem(key, JSON.stringify(val)) } catch {} }
const defaultWeapons = [
{ name:'Astartes Bolt Pistol', class:'pistol', damage:'2d10+5', pen:5, rof:3, modes:['single','semi'], tearing:true },
{ name:'Astartes Bolter (Godwyn)', class:'basic', damage:'2d10+5', pen:5, rof:4, modes:['single','semi','full'], tearing:true },
{ name:'Astartes Boltgun (Stalker)', class:'basic', damage:'2d10+5', pen:5, rof:1, modes:['single'], tearing:true },
{ name:'Astartes Combi-Weapon', class:'basic', damage:'2d10+5', pen:5, rof:4, modes:['single','semi','full'], tearing:true },
{ name:'Astartes Heavy Bolter', class:'heavy', damage:'2d10+10', pen:6, rof:10, modes:['full'], tearing:true },
{ name:'Astartes Storm Bolter', class:'basic', damage:'2d10+5', pen:5, rof:4, modes:['single','semi','full'], tearing:true },
{ name:'Astartes Plasma Cannon', class:'heavy', damage:'2d10+11', pen:10, rof:1, modes:['single'] },
{ name:'Astartes Plasma Gun (Ragefire)', class:'basic', damage:'1d10+9', pen:8, rof:2, modes:['single','semi'] },
{ name:'Astartes Plasma Pistol', class:'pistol', damage:'1d10+8', pen:8, rof:2, modes:['single','semi'] },
{ name:'Astartes Infernus Pistol', class:'pistol', damage:'2d10+8', pen:13, rof:1, modes:['single'] },
{ name:'Astartes Meltagun (Vulkan)', class:'basic', damage:'2d10+8', pen:13, rof:1, modes:['single'] },
{ name:'Astartes Multi-melta (Maxima)', class:'heavy', damage:'4d10+6', pen:13, rof:1, modes:['single'] },
{ name:'Astartes Flamer', class:'basic', damage:'2d10+2', pen:3, rof:1, modes:['single'] },
{ name:'Astartes Hand Flamer', class:'pistol', damage:'2d10+2', pen:3, rof:1, modes:['single'] },
{ name:'Astartes Heavy Flamer', class:'heavy', damage:'2d10+6', pen:6, rof:1, modes:['single'] },
{ name:'Astartes Assault Cannon', class:'heavy', damage:'3d10+6', pen:6, rof:10, modes:['full'], tearing:true },
{ name:'Astartes Shotgun', class:'basic', damage:'2d10+3', pen:0, rof:2, modes:['single','semi'], reliable:true },
{ name:'Autogun', class:'basic', damage:'1d10+3', pen:0, rof:10, modes:['single','semi','full'] },
{ name:'Autopistol', class:'pistol', damage:'1d10+2', pen:0, rof:6, modes:['single','full'] },
{ name:'Astartes Lascannon', class:'heavy', damage:'6d10+10', pen:10, rof:1, modes:['single'] },
{ name:'Lasgun', class:'basic', damage:'1d10+3', pen:0, rof:3, modes:['single','semi'], reliable:true },
{ name:'Laspistol', class:'pistol', damage:'1d10+2', pen:0, rof:1, modes:['single'], reliable:true },
{ name:'Astartes Digital Flamer', class:'pistol', damage:'2d10+2', pen:3, rof:1, modes:['single'] },
{ name:'Astartes Digital Laser', class:'pistol', damage:'1d10+4', pen:7, rof:1, modes:['single'], reliable:true },
{ name:'Astartes Digital Melta', class:'pistol', damage:'2d10+5', pen:12, rof:1, modes:['single'] },
{ name:'Astartes Sniper Rifle', class:'basic', damage:'1d10', pen:0, rof:1, modes:['single'] },
{ name:'Astartes Chainsword', class:'melee', damage:'1d10+3', pen:4, rof:1, modes:['single'], tearing:true },
{ name:'Astartes Power Axe', class:'melee', damage:'1d10+8', pen:6, rof:1, modes:['single'] },
{ name:'Astartes Power Sword', class:'melee', damage:'1d10+6', pen:6, rof:1, modes:['single'] },
{ name:'Astartes Power Fist', class:'melee', damage:'2d10', pen:9, rof:1, modes:['single'] },
{ name:'Astartes Thunder Hammer', class:'melee', damage:'2d10+5', pen:8, rof:1, modes:['single'] },
{ name:'Astartes Lightning Claw', class:'melee', damage:'1d10+6', pen:8, rof:1, modes:['single'], tearing:true },
{ name:'Astartes Chainfist', class:'melee', damage:'2d10', pen:10, rof:1, modes:['single'], tearing:true },
{ name:'Astartes Combat Knife', class:'melee', damage:'1d10+2', pen:2, rof:1, modes:['single'] },
{ name:'Ceremonial Sword', class:'melee', damage:'1d10+3', pen:2, rof:1, modes:['single'] },
{ name:'Sacris Claymore', class:'melee', damage:'2d10+2', pen:2, rof:1, modes:['single'] },
{ name:'Astartes Force Staff', class:'melee', damage:'1d10+1', pen:0, rof:1, modes:['single'] },
{ name:'Astartes Force Sword', class:'melee', damage:'1d10+2', pen:2, rof:1, modes:['single'] }
]
const defaultEnemies = [
{ name:'Custom/None', tb:4, armour:5, wounds:20 },
{ name:'Imperial Guardsman', tb:3, armourByLoc:{'Head':4,'Body':4,'Left Arm':4,'Right Arm':4,'Left Leg':4,'Right Leg':4}, wounds:10 },
{ name:'Chaos Space Marine', tb:8, armourByLoc:{'Head':8,'Body':10,'Left Arm':8,'Right Arm':8,'Left Leg':8,'Right Leg':8}, wounds:29 },
{ name:'Tyranid Warrior', tb:10, armourByLoc:{'Head':8,'Body':8,'Left Arm':8,'Right Arm':8,'Left Leg':8,'Right Leg':8}, wounds:48 },
{ name:'Hormagaunt', tb:3, armourByLoc:{'Head':3,'Body':3,'Left Arm':3,'Right Arm':3,'Left Leg':3,'Right Leg':3}, wounds:9 },
{ name:'Termagant', tb:3, armourByLoc:{'Head':3,'Body':3,'Left Arm':3,'Right Arm':3,'Left Leg':3,'Right Leg':3}, wounds:9 },
{ name:'Hive Tyrant', tb:15, armourByLoc:{'Head':10,'Body':10,'Left Arm':10,'Right Arm':10,'Left Leg':10,'Right Leg':10}, wounds:120 },
{ name:'Tau Commander (Crisis Suit)', tb:10, armourByLoc:{'Head':9,'Body':9,'Left Arm':9,'Right Arm':9,'Left Leg':9,'Right Leg':9}, wounds:90 },
{ name:'Industrial Servitor', tb:5, armourByLoc:{'Head':7,'Body':7,'Left Arm':7,'Right Arm':7,'Left Leg':7,'Right Leg':7}, wounds:20 },
{ name:'Ork Boy', tb:5, armour:3, wounds:13 },
{ name:'Ork Nob', tb:6, armour:4, wounds:22 },
{ name:'Genestealer', tb:6, armour:6, wounds:22 }
]
function normalizeWeapon(w) {
const name = String(w.name||'').trim()
const clsRaw = String(w.class||'other').toLowerCase()
const cls = ['melee','pistol','basic','heavy','other'].includes(clsRaw) ? clsRaw : 'other'
const damage = String(w.damage||'1d10').trim()
let modes = Array.isArray(w.modes) ? w.modes.map(m=>String(m).toLowerCase()) : []
modes = modes.filter(m=>['single','semi','full'].includes(m))
if (modes.length===0) modes = ['single']
const rof = Math.max(1, Number.isFinite(+w.rof) ? +w.rof : 1)
const pen = Math.max(0, Number.isFinite(+w.pen) ? +w.pen : 0)
const tearing = !!w.tearing
const reliable = !!w.reliable
const proven = Math.max(0, Number.isFinite(+w.proven) ? +w.proven : 0)
return { name, class: cls, damage, modes, rof, pen, tearing, reliable, proven }
}
function computeWeaponDefaults(w) { return { damage: w.damage, tearing: !!w.tearing, proven: w.proven || 0, pen: w.pen || 0, reliable: !!w.reliable, rof: w.rof || 1, mode: (w.modes && w.modes[0]) || 'single' } }
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36) }
function DeathwatchRoller() {
const [bs,setBS] = useState(45)
const [ws,setWS] = useState(45)
const [defenderBS, setDefenderBS] = useState(45)
const [defenderWS, setDefenderWS] = useState(45)
const [defenderAg, setDefenderAg] = useState(45)
const [modifier,setModifier] = useState(0)
const [defenderModifier,setDefenderModifier] = useState(0)
const [defenseType, setDefenseType] = useState('dodge')
const [coverBonus, setCoverBonus] = useState(0)
const [reactionUsed, setReactionUsed] = useState(false)
const [aim,setAim] = useState(0)
const [difficulty,setDifficulty] = useState('normal')
const [awaitingDefense, setAwaitingDefense] = useState(false)
const [pendingHits, setPendingHits] = useState(null)
const [mode,setMode] = useState('single')
const [rof,setRof] = useState(1)
const [damage,setDamage] = useState('1d10+5')
const [tearing,setTearing] = useState(false)
const [proven,setProven] = useState(0)
const [pen,setPen] = useState(0)
const [reliable,setReliable] = useState(false)
const [rfConfirm,setRfConfirm] = useState(true)
const [targetTB,setTargetTB] = useState(4)
const [targetArmour,setTargetArmour] = useState(5)
const [weapons,setWeapons] = useState(()=> {
const stored = safeGet(STORAGE_WEAPONS)
if (stored && Array.isArray(stored)) return stored
const built = buildWeaponsList()
if (built && built.length) return mergeWeapons(defaultWeapons, built)
return defaultWeapons
})
const [weaponName,setWeaponName] = useState('')
const [enemies,setEnemies] = useState(defaultEnemies)
const [enemiesLoading,setEnemiesLoading] = useState(false)
const [enemiesError,setEnemiesError] = useState(null)
const [enemyName,setEnemyName] = useState('Custom/None')
// Load enemies from database API
async function loadEnemiesFromAPI() {
setEnemiesLoading(true)
setEnemiesError(null)
try {
const response = await fetch('/api/bestiary/enemies')
if (response.ok) {
const apiEnemies = await response.json()
// Add default custom entry at the top
const allEnemies = [defaultEnemies[0], ...apiEnemies]
setEnemies(allEnemies)
console.log(`Loaded ${apiEnemies.length} enemies from database`)
} else {
throw new Error(`HTTP ${response.status}`)
}
} catch (error) {
console.error('Failed to load enemies from API:', error)
setEnemiesError(error.message)
// Keep default enemies on error
setEnemies(defaultEnemies)
} finally {
setEnemiesLoading(false)
}
}
// Load enemies on component mount
useEffect(() => {
loadEnemiesFromAPI()
}, [])
const trackerInit = safeGet(STORAGE_TRACKER)
const [maxWounds,setMaxWounds] = useState(() => Math.max(0, trackerInit?.maxWounds ?? 20))
const [curWounds,setCurWounds] = useState(() => Math.max(0, Math.min(trackerInit?.curWounds ?? 20, trackerInit?.maxWounds ?? 20)))
const [partDamage,setPartDamage] = useState(() => {
const zero = { 'Head':0, 'Body':0, 'Left Arm':0, 'Right Arm':0, 'Left Leg':0, 'Right Leg':0 }
if (!trackerInit?.partDamage) return zero
return { ...zero, ...trackerInit.partDamage }
})
const [armourMap,setArmourMap] = useState({ 'Head':5, 'Body':5, 'Left Arm':5, 'Right Arm':5, 'Left Leg':5, 'Right Leg':5 })
const [manageWeapons,setManageWeapons] = useState(false)
const [manageTab,setManageTab] = useState('json')
const [appendMode,setAppendMode] = useState(true)
const [weaponsJSON,setWeaponsJSON] = useState(JSON.stringify(weapons,null,2))
const [weaponsCSV,setWeaponsCSV] = useState('name,class,damage,pen,rof,modes,tearing,proven,reliable')
const [weaponFilter,setWeaponFilter] = useState('')
const [presets,setPresets] = useState(()=> safeGet(STORAGE_PRESETS) ?? [])
const [presetName,setPresetName] = useState('')
const [history,setHistory] = useState(()=> safeGet(STORAGE_HISTORY) ?? [])
const [error,setError] = useState('')
const [info,setInfo] = useState('')
const [progressStep, setProgressStep] = useState('idle') // idle | attack | defense | damage | wounds
const [lastAppliedDamage, setLastAppliedDamage] = useState(0)
// Manual mode state variables
const [manualMode, setManualMode] = useState(false)
const [manualAttackRoll, setManualAttackRoll] = useState('')
const [manualDefenseRoll, setManualDefenseRoll] = useState('')
const [manualDamageRolls, setManualDamageRolls] = useState('')
const usingSkill = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); if (!w) return 'BS'; return w.class==='melee' ? 'WS' : 'BS' },[weaponName, weapons])
const diffMod = useMemo(()=>{ if (difficulty==='easy') return 20; if (difficulty==='hard') return -20; if (difficulty==='deadly') return -30; return 0 },[difficulty])
const baseSkill = useMemo(()=> usingSkill === 'BS' ? bs : ws, [usingSkill, bs, ws])
const target = useMemo(()=> Math.max(0, Math.min(100, baseSkill + modifier + aim + diffMod)), [baseSkill, modifier, aim, diffMod])
useEffect(()=>{ safeSet(STORAGE_HISTORY, history) },[history])
useEffect(()=>{ safeSet(STORAGE_PRESETS, presets) },[presets])
useEffect(()=>{ safeSet(STORAGE_WEAPONS, weapons) },[weapons])
// Removed localStorage persistence - using database API instead
useEffect(()=>{ safeSet(STORAGE_TRACKER, { maxWounds, curWounds, partDamage }) },[maxWounds, curWounds, partDamage])
// On first run, if there are no stored weapons try to fetch the packaged DB from public
useEffect(()=>{
(async () => {
const stored = safeGet(STORAGE_WEAPONS)
if ((!stored || !Array.isArray(stored) || stored.length===0)) {
const endpoints = ['/deathwatch-weapons.json','/public/deathwatch-weapons.json','/build/deathwatch-weapons.json']
let fetched = null
for (const ep of endpoints) {
try {
const res = await fetch(ep, { cache: 'no-store' })
if (!res.ok) continue
const json = await res.json()
const list = []
if (json && (json.rangedWeapons || json.meleeWeapons || json.grenades || json.other)) {
if (Array.isArray(json.rangedWeapons)) list.push(...json.rangedWeapons.map(mapBuildEntryToWeapon))
if (Array.isArray(json.meleeWeapons)) list.push(...json.meleeWeapons.map(mapBuildEntryToWeapon))
if (Array.isArray(json.grenades)) list.push(...json.grenades.map(mapBuildEntryToWeapon))
if (Array.isArray(json.other)) list.push(...json.other.map(mapBuildEntryToWeapon))
} else if (Array.isArray(json)) {
list.push(...json.map(item => item.stats ? mapBuildEntryToWeapon(item) : normalizeWeapon(item)))
}
if (list.length > 0) {
fetched = list
break
}
} catch (e) {
console.info('[DW] first-run fetch failed for', ep, e && e.message)
}
}
const merged = mergeWeapons(defaultWeapons, fetched && fetched.length ? fetched : [])
setWeapons(merged)
safeSet(STORAGE_WEAPONS, merged)
if (fetched && fetched.length) setInfo('Imported weapons from database')
}
})()
}, [])
useEffect(()=>{ if (curWounds>maxWounds) setCurWounds(maxWounds) },[maxWounds, curWounds])
const filteredWeapons = useMemo(()=>{
const q = weaponFilter.trim().toLowerCase()
if (!q) return weapons
return weapons.filter(w => w.name.toLowerCase().includes(q))
},[weapons, weaponFilter])
function onSelectWeapon(name) {
setWeaponName(name)
const w = weapons.find(x=>x.name===name)
if (!w) return
const d = computeWeaponDefaults(w)
setDamage(d.damage); setTearing(d.tearing); setProven(d.proven); setPen(d.pen); setReliable(d.reliable); setRof(d.rof); setMode(d.mode)
}
function uniformArmourMap(val){
const v = Math.max(0, Number.isFinite(+val)? +val : 0)
return { 'Head':v, 'Body':v, 'Left Arm':v, 'Right Arm':v, 'Left Leg':v, 'Right Leg':v }
}
function onSelectEnemy(name) {
setEnemyName(name)
const e = enemies.find(x=>x.name===name)
if (!e) return
setTargetTB(e.tb); setTargetArmour(e.armour ?? 0)
if (e.armourByLoc) setArmourMap({ ...uniformArmourMap(0), ...e.armourByLoc })
else setArmourMap(uniformArmourMap(e.armour ?? 0))
// Apply wounds/parts if present
if (typeof e.wounds === 'number' && e.wounds >= 0) { setMaxWounds(e.wounds); setCurWounds(e.wounds); setPartDamage({ 'Head':0, 'Body':0, 'Left Arm':0, 'Right Arm':0, 'Left Leg':0, 'Right Leg':0 }) }
// Robustly extract AG/WS/BS from common enemy shapes: top-level, .characteristics, .tabInfo.characteristics
function findNumeric(o, keys) {
if (!o || typeof o !== 'object') return undefined
for (const k of keys) {
if (typeof o[k] === 'number') return o[k]
// case-insensitive variants
const lower = k.toLowerCase()
for (const ok of Object.keys(o)) {
if (ok.toLowerCase() === lower && typeof o[ok] === 'number') return o[ok]
}
}
return undefined
}
const char = e.characteristics || (e.tabInfo && e.tabInfo.characteristics) || {}
const agVal = findNumeric(e, ['ag', 'Ag', 'AG']) ?? findNumeric(char, ['ag'])
const wsVal = findNumeric(e, ['ws', 'Ws', 'WS']) ?? findNumeric(char, ['ws'])
const bsVal = findNumeric(e, ['bs', 'Bs', 'BS']) ?? findNumeric(char, ['bs'])
if (typeof agVal === 'number') setDefenderAg(agVal)
if (typeof wsVal === 'number') setDefenderWS(wsVal)
if (typeof bsVal === 'number') setDefenderBS(bsVal)
// Apply any stored/default defender modifier (try multiple common keys)
const modVal = findNumeric(e, ['defenderModifier','defenderMod','defMod','defenceModifier','defenceMod']) ?? findNumeric(e, ['modifier','mod'])
if (typeof modVal === 'number') setDefenderModifier(modVal)
else setDefenderModifier(0)
}
function importFromJSONText(txt) {
try {
const arr = JSON.parse(txt)
if (!Array.isArray(arr)) throw new Error('JSON must be an array')
const next = arr.map(normalizeWeapon).filter(w=>w.name)
const merged = appendMode ? mergeWeapons(weapons, next) : next
setWeapons(merged); setWeaponsJSON(JSON.stringify(merged,null,2)); setInfo(`Loaded ${next.length} weapons`); console.info('[DW] Imported JSON weapons:', next.length)
} catch (e) { setError('Invalid weapons JSON'); console.error('[DW] Import JSON error:', e) }
}
async function fetchWeaponsFromServer() {
setError(''); setInfo('')
const endpoints = ['/api/weapons', '/deathwatch-weapons.json', '/public/deathwatch-weapons.json']
let lastErr = null
for (const ep of endpoints) {
try {
const res = await fetch(ep, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
let list = []
// If the server returned the same shaped build DB (with rangedWeapons/meleeWeapons), map entries
if (json && (json.rangedWeapons || json.meleeWeapons || json.grenades || json.other)) {
const items = []
if (Array.isArray(json.rangedWeapons)) items.push(...json.rangedWeapons.map(mapBuildEntryToWeapon))
if (Array.isArray(json.meleeWeapons)) items.push(...json.meleeWeapons.map(mapBuildEntryToWeapon))
if (Array.isArray(json.grenades)) items.push(...json.grenades.map(mapBuildEntryToWeapon))
if (Array.isArray(json.other)) items.push(...json.other.map(mapBuildEntryToWeapon))
list = items
} else if (Array.isArray(json)) {
// Server might return an already-normalized array
list = json.map(item => {
// If item looks like a build entry, map it, otherwise normalize directly
if (item.stats || item.damage && item.damage.includes('d')) return normalizeWeapon(mapBuildEntryToWeapon(item))
return normalizeWeapon(item)
})
} else {
throw new Error('Unexpected payload')
}
const merged = appendMode ? mergeWeapons(weapons, list) : list
setWeapons(merged)
setWeaponsJSON(JSON.stringify(merged,null,2))
setInfo(`Fetched ${list.length} weapons from ${ep}`)
console.info('[DW] Fetched weapons from', ep, list.length)
return
} catch (e) {
lastErr = e
console.info('[DW] fetchWeaponsFromServer failed for', ep, e && e.message)
}
}
setError('Failed to fetch weapons from server')
console.error('[DW] fetchWeaponsFromServer last error:', lastErr)
}
function importFromCSVText(txt) {
try {
const rows = simpleCSVParse(txt)
const mapped = rows.map(r => normalizeWeapon({
name: r.name,
class: r.class,
damage: r.damage,
pen: Number(r.pen || 0),
rof: Number(r.rof || 1),
modes: String(r.modes || 'single').split('|').map(s => s.trim().toLowerCase()),
tearing: ['1', 'true', 'yes', 'y'].includes(String(r.tearing || '').toLowerCase()),
proven: Number(r.proven || 0),
reliable: ['1', 'true', 'yes', 'y'].includes(String(r.reliable || '').toLowerCase()),
}))
const merged = appendMode ? mergeWeapons(weapons, mapped) : mapped
setWeapons(merged); setWeaponsJSON(JSON.stringify(merged,null,2)); setInfo(`Loaded ${mapped.length} weapons from CSV`); console.info('[DW] Imported CSV weapons:', mapped.length)
} catch (e) { setError('Invalid CSV'); console.error('[DW] Import CSV error:', e) }
}
// ...existing code uses module-scope mergeWeapons helper
function exportWeapons() {
try {
const blob = new Blob([JSON.stringify(weapons,null,2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'deathwatch-weapons.json'
document.body.appendChild(a)
a.click()
a.remove()
setInfo('Exported current weapons to JSON')
console.info('[DW] Exported weapons:', weapons.length)
setTimeout(()=> URL.revokeObjectURL(url), 2500)
} catch (e) { setError('Export failed'); console.error('[DW] Export error:', e) }
}
function armourFor(where){ return (armourMap[where] ?? targetArmour) }
function runAttack() {
setError(''); setInfo('')
setProgressStep('attack')
setReactionUsed(false) // Reset reaction for new attack
let dmgSpec
try { dmgSpec = parseDice(damage) } catch (e) { setError('Invalid damage expression'); return }
// Use manual roll if manual mode is enabled
let attackRoll
if (manualMode && manualAttackRoll.trim()) {
const manualRoll = parseInt(manualAttackRoll.trim())
if (isNaN(manualRoll) || manualRoll < 1 || manualRoll > 100) {
setError('Manual attack roll must be a number between 1-100')
return
}
attackRoll = manualRoll
} else {
attackRoll = d100()
}
const isRanged = usingSkill==='BS'
const jamThreshold = isRanged ? (reliable ? 100 : 96) : 101
const jammed = attackRoll >= jamThreshold
const dg = degrees(target, attackRoll)
const where = hitLocationFromRoll(attackRoll)
if (dg.success) {
const hits = hitsFromDoS(mode, dg.dos, rof)
// Store attack info and prompt for defense roll
setPendingHits({
hits,
attackRoll,
where,
isRanged,
dmgSpec,
jammed,
mode,
rof
})
setAwaitingDefense(true)
setProgressStep('defense')
setInfo(`Attack hit! Roll defense to determine if damage is applied.`)
return
}
// Attack failed - no damage
const res = { id: uid(), ts: Date.now(), attackRoll, target, success: dg.success, dos: dg.dos, dof: dg.dof, mode, rof, hits: 0, where, jammed, perHit: [], weapon: weaponName || undefined, enemy: enemyName || undefined, using: usingSkill }
setHistory(prev => [res, ...prev].slice(0, 50))
setInfo(`Attack missed with DoF ${dg.dof}`)
// Clear progress after a short delay
setTimeout(()=> setProgressStep('idle'), 1200)
}
function resetTracker() {
setCurWounds(maxWounds)
setPartDamage({ 'Head':0, 'Body':0, 'Left Arm':0, 'Right Arm':0, 'Left Leg':0, 'Right Leg':0 })
}
function saveCurrentAsPreset() { if (!presetName.trim()) return; const p = { name: presetName.trim(), bs, ws, modifier, aim, mode, rof, damage, tearing, proven, pen, reliable, rfConfirm, targetTB, targetArmour, weapon: weaponName || undefined, enemy: enemyName || undefined }; setPresets(prev => { const others = prev.filter(x => x.name !== p.name); return [...others, p].sort((a,b)=>a.name.localeCompare(b.name)) }); setPresetName('') }
function applyPreset(p) { setBS(p.bs ?? bs); setWS(p.ws ?? ws); setModifier(p.modifier); setAim(p.aim); setMode(p.mode); setRof(p.rof); setDamage(p.damage); setTearing(p.tearing); setProven(p.proven); setPen(p.pen); setReliable(p.reliable); setRfConfirm(p.rfConfirm); setTargetTB(p.targetTB); setTargetArmour(p.targetArmour); if (p.weapon) { onSelectWeapon(p.weapon) } else { setWeaponName('') } if (p.enemy) { onSelectEnemy(p.enemy) } else { setEnemyName('Custom/None') } }
function deletePreset(name) { setPresets(prev => prev.filter(x => x.name !== name)) }
function clearHistory() { setHistory([]) }
function runDefense() {
setError(''); setInfo('')
setProgressStep('defense')
if (!pendingHits) return
// Check if reaction is already used
if (reactionUsed) {
setError('Reaction already used this round')
return
}
// Determine defense type and target
let defenseTarget, defenseSkill, defenseTypeName
const isRanged = pendingHits.isRanged
switch (defenseType) {
case 'dodge':
defenseSkill = defenderAg
defenseTypeName = 'Dodge'
break
case 'parry':
if (!isRanged) {
defenseSkill = defenderWS
defenseTypeName = 'Parry'
} else {
setError('Cannot parry ranged attacks')
return
}
break
case 'block':
defenseSkill = defenderWS
defenseTypeName = 'Block'
break
case 'cover':
defenseSkill = defenderAg
defenseTypeName = 'Cover'
break
default:
defenseSkill = isRanged ? defenderBS : defenderWS
defenseTypeName = isRanged ? 'BS' : 'WS'
}
// Calculate defense target with modifiers
let totalModifier = defenderModifier
if (defenseType === 'cover') totalModifier += 20 + coverBonus // Cover: +20 plus any input cover bonus
if (defenseType === 'block') totalModifier += 10 // Block provides +10 bonus
defenseTarget = Math.max(0, Math.min(100, defenseSkill + totalModifier))
// Use manual roll if manual mode is enabled
let defenseRoll
if (manualMode && manualDefenseRoll.trim()) {
const manualRoll = parseInt(manualDefenseRoll.trim())
if (isNaN(manualRoll) || manualRoll < 1 || manualRoll > 100) {
setError('Manual defense roll must be a number between 1-100')
return
}
defenseRoll = manualRoll
} else {
defenseRoll = d100()
}
const dg = degrees(defenseTarget, defenseRoll)
if (dg.success) {
setInfo(`${defenseTypeName} successful! Attack avoided with DoS (Degrees of Success) ${dg.dos}`)
const res = {
id: uid(),
ts: Date.now(),
attackRoll: pendingHits.attackRoll,
target,
success: true,
hits: pendingHits.hits,
where: pendingHits.where,
jammed: pendingHits.jammed,
perHit: [],
weapon: weaponName || undefined,
enemy: enemyName || undefined,
using: pendingHits.isRanged ? 'BS' : 'WS',
defenseRoll,
defenseTarget,
defenseType: defenseTypeName,
defenseFailed: false,
mode: pendingHits.mode,
rof: pendingHits.rof
}
setHistory(prev => [res, ...prev].slice(0, 50))
setAwaitingDefense(false)
setPendingHits(null)
setReactionUsed(true)
// Defense successful - no damage applied; show wounds briefly then idle
setProgressStep('wounds')
setTimeout(()=> setProgressStep('idle'), 1200)
return
}
// Defense failed, apply the pending hits
const where = pendingHits.where
const perHit = []
let totalApplied = 0
let nextParts = { ...partDamage }
for (let i=0; i<pendingHits.hits; i++) {
let r
if (manualMode && manualDamageRolls.trim()) {
// Parse manual damage rolls
const manualRolls = manualDamageRolls.trim().split(/[,\s]+/).map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n >= 1)
if (manualRolls.length === 0) {
setError('Manual damage rolls must be comma-separated numbers (e.g., "7, 9")')
return
}
r = { rolls: manualRolls, total: manualRolls.reduce((a,b) => a+b, 0) }
} else {
r = rollDice(pendingHits.dmgSpec.terms, { tearing, proven })
}
const tenCount = r.rolls.filter(x=>x===10).length
let rfExtras = []
if (tenCount>0) {
if (rfConfirm) {
let confirm
if (manualMode && manualAttackRoll.trim()) {
// Use a second manual roll for RF confirmation if available
const confirmRoll = parseInt(manualAttackRoll.trim())
if (!isNaN(confirmRoll) && confirmRoll >= 1 && confirmRoll <= 100) {
confirm = confirmRoll
} else {
confirm = d100()
}
} else {
confirm = d100()
}
if (confirm <= target) rfExtras = Array.from({length: tenCount}, ()=> Math.floor(Math.random()*10)+1)
} else {
rfExtras = Array.from({length: tenCount}, ()=> Math.floor(Math.random()*10)+1)
}
}
const raw = r.total + rfExtras.reduce((a,b)=>a+b,0) + pendingHits.dmgSpec.flat
const arForLoc = Math.max(0, armourFor(where) - pen)
const mitigated = mitigateDamage(raw, targetTB, arForLoc)
const flatText = pendingHits.dmgSpec.flat ? (' + ' + pendingHits.dmgSpec.flat) : ''
const rollsText = r.rolls.join(', ') + (rfExtras.length ? (' | RF: ' + rfExtras.join(', ')) : '')
const damageText = mitigated > 0 ? `${mitigated}` : `${mitigated} (Negated)`
perHit.push({ where, raw, mitigated, detail: `${rollsText}${flatText} - TB (Toughness Bonus) ${targetTB} - Armour(${where}) ${arForLoc} = ${damageText}` })
if (BODY_PARTS.includes(where)) nextParts[where] = (nextParts[where]||0) + mitigated
totalApplied += Math.max(0, mitigated|0)
}
const res = {
id: uid(),
ts: Date.now(),
attackRoll: pendingHits.attackRoll,
target,
success: true,
hits: pendingHits.hits,
where,
jammed: pendingHits.jammed,
perHit,
weapon: weaponName || undefined,
enemy: enemyName || undefined,
using: pendingHits.isRanged ? 'BS' : 'WS',
defenseRoll,
defenseTarget,
defenseType: defenseTypeName,
defenseFailed: true,
mode: pendingHits.mode,
rof: pendingHits.rof
}
setHistory(prev => [res, ...prev].slice(0, 50))
if (totalApplied>0) {
const applied = Math.max(0, Math.floor(totalApplied))
setPartDamage(nextParts)
setCurWounds(prev => {
const next = Math.max(0, Number(prev) - applied)
console.info('[DW] Applied damage', applied, '-> wounds', prev, '=>', next)
return next
})
setLastAppliedDamage(applied)
setInfo(`${defenseTypeName} failed with DoF (Degrees of Failure) ${dg.dof}. Applied ${applied} damage to ${where}`)
// Show damage then wounds
setProgressStep('damage')
setTimeout(()=> setProgressStep('wounds'), 250)
setTimeout(()=> setProgressStep('idle'), 2200)
} else {
setInfo(`${defenseTypeName} failed with DoF (Degrees of Failure) ${dg.dof}. No damage applied - attack completely negated by armour and toughness.`)
setLastAppliedDamage(0)
setProgressStep('wounds')
setTimeout(()=> setProgressStep('idle'), 1200)
}
setAwaitingDefense(false)
setPendingHits(null)
setReactionUsed(true)
}
const allowedModes = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); return w && Array.isArray(w.modes) && w.modes.length ? w.modes : ['single','semi','full'] },[weapons, weaponName])
const woundPct = maxWounds>0 ? Math.max(0, Math.min(100, (curWounds/maxWounds)*100)) : 0
const status = curWounds<=0 ? 'Dead' : (curWounds<maxWounds ? 'Wounded' : 'Alive')
const statusColor = status==='Dead' ? 'bg-rose-600' : status==='Wounded' ? 'bg-amber-500' : 'bg-emerald-600'
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-6 md:p-10">
<div className="mx-auto max-w-6xl space-y-6">
{(error||info) && (
<div className={`rounded-xl px-4 py-3 ${error? 'bg-rose-900/40 border border-rose-500/40 text-rose-200':'bg-emerald-900/30 border border-emerald-500/40 text-emerald-200'}`}>
{error ? error : <div dangerouslySetInnerHTML={{ __html: info }} />}
</div>
)}
<div className="flex items-center justify-between">
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Deathwatch Roller Pro</h1>
<div className="flex items-center gap-3">
<div className="text-xs uppercase tracking-wide opacity-70">Target {target} • {usingSkill}</div>
<span className={`text-xs px-2 py-1 rounded ${statusColor}`}>{status}</span>
<button
onClick={() => setInfo(`
<strong>Abbreviations Guide:</strong><br/>
• <strong>BS</strong> - Ballistic Skill (ranged attacks)<br/>
• <strong>WS</strong> - Weapon Skill (melee attacks)<br/>
• <strong>Ag</strong> - Agility (dodge/cover defense)<br/>
• <strong>TB</strong> - Toughness Bonus (reduces damage)<br/>
• <strong>AR</strong> - Armour Rating (reduces damage)<br/>
• <strong>Pen</strong> - Penetration (reduces armour)<br/>
• <strong>RoF</strong> - Rate of Fire (max shots)<br/>
• <strong>DoS</strong> - Degrees of Success<br/>
• <strong>DoF</strong> - Degrees of Failure<br/>
• <strong>RF</strong> - Righteous Fury (extra damage on 10s)<br/>
• <strong>W</strong> - Wounds (health points)<br/><br/>
<strong>Damage Calculation:</strong><br/>
Final Damage = Raw Damage - TB - (Armour - Pen)<br/>
If result ≤ 0, damage is completely negated<br/><br/>
<strong>Manual Mode:</strong><br/>
• Enable to input dice rolls manually<br/>
• Attack/Defense: Enter 1-100 values<br/>
• Damage: Enter comma-separated numbers (e.g., "7, 9")<br/>
• Useful for physical dice or GM-controlled rolls
`)}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600"
>
?
</button>
</div>
</div>
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 rounded-2xl bg-white/5 backdrop-blur border border-white/10 p-5 space-y-4 shadow-xl">
{awaitingDefense && pendingHits && (
<div className="mb-4 rounded-xl bg-amber-900/50 border border-amber-500/30 p-4">
<div className="text-lg font-bold mb-2">Incoming Attack!</div>
<div className="text-sm mb-4">
Attack hit with {pendingHits.hits} potential hit{pendingHits.hits !== 1 ? 's' : ''}.
Roll defense to determine if damage is applied.
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="text-xs uppercase opacity-70">Defense Type</label>
<select className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={defenseType} onChange={e=>setDefenseType(e.target.value)}>
<option value="dodge">Dodge (Agility)</option>
<option value="parry" disabled={pendingHits?.isRanged}>Parry (WS - Melee Only)</option>
<option value="block">Block (WS +10)</option>
<option value="cover">Cover (Agility +20)</option>
</select>
{pendingHits && (
<div className="text-xs opacity-60 mt-1">
{pendingHits.isRanged ? 'Ranged attack - Parry disabled' : 'Melee attack - Parry available'}
</div>
)}
</div>
<div>
<label className="text-xs uppercase opacity-70">Cover Bonus</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={coverBonus} onChange={e=>setCoverBonus(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Agility - Used for dodge and cover defenses">Defender's Ag</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={defenderAg} onChange={e=>setDefenderAg(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Weapon Skill - Used for parry and block defenses">Defender's WS</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={defenderWS} onChange={e=>setDefenderWS(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Defense Modifier</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={defenderModifier} onChange={e=>setDefenderModifier(parseInt(e.target.value||'0'))} />
</div>
<div className="flex items-center">
<label className="text-xs uppercase opacity-70">Reaction Used</label>
<input type="checkbox" className="ml-2 align-middle" checked={reactionUsed} onChange={e=>setReactionUsed(e.target.checked)} />
</div>
</div>
<div className="flex gap-2">
<button onClick={runDefense} className="rounded-xl px-4 py-2 bg-amber-600 hover:bg-amber-500">Roll Defense</button>
<button onClick={()=>{
// Apply damage without defense
const where = pendingHits.where
const perHit = []
let totalApplied = 0
let nextParts = { ...partDamage }
for (let i=0; i<pendingHits.hits; i++) {
let r
if (manualMode && manualDamageRolls.trim()) {
// Parse manual damage rolls
const manualRolls = manualDamageRolls.trim().split(/[,\s]+/).map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n >= 1)
if (manualRolls.length === 0) {
setError('Manual damage rolls must be comma-separated numbers (e.g., "7, 9")')
return
}
r = { rolls: manualRolls, total: manualRolls.reduce((a,b) => a+b, 0) }
} else {
r = rollDice(pendingHits.dmgSpec.terms, { tearing, proven })
}
const tenCount = r.rolls.filter(x=>x===10).length
let rfExtras = []
if (tenCount>0) {
if (rfConfirm) {
let confirm
if (manualMode && manualAttackRoll.trim()) {
// Use a second manual roll for RF confirmation if available
const confirmRoll = parseInt(manualAttackRoll.trim())
if (!isNaN(confirmRoll) && confirmRoll >= 1 && confirmRoll <= 100) {
confirm = confirmRoll
} else {
confirm = d100()
}
} else {
confirm = d100()
}
if (confirm <= target) rfExtras = Array.from({length: tenCount}, ()=> Math.floor(Math.random()*10)+1)
} else {
rfExtras = Array.from({length: tenCount}, ()=> Math.floor(Math.random()*10)+1)
}
}
const raw = r.total + rfExtras.reduce((a,b)=>a+b,0) + pendingHits.dmgSpec.flat
const arForLoc = Math.max(0, armourFor(where) - pen)
const mitigated = mitigateDamage(raw, targetTB, arForLoc)
const flatText = pendingHits.dmgSpec.flat ? (' + ' + pendingHits.dmgSpec.flat) : ''
const rollsText = r.rolls.join(', ') + (rfExtras.length ? (' | RF: ' + rfExtras.join(', ')) : '')
const damageText = mitigated > 0 ? `${mitigated}` : `${mitigated} (Negated)`
perHit.push({ where, raw, mitigated, detail: `${rollsText}${flatText} - TB (Toughness Bonus) ${targetTB} - Armour(${where}) ${arForLoc} = ${damageText}` })
if (BODY_PARTS.includes(where)) nextParts[where] = (nextParts[where]||0) + mitigated
totalApplied += Math.max(0, mitigated|0)
}
const res = {
id: uid(),
ts: Date.now(),
attackRoll: pendingHits.attackRoll,
target,
success: true,
hits: pendingHits.hits,
where,
jammed: pendingHits.jammed,
perHit,
weapon: weaponName || undefined,
enemy: enemyName || undefined,
using: pendingHits.isRanged ? 'BS' : 'WS',
defenseType: 'No Defense',
defenseFailed: true,
mode: pendingHits.mode,
rof: pendingHits.rof
}
setHistory(prev => [res, ...prev].slice(0, 50))
if (totalApplied>0) {
setPartDamage(nextParts)
setCurWounds(w => Math.max(0, w - totalApplied))
setInfo(`No defense used. Applied ${totalApplied} damage to ${where}`)
} else {
setInfo(`No defense used. No damage applied - attack completely negated by armour and toughness.`)
}
setAwaitingDefense(false)
setPendingHits(null)
}} className="rounded-xl px-4 py-2 bg-slate-700 hover:bg-slate-600">No Defense</button>
</div>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4 items-end">
<div className="lg:col-span-3">
<label className="text-xs uppercase opacity-70">Weapon</label>
<div className="flex gap-2">
<div className="flex-1">
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 mb-2" placeholder="Search weapons..." value={weaponFilter} onChange={e=>setWeaponFilter(e.target.value)} />
<select className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={weaponName} onChange={e=>onSelectWeapon(e.target.value)}>
<option value="">Custom</option>
{filteredWeapons.map(w => (<option key={w.name} value={w.name}>{w.name}</option>))}
</select>
</div>
<button onClick={()=>{ setManageWeapons(v=>!v); setManageTab('json'); setWeaponsJSON(JSON.stringify(weapons,null,2)); setWeaponsCSV('name,class,damage,pen,rof,modes,tearing,proven,reliable') }} className="rounded-xl px-3 py-2 bg-slate-700 hover:bg-slate-600">Manage</button>
</div>
</div>
<div className="lg:col-span-3">
<div className="flex items-center justify-between mb-1">
<label className="text-xs uppercase opacity-70">Enemy</label>
<div className="flex items-center gap-2">
{enemiesLoading && <span className="text-xs text-blue-400">Loading...</span>}
{enemiesError && <span className="text-xs text-red-400">Error: {enemiesError}</span>}
<button
onClick={loadEnemiesFromAPI}
disabled={enemiesLoading}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 disabled:opacity-50"
title="Refresh enemies from database"
>
↻
</button>
</div>
</div>
<select className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={enemyName} onChange={e=>onSelectEnemy(e.target.value)}>
{enemies.map(en => (<option key={en.name} value={en.name}>{en.name}{en.name==='Custom/None' ? '' : ` (TB ${en.tb} / AR Var / W ${en.wounds??'-'})`}</option>))}
</select>
<div className="text-xs opacity-60 mt-1">
<Tooltip text="Toughness Bonus">TB</Tooltip> / <Tooltip text="Armour Rating">AR</Tooltip> / <Tooltip text="Wounds">W</Tooltip> • {enemies.length} enemies loaded
</div>
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Ballistic Skill - Used for ranged attacks">Attacker's BS</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={bs} onChange={e=>setBS(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Weapon Skill - Used for melee attacks">Attacker's WS</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={ws} onChange={e=>setWS(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Modifier</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" value={modifier} onChange={e=>setModifier(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Aim</label>
<select className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={aim} onChange={e=>setAim(parseInt(e.target.value))}>
<option value={0}>None</option>
<option value={10}>Half (+10)</option>
<option value={20}>Full (+20)</option>
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Difficulty</label>
<select className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={difficulty} onChange={e=>setDifficulty(e.target.value)}>
<option value="easy">Easy (+20)</option>
<option value="normal">Normal (+0)</option>
<option value="hard">Hard (-20)</option>
<option value="deadly">Deadly (-30)</option>
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Mode</label>
<select className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={mode} onChange={e=>setMode(e.target.value)}>
{(['single','semi','full']).map(m => (
<option key={m} value={m} disabled={!allowedModes.includes(m)}>{m[0].toUpperCase()+m.slice(1)}</option>
))}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Rate of Fire - Maximum shots per attack">RoF</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={1} value={rof} onChange={e=>setRof(parseInt(e.target.value||'1'))} />
</div>
<div className="lg:col-span-3">
<label className="text-xs uppercase opacity-70">Damage</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={damage} onChange={e=>{ setDamage(e.target.value); if (error) setError('') }} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Tearing - Roll damage dice twice, take highest">Tearing</Tooltip>
</label>
<input type="checkbox" className="ml-2 align-middle" checked={tearing} onChange={e=>setTearing(e.target.checked)} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Proven - Minimum value for d10 damage dice">Proven</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} max={10} value={proven} onChange={e=>setProven(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Penetration - Reduces target's armour value">Pen</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={pen} onChange={e=>setPen(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Reliable - Weapon cannot jam on natural 96-00">Reliable</Tooltip>
</label>
<input type="checkbox" className="ml-2 align-middle" checked={reliable} onChange={e=>setReliable(e.target.checked)} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Righteous Fury Confirm - Roll to confirm extra damage on 10s">RF confirm</Tooltip>
</label>
<input type="checkbox" className="ml-2 align-middle" checked={rfConfirm} onChange={e=>setRfConfirm(e.target.checked)} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Toughness Bonus - Reduces incoming damage">Target TB</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={targetTB} onChange={e=>setTargetTB(parseInt(e.target.value||'0'))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">
<Tooltip text="Armour - Reduces incoming damage (Body location)">Target Armour (Body)</Tooltip>
</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={targetArmour} onChange={e=>{ const v = parseInt(e.target.value||'0'); setTargetArmour(v); setArmourMap(uniformArmourMap(v)) }} />
</div>
</div>
{/* Manual Mode Controls */}
<div className="rounded-xl bg-amber-900/20 border border-amber-500/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-amber-200">Manual Dice Mode</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={manualMode}
onChange={e=>{
setManualMode(e.target.checked)
if (!e.target.checked) {
// Clear manual rolls when disabling manual mode
setManualAttackRoll('')
setManualDefenseRoll('')
setManualDamageRolls('')
}
}}
className="rounded"
/>
Enable Manual Rolls
</label>
</div>
</div>
{manualMode && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div>
<label className="text-xs uppercase opacity-70">Manual Attack Roll (1-100)</label>
<input
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2"
type="number"
min="1"
max="100"
placeholder="e.g., 45"
value={manualAttackRoll}
onChange={e=>setManualAttackRoll(e.target.value)}
/>
</div>
<div>
<label className="text-xs uppercase opacity-70">Manual Defense Roll (1-100)</label>
<input
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2"
type="number"
min="1"
max="100"
placeholder="e.g., 67"
value={manualDefenseRoll}
onChange={e=>setManualDefenseRoll(e.target.value)}
/>
</div>
<div>
<label className="text-xs uppercase opacity-70">Manual Damage Rolls</label>
<input
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2"
type="text"
placeholder="e.g., 7, 9"
value={manualDamageRolls}
onChange={e=>setManualDamageRolls(e.target.value)}
/>
<div className="text-xs opacity-60 mt-1">Comma-separated numbers</div>
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<button onClick={runAttack} className="rounded-xl px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:scale-[.99] transition">Roll</button>
<div className="text-sm opacity-80">Target <span className="font-semibold">{target}</span></div>
</div>
{/* Progress tracker */}
<div className="mt-3">
<div className="flex items-center gap-2 text-sm">
<div className={`px-3 py-1 rounded font-semibold ${progressStep==='attack' ? 'bg-blue-600 text-white animate-pulse' : 'bg-white/5 text-white/80'}`}>Attack</div>
<div className={`px-3 py-1 rounded font-semibold ${progressStep==='defense' ? 'bg-amber-500 text-black animate-pulse' : 'bg-white/5 text-white/80'}`}>Defense</div>
<div className={`px-3 py-1 rounded font-semibold ${progressStep==='damage' ? 'bg-rose-500 text-black animate-pulse' : 'bg-white/5 text-white/80'}`}>Damage</div>
<div className={`px-3 py-1 rounded font-semibold ${progressStep==='wounds' ? 'bg-emerald-500 text-black animate-pulse' : 'bg-white/5 text-white/80'}`}>Wounds</div>
<div className="ml-3 text-xs opacity-70">Phase: <span className="font-semibold">{progressStep}</span></div>
<div className="ml-4 text-xs opacity-80">Last Applied: <span className="font-semibold">{lastAppliedDamage}</span></div>
</div>
</div>
{manageWeapons && (
<>
<div className="rounded-xl bg-white/5 border border-white/10 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">Manage Weapons</div>
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-1"><input type="checkbox" checked={appendMode} onChange={e=>setAppendMode(e.target.checked)} />Append</label>
<button onClick={()=>setManageWeapons(false)} className="rounded-xl px-3 py-1.5 bg-slate-700">Close</button>
</div>
</div>
<div className="flex gap-2 text-xs">
<button onClick={()=>setManageTab('json')} className={`px-2 py-1 rounded ${manageTab==='json'?'bg-slate-600':'bg-slate-800'}`}>JSON</button>
<button onClick={()=>setManageTab('csv')} className={`px-2 py-1 rounded ${manageTab==='csv'?'bg-slate-600':'bg-slate-800'}`}>CSV</button>
<button onClick={exportWeapons} className="px-2 py-1 rounded bg-emerald-700">Export JSON</button>
</div>
{manageTab==='json' ? (
<>
<textarea className="w-full h-48 rounded-xl border border-white/10 bg-black/20 px-3 py-2 font-mono text-xs" value={weaponsJSON} onChange={e=>setWeaponsJSON(e.target.value)} />
<div className="flex gap-2">
<button onClick={()=>importFromJSONText(weaponsJSON)} className="rounded-xl px-3 py-2 bg-blue-600">Import JSON</button>
<button onClick={()=>{ const merged = importBuildWeapons(weapons); setWeapons(merged); setWeaponsJSON(JSON.stringify(merged,null,2)); setInfo('Imported built DB weapons') }} className="rounded-xl px-3 py-2 bg-emerald-700">Import Built DB</button>
<button onClick={()=>{ fetchWeaponsFromServer() }} className="rounded-xl px-3 py-2 bg-indigo-700">Fetch from Server</button>
<button onClick={()=>{ setWeapons(defaultWeapons); setWeaponsJSON(JSON.stringify(defaultWeapons,null,2)); setInfo('Reset to defaults') }} className="rounded-xl px-3 py-2 bg-slate-700">Reset Defaults</button>
</div>
</>
) : (
<>
<textarea className="w-full h-48 rounded-xl border border-white/10 bg-black/20 px-3 py-2 font-mono text-xs" value={weaponsCSV} onChange={e=>setWeaponsCSV(e.target.value)} />
<div className="flex gap-2">
<button onClick={()=>importFromCSVText(weaponsCSV)} className="rounded-xl px-3 py-2 bg-blue-600">Import CSV</button>
</div>
</>
)}
</div>
{weapons.length>0 && (
<div className="mt-3 text-xs">
<div className="font-semibold mb-1">Weapons Loaded ({weapons.length})</div>
<div className="max-h-40 overflow-auto">
{weapons.slice(0,200).map(w => (
<div key={w.name} className="text-xs opacity-80 mb-1">{w.name} {w.req ? `• Req ${w.req}` : ''} {w.renown ? `• ${w.renown}` : ''} {w.source ? `• ${w.source}` : ''}</div>
))}
</div>
</div>
)}
</>
)}
{history[0] && (
<div className="rounded-xl bg-white/5 border border-white/10 p-4 space-y-2">
<div className="text-sm">{history[0].weapon ? `${history[0].weapon} • ` : ''}{history[0].using} • Attack <span className="font-semibold">{history[0].attackRoll}</span> → <span className="text-emerald-400">Hit!</span></div>
{history[0].defenseRoll && (
<div className="text-sm">{history[0].defenseType || 'Defense'} <span className="font-semibold">{history[0].defenseRoll}</span> vs {history[0].defenseTarget} → {history[0].defenseFailed ? <span className="text-rose-400">Failed!</span> : <span className="text-emerald-400">Defended!</span>}</div>
)}
<div className="text-sm">{history[0].mode?.toUpperCase() || 'SINGLE'} • RoF <span className="font-semibold">{history[0].rof || 1}</span> • Hits <span className="font-semibold">{history[0].hits}</span> • {history[0].where}{history[0].jammed && <span className="ml-2 text-rose-400">JAM</span>}</div>
{history[0].perHit && history[0].perHit.length > 0 && (
<div className="space-y-1">
<div className="text-sm font-medium">Damage</div>
<ul className="text-sm list-disc ml-6">
{history[0].perHit.map((h, i)=>(<li key={i}><span className="font-semibold">{h.mitigated}</span> ({h.detail})</li>))}
</ul>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-white/10">
<div className="flex gap-2">
<input className="flex-1 rounded-xl border border-white/10 bg-white/10 px-3 py-2" placeholder="Preset name" value={presetName} onChange={e=>setPresetName(e.target.value)} />
<button onClick={saveCurrentAsPreset} className="rounded-xl px-3 py-2 bg-slate-700 hover:bg-slate-600">Save</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{presets.map(p => (
<div key={p.name} className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 flex items-center gap-2">
<button className="underline" onClick={()=>applyPreset(p)}>{p.name}</button>
<button onClick={()=>deletePreset(p.name)} className="text-xs px-2 py-1 rounded bg-rose-600">x</button>
</div>
))}
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-2xl bg-white/5 backdrop-blur border border-white/10 p-5 shadow-xl">
<div className="flex items-center justify-between mb-3">
<div className="font-semibold">Enemy Wounds Tracker</div>
<button onClick={resetTracker} className="text-xs rounded px-2 py-1 bg-slate-700 hover:bg-slate-600">Reset</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs uppercase opacity-70">Max Wounds</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={maxWounds} onChange={e=>setMaxWounds(Math.max(0, parseInt(e.target.value||'0')))} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Current Wounds</label>
<input className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={curWounds} onChange={e=>setCurWounds(Math.max(0, parseInt(e.target.value||'0')))} />
</div>
<div className="col-span-2">
<div className="h-2 w-full bg-white/10 rounded overflow-hidden">
<div className="h-2 bg-emerald-500" style={{width: `${woundPct}%`}}></div>
</div>
<div className="text-xs opacity-70 mt-1">{curWounds}/{maxWounds} remaining</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 sm:grid-cols-3 gap-2">
{BODY_PARTS.map(p => (
<div key={p} className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs opacity-70">{p}</div>
<div className="text-lg font-semibold">{partDamage[p]||0}</div>
<div className="text-xs opacity-60 mt-1">
<Tooltip text="Armour Rating">AR</Tooltip> {armourMap[p]??targetArmour}
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl bg-white/5 backdrop-blur border border-white/10 p-5 shadow-xl flex flex-col">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold">Roll History</div>
<button onClick={clearHistory} className="text-xs rounded px-2 py-1 rounded-xl bg-slate-700 hover:bg-slate-600">Clear</button>
</div>
<div className="overflow-y-auto space-y-2 pr-2" style={{maxHeight:'520px'}}>
{history.length===0 && <div className="text-sm opacity-70">No rolls yet</div>}
{history.map(r => (
<div key={r.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="flex items-center justify-between text-xs opacity-80">
<div>{new Date(r.ts).toLocaleTimeString()}</div>
<div>{r.weapon ? `${r.weapon} • ` : ''}{r.using || 'Unknown'} • {r.mode ? r.mode.toUpperCase() : 'SINGLE'} • RoF {r.rof || 1}</div>
</div>
<div className="text-sm mt-1">{r.success ? <span className="text-emerald-400">Success</span> : <span className="text-rose-400">Fail</span>} • Roll {r.attackRoll || '?'} • Target {r.target || '?'} • Hits {r.hits || 0} • {r.where || 'Unknown'}{r.jammed ? ' • JAM' : ''}</div>
{r.success && r.hits > 0 && r.perHit && Array.isArray(r.perHit) && (
<div className="text-xs opacity-80">{r.enemy? (r.enemy + ' • '): ''}{r.perHit.map((h,i)=>`${i+1}:${h.mitigated || 0}`).join(' ')}</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>
</section>
)
}
function __run_tests__() {
const a = parseDice('1d10+5'); if (!(a.terms.length===1 && a.terms[0].count===1 && a.terms[0].faces===10 && a.flat===5)) throw new Error('parseDice a')
const b = parseDice('2d10+3'); if (!(b.terms.length===1 && b.terms[0].count===2 && b.terms[0].faces===10 && b.flat===3)) throw new Error('parseDice b')
const c1 = degrees(50,30); if (!(c1.success && c1.dos===3)) throw new Error('degrees success')
const c2 = degrees(50,70); if (!(!c2.success && c2.dof===3)) throw new Error('degrees fail')
if (hitsFromDoS('semi',3,3)!==2) throw new Error('hits semi')
if (hitsFromDoS('full',3,3)!==3) throw new Error('hits full')
if (hitsFromDoS('single',5,2)!==1) throw new Error('hits single')
if (hitLocationFromRoll(1)!=='Head') throw new Error('hitloc head')
if (hitLocationFromRoll(99)!=='Left Leg') throw new Error('hitloc leg')
if (hitLocationFromRoll(15)!=='Body') throw new Error('hitloc body')
if (mitigateDamage(15,4,5)!==6) throw new Error('mitigate a')
if (mitigateDamage(7,4,5)!==0) throw new Error('mitigate b')
const p = parseDice('1d10'); if (!(p.terms[0].count===1 && p.terms[0].faces===10 && p.flat===0)) throw new Error('parseDice c')
if (hitsFromDoS('semi',1,3)!==1) throw new Error('hits semi edge')
if (mitigateDamage(3,4,5)!==0) throw new Error('mitigate floor')
try { parseDice('1d10+foo'); throw new Error('parseDice invalid should throw') } catch {}
const z = (()=>{ try { parseDice('1d10+bar'); return 'ok' } catch { return 'err' } })(); if (z!=='err') throw new Error('invalid dice not handled')
const eq = degrees(55,55); if (!(eq.success && eq.dos===1)) throw new Error('degrees equality')
if (hitsFromDoS('semi',0,3)!==1) throw new Error('semi min hits')
const sp = parseDice(' 2D10 + 5 '); if (!(sp.terms[0].count===2 && sp.terms[0].faces===10 && sp.flat===5)) throw new Error('parseDice spaces')
const emp = parseDice(''); if (!(emp.terms[0].count===1 && emp.terms[0].faces===10 && emp.flat===0)) throw new Error('parseDice empty')
try { parseDice('d10') } catch {}
try { parseDice('0d10') } catch {}
try { parseDice('1d0') } catch {}
if (hitsFromDoS('single',3,0)!==1) throw new Error('single min hits')
const csv1 = 'name,class,damage\nFoo,basic,1d10+5'; const r1 = simpleCSVParse(csv1); if (!(r1.length===1 && r1[0].name==='Foo')) throw new Error('csv lf')
const csv2 = 'a,b\r\nc,d'; const r2 = simpleCSVParse(csv2); if (!(r2.length===1 && r2[0].a==='c' && r2[0].b==='d')) throw new Error('csv crlf')
}
// Run tests only in development
if (process.env.NODE_ENV === 'development') {
let __DW_TESTS__RAN = false
try {
if (!__DW_TESTS__RAN) {
__run_tests__()
__DW_TESTS__RAN = true
}
} catch (e) {
console.error('Test run failed:', e)
}
}
export default DeathwatchRoller;
// Named export for reuse in other components
export { Tooltip };