require('dotenv').config(); const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); // MariaDB routes const playerRoutes = require('./routes/playerRoutes'); const sessionRoutes = require('./routes/sessionRoutes'); const shopRoutes = require('./routes/shopRoutes'); const rulesRoutes = require('./routes/rulesRoutes'); const bestiaryRoutes = require('./routes/bestiaryRoutes'); const weaponsRoutes = require('./routes/weaponsRoutes'); const rulesStagingRoutes = require('./routes/rulesStagingRoutes'); const missionRoutes = require('./routes/missionRoutes'); const simulationRoutes = require('./routes/simulationRoutes'); const errorRoutes = require('./routes/errorRoutes'); const musicRoutes = require('./routes/musicRoutes'); // const rulesRoutes = require('./routes/rulesRoutes-simple'); const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit'); // Initialize MariaDB require('./mariadb'); console.log('Routes loaded:', { shopRoutes: typeof shopRoutes, rulesRoutes: typeof rulesRoutes, bestiaryRoutes: typeof bestiaryRoutes, weaponsRoutes: typeof weaponsRoutes }); const app = express(); const PORT = process.env.PORT || 5000; // Middleware app.use(express.json()); app.use(cors()); // API Routes (before static files) console.log('Registering API routes...'); // Player routes now working with MariaDB try { console.log('Registering /api/players'); app.use('/api/players', playerRoutes); console.log('Players routes registered'); } catch (e) { console.error('Error mounting /api/players:', e && e.stack ? e.stack : e); throw e; } try { console.log('Registering /api/sessions'); app.use('/api/sessions', sessionRoutes); console.log('Sessions routes registered'); } catch (e) { console.error('Error mounting /api/sessions:', e && e.stack ? e.stack : e); throw e; } try { console.log('Registering /api/shop'); app.use('/api/shop', shopRoutes); console.log('Shop routes registered'); } catch (e) { console.error('Error mounting /api/shop:', e && e.stack ? e.stack : e); throw e; } try { console.log('Registering /api/rules'); app.use('/api/rules', rulesRoutes); console.log('Rules routes registered'); } catch (e) { console.error('Error mounting /api/rules:', e && e.stack ? e.stack : e); throw e; } try { console.log('Registering /api/weapons'); app.use('/api/weapons', weaponsRoutes); console.log('Weapons routes registered'); } catch (e) { console.error('Error mounting /api/weapons:', e && e.stack ? e.stack : e); throw e; } try { console.log('Registering /api/bestiary'); app.use('/api/bestiary', bestiaryRoutes); console.log('Bestiary routes registered'); } catch (e) { console.error('Error mounting /api/bestiary:', e && e.stack ? e.stack : e); throw e; } try { console.log('Registering /api/rules/staging'); app.use('/api/rules/staging', rulesStagingRoutes); console.log('Rules staging routes registered'); } catch (e) { console.error('Error mounting /api/rules/staging:', e && e.stack ? e.stack : e); } try { console.log('Registering /api/missions'); app.use('/api/missions', missionRoutes); console.log('Mission routes registered'); } catch (e) { console.error('Error mounting /api/missions:', e && e.stack ? e.stack : e); } try { console.log('Registering /api/simulations'); app.use('/api/simulations', simulationRoutes); console.log('Simulation routes registered'); } catch (e) { console.error('Error mounting /api/simulations:', e && e.stack ? e.stack : e); } try { console.log('Registering /api/errors'); app.use('/api/errors', errorRoutes); console.log('Error report routes registered'); } catch (e) { console.error('Error mounting /api/errors:', e && e.stack ? e.stack : e); } try { console.log('Registering /api/music'); app.use('/api/music', musicRoutes); console.log('Music routes registered'); } catch (e) { console.error('Error mounting /api/music:', e && e.stack ? e.stack : e); } // Expose gamemaster kit files and a simple listing API for GM-only resources try { console.log('Registering /api/gmkit and /gmkit static'); app.get('/api/gmkit/list', (req, res) => { try { if (!fs.existsSync(gmkitDir)) return res.json([]); const files = fs.readdirSync(gmkitDir).filter(f => !f.startsWith('.')); const list = files.map(f => { const filePath = path.join(gmkitDir, f); let stat = null; try { stat = fs.statSync(filePath); } catch (e) { stat = null; } const ext = path.extname(f).toLowerCase().replace('.', ''); // minimal mime mapping const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', pdf: 'application/pdf' }; const mime = mimeMap[ext] || 'application/octet-stream'; return { name: f, url: `/gmkit/${encodeURIComponent(f)}`, size: stat ? stat.size : null, mime, ext }; }); res.json(list); } catch (err) { console.error('Failed to list gmkit files', err); res.status(500).json({ error: 'Failed to list gmkit files' }); } }); app.use('/gmkit', express.static(gmkitDir)); console.log('GMKit routes registered'); } catch (e) { console.error('Error mounting /api/gmkit or /gmkit:', e && e.stack ? e.stack : e); } // Simple JSON base64 upload endpoint for GM Kit files (GM-only via session) const { requireGM } = require('./auth'); app.post('/api/gmkit/upload', express.json({ limit: '20mb' }), async (req, res) => { try { if (!(await requireGM(req, res))) return; const { name, b64 } = req.body || {}; if (!name || !b64) return res.status(400).json({ error: 'Missing name or b64 body' }); if (!fs.existsSync(gmkitDir)) fs.mkdirSync(gmkitDir, { recursive: true }); // Basic sanitize name const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, '_'); const filePath = path.join(gmkitDir, safeName); // Decode base64 (allow data: prefix) const m = b64.match(/^data:([\w/+-\.]+);base64,(.*)$/); const data = m ? m[2] : b64; const buf = Buffer.from(data, 'base64'); fs.writeFileSync(filePath, buf); return res.json({ success: true, name: safeName, url: `/gmkit/${encodeURIComponent(safeName)}` }); } catch (err) { console.error('GMKit upload failed', err); return res.status(500).json({ error: 'Upload failed' }); } }); // Add bestiary copy endpoint app.post('/api/copy-bestiary', (req, res) => { try { const { copyBestiaryToPublic } = require('../scripts/copy-bestiary-live'); const success = copyBestiaryToPublic(); if (success) { res.json({ success: true, message: 'Bestiary files updated successfully' }); } else { res.status(500).json({ success: false, message: 'Failed to update bestiary files' }); } } catch (error) { console.error('Error in copy-bestiary endpoint:', error); res.status(500).json({ success: false, message: 'Internal server error', error: error.message }); } }); // Local Ollama narrator — used by MissionSimTab callCopilot() app.post('/api/narrate', express.json({ limit: '4mb' }), async (req, res) => { try { const { prompt, systemPrompt } = req.body || {}; if (!prompt) return res.status(400).json({ error: 'Missing prompt' }); const ollamaBase = process.env.OLLAMA_BASE || 'http://localhost:11434'; const model = process.env.NARRATOR_MODEL || 'qwen3.5:latest'; const messages = []; if (systemPrompt) messages.push({ role: 'system', content: systemPrompt }); messages.push({ role: 'user', content: `/no_think ${prompt}` }); const response = await fetch(`${ollamaBase}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages, stream: false, think: false }), }); if (!response.ok) { const errText = await response.text(); return res.status(502).json({ error: 'Ollama error', detail: errText }); } const data = await response.json(); const text = data.message?.content || data.response || ''; return res.json({ text }); } catch (err) { console.error('Narrator error:', err); return res.status(500).json({ error: 'Narrator failed', detail: err.message }); } }); // Serve static files from build directory (React app) const buildDir = path.join(__dirname, '..', 'build'); app.use(express.static(buildDir)); // Serve weapon images directly from public/ so new images appear without rebuild const weaponImagesDir = path.join(__dirname, '..', 'public', 'weapon-images'); fs.mkdirSync(weaponImagesDir, { recursive: true }); app.use('/weapon-images', express.static(weaponImagesDir)); // Serve uploaded avatars from public/avatars const avatarsDir = path.join(__dirname, '..', 'public', 'avatars'); if (!fs.existsSync(avatarsDir)) { fs.mkdirSync(avatarsDir, { recursive: true }); } app.use('/avatars', express.static(avatarsDir)); // Serve mood music files from media/music const musicDir = path.join(__dirname, '..', 'media', 'music'); if (!fs.existsSync(musicDir)) { fs.mkdirSync(musicDir, { recursive: true }); } app.use('/music', express.static(musicDir)); // Catch-all handler for React Router (must be after API routes) // app.get('*', (req, res) => { // res.sendFile(path.join(buildDir, 'index.html')); // }); // Graceful shutdown - MariaDB connections are handled by the pool process.on('SIGINT', () => { console.log('Shutting down server...'); process.exit(0); }); process.on('SIGTERM', () => { console.log('Shutting down server...'); process.exit(0); }); // If a React build exists, serve it at root and fallback to index.html for client-side routing const indexHtml = path.join(buildDir, 'index.html'); if (fs.existsSync(indexHtml)) { app.get('/', (req, res) => res.sendFile(indexHtml)); // Catch-all for client-side routes (must be after API routes) // Middleware fallback for client-side routing: serve index.html for non-API and non-avatar paths. // Use a plain middleware (no path) to avoid route string parsing by path-to-regexp. app.use((req, res, next) => { if (req.path.startsWith('/api/') || req.path.startsWith('/avatars/') || req.path.startsWith('/music/') || req.path.startsWith('/gmkit/')) return next(); return res.sendFile(indexHtml); }); } else { // Root route for friendly message when no build is present app.get('/', (req, res) => { res.send('Deathwatch Roller API is running with MariaDB. Use /api/shop for shop data.'); }); } // Routes have already been registered above - no need to duplicate // Start Server app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on http://0.0.0.0:${PORT}`); console.log('Using MariaDB database'); }); module.exports = app;