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.
This commit is contained in:
2025-08-17 18:15:39 +02:00
parent caf8a03553
commit 4c1b19fb8c
18 changed files with 4606 additions and 653 deletions
+61 -34
View File
@@ -63,31 +63,47 @@ export default function BestiaryTab(){
// hydrate from storage on mount
useEffect(()=>{ try{ const raw = localStorage.getItem(STORAGE_ENEMIES); if(raw){ const parsed = JSON.parse(raw); const arr = Array.isArray(parsed) ? parsed : (parsed.results || []); setEnemies(arr.map(normalizeEntry)) } }catch(e){} },[])
// loadData attempts to fetch packaged JSON; on failure it will fall back to cache and set dbDown
// loadData attempts to fetch from database API; on failure it will fall back to cache and set dbDown
async function loadData(force = false){
setIsRefreshing(true)
const cacheParam = force ? '?_=' + Date.now() : ''
try {
// Try database API first
const response = await fetch(`/api/bestiary/full${cacheParam}`)
if (response.ok) {
const data = await response.json()
if (Array.isArray(data) && data.length > 0) {
setEnemies(data.map(normalizeEntry))
localStorage.setItem(STORAGE_ENEMIES, JSON.stringify(data))
setDbDown(false)
setIsRefreshing(false)
console.log(`Loaded ${data.length} enemies from database API`)
return
}
}
} catch (error) {
console.error('Database API failed:', error)
}
// Fallback to file endpoints
const endpoints = ['/deathwatch-bestiary-extracted.json','/public/deathwatch-bestiary-extracted.json','/build/deathwatch-bestiary-extracted.json']
for(const ep of endpoints){
try{
const res = await fetch(ep + cacheParam, { cache: force ? 'no-store' : 'default' })
if(!res.ok) throw new Error('bad')
const json = await res.json()
let list = []
if(Array.isArray(json)) list = json
else if(Array.isArray(json.results)) list = json.results
else continue
const norm = list.map(normalizeEntry)
setEnemies(norm)
try{ localStorage.setItem(STORAGE_ENEMIES, JSON.stringify(norm)) }catch(e){}
setDbDown(false)
setIsRefreshing(false)
// clear any retry if present
if(retryRef.current){ clearInterval(retryRef.current); retryRef.current = null }
return
}catch(e){
continue
}
const res = await fetch(ep + cacheParam)
if(res.ok){
const data = await res.json()
const arr = Array.isArray(data) ? data : (data.results || [])
if(arr.length>0){
setEnemies(arr.map(normalizeEntry))
localStorage.setItem(STORAGE_ENEMIES, JSON.stringify(arr))
setDbDown(false)
setIsRefreshing(false)
console.log(`Loaded ${arr.length} enemies from ${ep}`)
return
}
}
}catch(e){}
}
// If we reach here, all endpoints failed
@@ -96,31 +112,42 @@ export default function BestiaryTab(){
// Try reading from cache
try{
const raw = localStorage.getItem(STORAGE_ENEMIES)
if(raw){ const parsed = JSON.parse(raw); const arr = Array.isArray(parsed) ? parsed : (parsed.results || []); setEnemies(arr.map(normalizeEntry)) }
if(raw){
const parsed = JSON.parse(raw)
const arr = Array.isArray(parsed) ? parsed : (parsed.results || [])
if(arr.length>0){
setEnemies(arr.map(normalizeEntry))
console.log(`Loaded ${arr.length} enemies from localStorage cache`)
return
}
}
}catch(e){}
// ensure a background retry is scheduled
if(!retryRef.current){
retryRef.current = setInterval(()=>{ loadData(true).catch(()=>{}); }, 30000)
}
// No data available
setEnemies([])
console.log('No enemy data available')
}
async function updateFromDatabase() {
setIsRefreshing(true)
try {
// Try to call a copy script endpoint if available
const response = await fetch('/api/copy-bestiary', {
// First try to reload the database
const reloadResponse = await fetch('/api/bestiary/reload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
headers: {
'Content-Type': 'application/json',
'x-gm-secret': 'bongo'
}
})
if (response.ok) {
// If the endpoint exists, wait a moment then refresh
setTimeout(() => loadData(true), 1000)
} else {
// Fallback to regular refresh
await loadData(true)
if (reloadResponse.ok) {
console.log('Database reloaded successfully')
}
// Then refresh the data (force=true to bypass cache)
await loadData(true)
} catch (e) {
console.error('Update from database failed:', e)
// Fallback to regular refresh
await loadData(true)
}
+51 -33
View File
@@ -96,7 +96,6 @@ 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_ENEMIES = 'dw:enemies:v1'
const STORAGE_TRACKER = 'dw:tracker:v1'
const BODY_PARTS = ['Head','Body','Left Arm','Right Arm','Left Leg','Right Leg']
@@ -163,28 +162,7 @@ function mapBuildEntryToWeapon(entry) {
}
// Normalize bestiary entry shapes (same logic as BestiaryTab) so different consumers get a consistent shape
function normalizeEntry(en){
const stats = en && en.stats ? en.stats : {}
const profile = en.profile || stats.profile || null
const wounds = (typeof en.wounds !== 'undefined' && en.wounds !== null) ? en.wounds : (typeof stats.wounds !== 'undefined' ? stats.wounds : null)
return {
name: en.name || en.bestiaryName || (stats && (stats.bestiaryName || stats.name)) || en.pdf || '<no-name>',
book: en.book || en.source || en.pdf || '',
pdf: en.pdf || '',
page: en.page ?? stats.page ?? null,
profile,
wounds,
movement: en.movement || stats.movement || null,
toughness: en.toughness || stats.toughness || null,
armour: en.armour || stats.armour || null,
skills: en.skills || stats.skills || null,
talents: en.talents || stats.talents || null,
traits: en.traits || stats.traits || null,
weapons: en.weapons || stats.weapons || null,
gear: en.gear || stats.gear || null,
stats
}
}
// Removed normalizeEntry function - now using database API transformation
function buildWeaponsList() {
if (!_builtWeapons) return []
@@ -352,15 +330,41 @@ function DeathwatchRoller() {
return defaultWeapons
})
const [weaponName,setWeaponName] = useState('')
const [enemies] = useState(()=> {
try{
const stored = safeGet(STORAGE_ENEMIES)
if (stored && Array.isArray(stored) && stored.length>0) return stored.map(normalizeEntry)
return defaultEnemies
}catch(e){ return defaultEnemies }
})
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)))
@@ -400,7 +404,7 @@ function DeathwatchRoller() {
useEffect(()=>{ safeSet(STORAGE_HISTORY, history) },[history])
useEffect(()=>{ safeSet(STORAGE_PRESETS, presets) },[presets])
useEffect(()=>{ safeSet(STORAGE_WEAPONS, weapons) },[weapons])
useEffect(()=>{ safeSet(STORAGE_ENEMIES, enemies) },[enemies])
// 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
@@ -1052,12 +1056,26 @@ function DeathwatchRoller() {
</div>
</div>
<div className="lg:col-span-3">
<label className="text-xs uppercase opacity-70">Enemy</label>
<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>
<Tooltip text="Toughness Bonus">TB</Tooltip> / <Tooltip text="Armour Rating">AR</Tooltip> / <Tooltip text="Wounds">W</Tooltip> • {enemies.length} enemies loaded
</div>
</div>
<div>