From fe1b77ec68ec411267b2aa794bc2c3bfb69ef1b3 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 23 Apr 2026 10:07:46 +0200 Subject: [PATCH] Add mission persistence: missions table, API routes, and history UI - MariaDB missions table with scenes JSON - /api/missions CRUD routes - MissionTab saves completed missions to DB - Mission history panel in UI Co-Authored-By: Claude Opus 4.7 --- database/mariadb.js | 93 ++++++++ database/routes/missionRoutes.js | 66 ++++++ database/server.js | 13 +- src/App.js | 12 +- src/components/MissionTab.jsx | 388 ++++++++++++++++++++++++++----- 5 files changed, 505 insertions(+), 67 deletions(-) create mode 100644 database/routes/missionRoutes.js diff --git a/database/mariadb.js b/database/mariadb.js index 5654132..c03760f 100644 --- a/database/mariadb.js +++ b/database/mariadb.js @@ -88,6 +88,26 @@ const createTables = async () => { ) `); + // Missions table + await connection.execute(` + CREATE TABLE IF NOT EXISTS missions ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(500) NOT NULL DEFAULT 'Untitled Mission', + theme VARCHAR(100) NOT NULL DEFAULT 'tyranid', + scene_count INT NOT NULL DEFAULT 4, + enemy_count INT NOT NULL DEFAULT 3, + threat_level VARCHAR(50) NOT NULL DEFAULT 'Medium', + player_count INT NOT NULL DEFAULT 3, + scenes JSON DEFAULT ('[]'), + gm_player VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `); + + await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_created_at ON missions(created_at)`); + await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_theme ON missions(theme)`); + // Create indexes await connection.execute(`CREATE INDEX IF NOT EXISTS idx_players_name ON players(name)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)`); @@ -441,6 +461,78 @@ const bestiaryHelpers = { } }; +// Mission helpers +const missionHelpers = { + getAll: async () => { + try { + const [rows] = await pool.execute('SELECT * FROM missions ORDER BY created_at DESC'); + return rows.map(row => ({ + ...row, + scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes + })); + } catch (error) { + logToFile('MariaDB: Error getting all missions', error); + return []; + } + }, + + getById: async (id) => { + try { + const [rows] = await pool.execute('SELECT * FROM missions WHERE id = ?', [id]); + if (rows.length === 0) return null; + const row = rows[0]; + return { + ...row, + scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes + }; + } catch (error) { + logToFile('MariaDB: Error getting mission', id, error); + return null; + } + }, + + create: async (missionData) => { + try { + const { name, theme, sceneCount, enemyCount, threatLevel, playerCount, scenes, gmPlayer } = missionData; + const [result] = await pool.execute( + 'INSERT INTO missions (name, theme, scene_count, enemy_count, threat_level, player_count, scenes, gm_player) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [name, theme, sceneCount, enemyCount, threatLevel, playerCount, JSON.stringify(scenes || []), gmPlayer || null] + ); + logToFile('MariaDB: Created mission', name); + return result.insertId; + } catch (error) { + logToFile('MariaDB: Error creating mission', error); + return null; + } + }, + + update: async (id, missionData) => { + try { + const { name, theme, sceneCount, enemyCount, threatLevel, playerCount, scenes } = missionData; + const [result] = await pool.execute( + 'UPDATE missions SET name = ?, theme = ?, scene_count = ?, enemy_count = ?, threat_level = ?, player_count = ?, scenes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [name, theme, sceneCount, enemyCount, threatLevel, playerCount, JSON.stringify(scenes), id] + ); + logToFile('MariaDB: Updated mission', id); + return result.affectedRows > 0; + } catch (error) { + logToFile('MariaDB: Error updating mission', id, error); + return false; + } + }, + + delete: async (id) => { + try { + const [result] = await pool.execute('DELETE FROM missions WHERE id = ?', [id]); + logToFile('MariaDB: Deleted mission', id); + return result.affectedRows > 0; + } catch (error) { + logToFile('MariaDB: Error deleting mission', id, error); + return false; + } + } +}; + // Initialize database createTables().catch(error => { console.error('Failed to initialize MariaDB:', error); @@ -456,5 +548,6 @@ module.exports = { stagingHelpers, weaponsHelpers, bestiaryHelpers, + missionHelpers, logToFile }; diff --git a/database/routes/missionRoutes.js b/database/routes/missionRoutes.js new file mode 100644 index 0000000..df25bfe --- /dev/null +++ b/database/routes/missionRoutes.js @@ -0,0 +1,66 @@ +const express = require('express'); +const { missionHelpers, logToFile } = require('../mariadb'); +const router = express.Router(); + +// Get all missions +router.get('/', async (req, res) => { + try { + const missions = await missionHelpers.getAll(); + res.json(missions); + } catch (error) { + logToFile('API: Failed to get missions', error); + res.status(500).json({ error: String(error) }); + } +}); + +// Get mission by ID +router.get('/:id', async (req, res) => { + try { + const mission = await missionHelpers.getById(req.params.id); + if (!mission) return res.status(404).json({ error: 'Mission not found' }); + res.json(mission); + } catch (error) { + logToFile('API: Failed to get mission', req.params.id, error); + res.status(500).json({ error: String(error) }); + } +}); + +// Create mission +router.post('/', async (req, res) => { + try { + const id = await missionHelpers.create(req.body); + if (!id) return res.status(500).json({ error: 'Failed to create mission' }); + logToFile('API: Created mission', req.body.name); + res.json({ success: true, id }); + } catch (error) { + logToFile('API: Failed to create mission', error); + res.status(500).json({ error: String(error) }); + } +}); + +// Update mission +router.put('/:id', async (req, res) => { + try { + const ok = await missionHelpers.update(req.params.id, req.body); + if (!ok) return res.status(404).json({ error: 'Mission not found' }); + res.json({ success: true }); + } catch (error) { + logToFile('API: Failed to update mission', req.params.id, error); + res.status(500).json({ error: String(error) }); + } +}); + +// Delete mission +router.delete('/:id', async (req, res) => { + try { + const ok = await missionHelpers.delete(req.params.id); + if (!ok) return res.status(404).json({ error: 'Mission not found' }); + res.json({ success: true }); + } catch (error) { + logToFile('API: Failed to delete mission', req.params.id, error); + res.status(500).json({ error: String(error) }); + } +}); + +console.log('Mission routes registered (MariaDB)'); +module.exports = router; diff --git a/database/server.js b/database/server.js index 54eeeec..d610406 100755 --- a/database/server.js +++ b/database/server.js @@ -11,6 +11,7 @@ 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 rulesRoutes = require('./routes/rulesRoutes-simple'); const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit'); @@ -92,7 +93,17 @@ try { console.log('Rules staging routes registered'); } catch (e) { console.error('Error mounting /api/rules/staging:', e && e.stack ? e.stack : e); - } // Expose gamemaster kit files and a simple listing API for GM-only resources + } + + 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); + } + + // 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) => { diff --git a/src/App.js b/src/App.js index e2b9e38..b2aabb2 100755 --- a/src/App.js +++ b/src/App.js @@ -14,7 +14,7 @@ import axios from 'axios'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; function App() { - const [tab, setTab] = useState('roller'); + const [tab, setTab] = useState('mission'); // Global login/session state const [authedPlayer, setAuthedPlayer] = useState(() => localStorage.getItem('dw:shop:authedPlayer') ? JSON.parse(localStorage.getItem('dw:shop:authedPlayer')) : ''); const [sessionId, setSessionId] = useState(() => localStorage.getItem('dw:shop:sessionId') ? JSON.parse(localStorage.getItem('dw:shop:sessionId')) : ''); @@ -267,8 +267,14 @@ function App() { {/* Tab Navigation */}
- + )} +
@@ -237,13 +456,15 @@ function MissionTab({ authedPlayer }) {
- +
@@ -307,6 +528,17 @@ function MissionTab({ authedPlayer }) {
+ {/* Copilot Narrative */} +
+
+

Narrative

+ {copilotLoading && Loading...} +
+
+ {copilotText || (copilotLoading ? 'Generating...' : scenes[currentScene]?.description || '')} +
+
+ {/* Enemy List */}

Enemies

@@ -433,6 +665,36 @@ function MissionTab({ authedPlayer }) {
)} + + {/* History Panel */} + {showHistory && ( +
+

Mission History

+ {loadingHistory ? ( +

Loading...

+ ) : history.length === 0 ? ( +

No missions yet.

+ ) : ( +
+ {history.map(m => ( +
+
+
+ {m.name} + + {m.theme} · {m.threat_level} · {m.scene_count} scenes + +
+ + {new Date(m.created_at).toLocaleDateString('da-DK')} + +
+
+ ))} +
+ )} +
+ )} );