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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user