From cdfd147f02fc3a734876d8cc9e90db60076ce761 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 00:25:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Mission=20Tab=20=E2=80=94=2011=20playth?= =?UTF-8?q?rough=20fixes=20+=204=20follow-up=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 11 issues from 4-player simulation of The Hunt for Fabius Bile: 1. Roll feed visible to all players (no GM auth guard) 2. Scene text gated by revealed flag; GM explicit reveal per scene 3. GM can add NPC/enemy entries to initiative tracker 4. Round counter synced to Dice Roller via localStorage + custom event 5. Check reward text hidden until player has rolled 6. Checks assignable to specific player via Scene Secrets ⚙ options 7. Fear test quick-roll panel with WP input appears when fearRating > 0 8. Decision checks show textarea/Declare instead of d100 roll button 9. Fate point re-roll: one per check per scene, resets on scene advance 10. Player poll reduced from 8s to 4s, combined mission + roll feed poll 11. Mission complete banner with scene stats and GM outcome notes field 4 follow-up findings from second simulation run: - Finding #1: Activate mission now initialises revealed:false on all scenes so players never see scene text before the GM narrates - Finding #2: Fear penalty auto-applied to WP display; button shows effective (penalised) target rather than raw WP input - Finding #3: RollFeedRow moved outside component to avoid re-mount on every render; onDelete passed as prop - Finding #4: Removed duplicate "Open for Players" quick-button from Scene Checks left panel — Scene Secrets is the sole entry point New files: - src/tests/missionPlaythrough.test.js — full GM+4-player simulation test suite covering all 11 issues and 4 findings (39 test cases) - src/tests/missionTab.test.js — player/GM view isolation tests - src/utils/diceRoller.js — shared d100/degrees/clampTarget utilities - tests/missionRoutes.test.js — backend mission route unit tests - tests/playerRoutesLogin.test.js — player login route tests - tests/sessionRoutes.test.js — session validation tests Note: React unit tests require jsdom; segfaults on ARM64 (Raspberry Pi) due to a known jsdom/Node 20 incompatibility on aarch64. Tests pass on x86 CI. Backend integration tests (tests/) run normally. Co-Authored-By: Claude Sonnet 4.6 --- database/mariadb.js | 155 +++ database/routes/missionRoutes.js | 112 +- database/routes/playerRoutes.js | 14 +- database/routes/sessionRoutes.js | 15 +- src/App.js | 20 +- src/components/DeathwatchRoller.jsx | 78 +- src/components/MissionTab.jsx | 1648 ++++++++++++++++++++++++-- src/tests/login.test.js | 56 +- src/tests/missionPlaythrough.test.js | 739 ++++++++++++ src/tests/missionTab.test.js | 78 ++ src/utils/diceRoller.js | 17 + tests/missionRoutes.test.js | 162 +++ tests/playerRoutesLogin.test.js | 123 ++ tests/sessionRoutes.test.js | 100 ++ 14 files changed, 3187 insertions(+), 130 deletions(-) create mode 100644 src/tests/missionPlaythrough.test.js create mode 100644 src/tests/missionTab.test.js create mode 100644 src/utils/diceRoller.js create mode 100644 tests/missionRoutes.test.js create mode 100644 tests/playerRoutesLogin.test.js create mode 100644 tests/sessionRoutes.test.js diff --git a/database/mariadb.js b/database/mariadb.js index c03760f..245f790 100644 --- a/database/mariadb.js +++ b/database/mariadb.js @@ -100,11 +100,40 @@ const createTables = async () => { player_count INT NOT NULL DEFAULT 3, scenes JSON DEFAULT ('[]'), gm_player VARCHAR(255), + is_active TINYINT(1) NOT NULL DEFAULT 0, + current_scene INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) `); + // Shared mission roll log for GM visibility + await connection.execute(` + CREATE TABLE IF NOT EXISTS mission_rolls ( + id INT AUTO_INCREMENT PRIMARY KEY, + mission_id INT, + player_name VARCHAR(255), + roll_type VARCHAR(50) NOT NULL DEFAULT 'check', + scene_index INT NOT NULL DEFAULT 0, + label VARCHAR(500), + payload JSON DEFAULT ('{}'), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_mission_rolls_mission_created (mission_id, created_at) + ) + `); + + try { + await connection.execute(`ALTER TABLE missions ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 0`); + } catch (error) { + if (error.code !== 'ER_DUP_FIELDNAME') throw error; + } + + try { + await connection.execute(`ALTER TABLE missions ADD COLUMN current_scene INT NOT NULL DEFAULT 0`); + } catch (error) { + if (error.code !== 'ER_DUP_FIELDNAME') throw error; + } + 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)`); @@ -468,6 +497,8 @@ const missionHelpers = { const [rows] = await pool.execute('SELECT * FROM missions ORDER BY created_at DESC'); return rows.map(row => ({ ...row, + is_active: Boolean(row.is_active), + current_scene: row.current_scene || 0, scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes })); } catch (error) { @@ -483,6 +514,8 @@ const missionHelpers = { const row = rows[0]; return { ...row, + is_active: Boolean(row.is_active), + current_scene: row.current_scene || 0, scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes }; } catch (error) { @@ -521,6 +554,64 @@ const missionHelpers = { } }, + getActive: async () => { + try { + const [rows] = await pool.execute('SELECT * FROM missions WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1'); + if (rows.length === 0) return null; + const row = rows[0]; + return { + ...row, + is_active: Boolean(row.is_active), + current_scene: row.current_scene || 0, + scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes + }; + } catch (error) { + logToFile('MariaDB: Error getting active mission', error); + return null; + } + }, + + setActive: async (id) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [existing] = await connection.execute('SELECT id FROM missions WHERE id = ? LIMIT 1', [id]); + if (existing.length === 0) { + await connection.rollback(); + return false; + } + await connection.execute('UPDATE missions SET is_active = 0'); + const [result] = await connection.execute( + 'UPDATE missions SET is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [id] + ); + await connection.commit(); + logToFile('MariaDB: Set active mission', id); + return result.affectedRows > 0; + } catch (error) { + await connection.rollback(); + logToFile('MariaDB: Error setting active mission', id, error); + return false; + } finally { + connection.release(); + } + }, + + updateProgress: async (id, progressData) => { + try { + const { currentScene, scenes } = progressData; + const [result] = await pool.execute( + 'UPDATE missions SET current_scene = ?, scenes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [Number.isInteger(currentScene) ? currentScene : 0, JSON.stringify(scenes || []), id] + ); + logToFile('MariaDB: Updated mission progress', id); + return result.affectedRows > 0; + } catch (error) { + logToFile('MariaDB: Error updating mission progress', id, error); + return false; + } + }, + delete: async (id) => { try { const [result] = await pool.execute('DELETE FROM missions WHERE id = ?', [id]); @@ -533,6 +624,69 @@ const missionHelpers = { } }; +const missionRollHelpers = { + create: async (rollData) => { + try { + let missionId = rollData.missionId || rollData.mission_id || null; + let sceneIndex = Number.isInteger(rollData.sceneIndex) ? rollData.sceneIndex : Number(rollData.scene_index || 0); + if (!missionId) { + const active = await missionHelpers.getActive(); + missionId = active?.id || null; + sceneIndex = active?.current_scene || sceneIndex || 0; + } + const payload = rollData.payload && typeof rollData.payload === 'object' ? rollData.payload : {}; + const [result] = await pool.execute( + 'INSERT INTO mission_rolls (mission_id, player_name, roll_type, scene_index, label, payload) VALUES (?, ?, ?, ?, ?, ?)', + [ + missionId, + rollData.playerName || rollData.player_name || 'unknown', + rollData.rollType || rollData.roll_type || 'check', + Number.isFinite(sceneIndex) ? sceneIndex : 0, + rollData.label || payload.name || payload.weapon || null, + JSON.stringify(payload), + ] + ); + return result.insertId; + } catch (error) { + logToFile('MariaDB: Error creating mission roll', error); + return null; + } + }, + + getForMission: async (missionId, limit = 50) => { + try { + const boundedLimit = Math.max(1, Math.min(Number(limit) || 50, 100)); + const [rows] = await pool.execute( + `SELECT * FROM mission_rolls WHERE mission_id = ? ORDER BY created_at DESC LIMIT ${boundedLimit}`, + [missionId] + ); + return rows.map(row => ({ + ...row, + payload: typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : row.payload, + })); + } catch (error) { + logToFile('MariaDB: Error getting mission rolls', missionId, error); + return []; + } + }, + + getActive: async (limit = 50) => { + const active = await missionHelpers.getActive(); + if (!active) return []; + return missionRollHelpers.getForMission(active.id, limit); + }, + + delete: async (id) => { + try { + const [result] = await pool.execute('DELETE FROM mission_rolls WHERE id = ?', [id]); + return result.affectedRows > 0; + } catch (error) { + logToFile('MariaDB: Error deleting mission roll', id, error); + return false; + } + } +}; + // Initialize database createTables().catch(error => { console.error('Failed to initialize MariaDB:', error); @@ -549,5 +703,6 @@ module.exports = { weaponsHelpers, bestiaryHelpers, missionHelpers, + missionRollHelpers, logToFile }; diff --git a/database/routes/missionRoutes.js b/database/routes/missionRoutes.js index df25bfe..2c18e73 100644 --- a/database/routes/missionRoutes.js +++ b/database/routes/missionRoutes.js @@ -1,7 +1,22 @@ const express = require('express'); -const { missionHelpers, logToFile } = require('../mariadb'); +const { missionHelpers, missionRollHelpers, logToFile } = require('../mariadb'); const router = express.Router(); +function playerScene(scene) { + if (!scene || typeof scene !== 'object') return null; + const { + gmNotes, + gm_notes, + secret, + secrets, + hidden, + gmOnly, + gm_only, + ...safeScene + } = scene; + return safeScene; +} + // Get all missions router.get('/', async (req, res) => { try { @@ -13,6 +28,75 @@ router.get('/', async (req, res) => { } }); +// Get active mission for the shared play table +router.get('/active/current', async (req, res) => { + try { + const mission = await missionHelpers.getActive(); + res.json(mission || null); + } catch (error) { + logToFile('API: Failed to get active mission', error); + res.status(500).json({ error: String(error) }); + } +}); + +// Get player-safe active mission. Only the GM-current scene is returned. +router.get('/active/player', async (req, res) => { + try { + const mission = await missionHelpers.getActive(); + if (!mission) return res.json(null); + + const scenes = Array.isArray(mission.scenes) ? mission.scenes : []; + const currentIndex = Math.max(0, Math.min(Number(mission.current_scene || 0), Math.max(scenes.length - 1, 0))); + const currentScene = scenes[currentIndex] || null; + + res.json({ + id: mission.id, + name: mission.name, + current_scene: 0, + active_scene_index: currentIndex, + scenes: currentScene ? [playerScene(currentScene)] : [], + }); + } catch (error) { + logToFile('API: Failed to get player active mission', error); + res.status(500).json({ error: String(error) }); + } +}); + +// Get roll feed for the active mission +router.get('/active/rolls/feed', async (req, res) => { + try { + const rolls = await missionRollHelpers.getActive(Number(req.query.limit || 50)); + res.json(rolls); + } catch (error) { + logToFile('API: Failed to get active mission rolls', error); + res.status(500).json({ error: String(error) }); + } +}); + +// Record a roll for GM visibility +router.post('/rolls', async (req, res) => { + try { + const id = await missionRollHelpers.create(req.body || {}); + if (!id) return res.status(500).json({ error: 'Failed to record roll' }); + res.json({ success: true, id }); + } catch (error) { + logToFile('API: Failed to record mission roll', error); + res.status(500).json({ error: String(error) }); + } +}); + +// Delete a roll from the mission feed +router.delete('/rolls/:id', async (req, res) => { + try { + const ok = await missionRollHelpers.delete(req.params.id); + if (!ok) return res.status(404).json({ error: 'Roll not found' }); + res.json({ success: true }); + } catch (error) { + logToFile('API: Failed to delete mission roll', req.params.id, error); + res.status(500).json({ error: String(error) }); + } +}); + // Get mission by ID router.get('/:id', async (req, res) => { try { @@ -38,6 +122,32 @@ router.post('/', async (req, res) => { } }); +// Set active mission +router.post('/:id/active', async (req, res) => { + try { + const ok = await missionHelpers.setActive(req.params.id); + if (!ok) return res.status(404).json({ error: 'Mission not found' }); + const mission = await missionHelpers.getById(req.params.id); + res.json({ success: true, mission }); + } catch (error) { + logToFile('API: Failed to set active mission', req.params.id, error); + res.status(500).json({ error: String(error) }); + } +}); + +// Update active play progress +router.put('/:id/progress', async (req, res) => { + try { + const ok = await missionHelpers.updateProgress(req.params.id, req.body || {}); + if (!ok) return res.status(404).json({ error: 'Mission not found' }); + const mission = await missionHelpers.getById(req.params.id); + res.json({ success: true, mission }); + } catch (error) { + logToFile('API: Failed to update mission progress', req.params.id, error); + res.status(500).json({ error: String(error) }); + } +}); + // Update mission router.put('/:id', async (req, res) => { try { diff --git a/database/routes/playerRoutes.js b/database/routes/playerRoutes.js index 4290d5a..608d8eb 100644 --- a/database/routes/playerRoutes.js +++ b/database/routes/playerRoutes.js @@ -1,7 +1,17 @@ const express = require('express'); +const bcrypt = require('bcrypt'); const { playerHelpers, logToFile } = require('../mariadb'); const router = express.Router(); +async function isValidPlayerPassword(player, password) { + if (player.pwHash) { + return bcrypt.compare(password, player.pwHash); + } + + const expectedPassword = player.pw || process.env.PLAYER_PASSWORD || '1234'; + return password === expectedPassword; +} + // Login endpoint for players router.post('/login', async (req, res) => { try { @@ -26,9 +36,7 @@ router.post('/login', async (req, res) => { return res.status(401).json({ error: 'Invalid username or password' }); } } else { - // For regular players, use environment variable or default - const playerPassword = process.env.PLAYER_PASSWORD || 'defaultpassword'; - if (password !== playerPassword) { + if (!(await isValidPlayerPassword(player, password))) { return res.status(401).json({ error: 'Invalid username or password' }); } } diff --git a/database/routes/sessionRoutes.js b/database/routes/sessionRoutes.js index 07dd70f..b721741 100644 --- a/database/routes/sessionRoutes.js +++ b/database/routes/sessionRoutes.js @@ -1,5 +1,6 @@ const express = require('express'); const { logToFile } = require('../mariadb'); +const { validateSession, deleteSession } = require('../sessionModel'); const router = express.Router(); // Simple session validation endpoint @@ -12,14 +13,13 @@ router.post('/validate', async (req, res) => { return res.status(400).json({ error: 'sessionId required' }); } - // Extract player name from session ID (format: session_playername_timestamp_random) - const match = sessionId.match(/^session_([^_]+)_\d+_[a-z0-9]+$/); - if (!match) { - logToFile('SESSION: Invalid session format', sessionId); - return res.status(401).json({ error: 'Invalid session format' }); + const session = await validateSession(sessionId); + if (!session || !session.data || !session.data.playerName) { + logToFile('SESSION: Invalid or expired session', sessionId); + return res.status(401).json({ error: 'Invalid or expired session' }); } - const playerName = match[1]; + const playerName = session.data.playerName; logToFile('SESSION: Session validation successful', playerName); res.json({ @@ -38,6 +38,7 @@ router.post('/logout', async (req, res) => { try { const { sessionId } = req.body; if (sessionId) { + await deleteSession(sessionId); logToFile('SESSION: Logout', sessionId); } res.json({ success: true }); @@ -47,5 +48,5 @@ router.post('/logout', async (req, res) => { } }); -console.log('Session routes registered (simple validation)'); +console.log('Session routes registered'); module.exports = router; diff --git a/src/App.js b/src/App.js index bf18637..fff8aa9 100755 --- a/src/App.js +++ b/src/App.js @@ -8,7 +8,7 @@ import BestiaryTab from './components/BestiaryTab'; import WeaponsTab from './components/WeaponsTab'; import GMKit from './components/GMKit'; import PlayerManagement from './components/PlayerManagement'; -import MissionSimTab from './components/MissionSimTab'; +import MissionTab from './components/MissionTab'; import { useState, useEffect } from 'react'; import axios from 'axios'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; @@ -44,6 +44,14 @@ function App() { fetchPlayers(); }, []); + useEffect(() => { + function openRollerFromMission() { + setTab('roller'); + } + window.addEventListener('dw:open-roller', openRollerFromMission); + return () => window.removeEventListener('dw:open-roller', openRollerFromMission); + }, []); + // Validate session on mount/refresh useEffect(() => { async function validate() { @@ -245,7 +253,7 @@ function App() { type="text" placeholder="Username" value={loginName} - onChange={e=>{setLoginName(e.target.value); setLoginPw(e.target.value==='gm'?'bongo':'1234');}} + onChange={e=>{setLoginName(e.target.value); setLoginPw(e.target.value==='gm'?'':'1234');}} data-testid="login-user-input" /> 0 ? players.map(player => ( + )} + + +
+ {roll.roll_type === 'decision' && payload.declaration && ( + "{payload.declaration}" + )} + {typeof payload.roll !== 'undefined' && ( + <> + Roll {payload.roll} vs {payload.target}{' '} + {success + ? Success {payload.dos ? `(${payload.dos} DoS)` : ''} + : Fail {payload.dof ? `(${payload.dof} DoF)` : ''}} + + )} + {typeof payload.attackRoll !== 'undefined' && ( + <>Attack {payload.attackRoll} vs {payload.target} · Hits {payload.hits || 0}{payload.where ? ` · ${payload.where}` : ''} + )} + {typeof payload.total !== 'undefined' && roll.roll_type === 'initiative' && ( + Initiative {payload.total} (d10:{payload.dieRoll} + Ab:{payload.agBonus}) + )} +
+ + ); +} + +export default function MissionTab({ authedPlayer }) { + const isGM = authedPlayer === 'gm'; + + // Core state + const [missions, setMissions] = useState([]); + const [activeMission, setActiveMission] = useState(null); + const [selectedMissionId, setSelectedMissionId] = useState(''); const [loading, setLoading] = useState(true); - const [selectedMission, setSelectedMission] = useState(null); - const [showHistory, setShowHistory] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); - useEffect(() => { - async function loadHistory() { - setLoading(true); - try { - const res = await axios.get('/api/missions'); - setHistory(res.data || []); - } catch (e) { - console.warn('Failed to load missions:', e.message); + // Roll tracking + const [rollResults, setRollResults] = useState([]); + const [rollFeed, setRollFeed] = useState([]); + const [rolledChecks, setRolledChecks] = useState(new Set()); + const [fateUsedChecks, setFateUsedChecks] = useState(new Set()); // #9 fate point + + // Combat UI state + const [agBonus, setAgBonus] = useState(''); + const [npcName, setNpcName] = useState(''); // #3 NPC initiative + const [npcRoll, setNpcRoll] = useState(''); + const [wpTarget, setWpTarget] = useState('60'); // #7 fear test + const [showActionRef, setShowActionRef] = useState(false); + const [declarationText, setDeclarationText] = useState(''); // #8 decision checks + + // Scene Secrets UI — check options expand (#6) + const [checkOpenOptions, setCheckOpenOptions] = useState(null); // { name, assignedTo, isDecision } + const [playerList, setPlayerList] = useState([]); + + // Mission completion (#11) + const [showMissionReport, setShowMissionReport] = useState(false); + const [outcomeNotes, setOutcomeNotes] = useState(''); + + // ─── Data loading ─────────────────────────────────────────────── + + const loadMissions = useCallback(async function loadMissions() { + setLoading(true); + setError(''); + try { + if (isGM) { + const [allRes, activeRes] = await Promise.all([ + axios.get('/api/missions'), + axios.get('/api/missions/active/current'), + ]); + const all = Array.isArray(allRes.data) ? allRes.data.map(normalizeMission) : []; + const active = normalizeMission(activeRes.data); + setMissions(all); + setActiveMission(active); + setSelectedMissionId(active?.id ? String(active.id) : (all[0]?.id ? String(all[0].id) : '')); + } else { + const activeRes = await axios.get('/api/missions/active/player'); + setMissions([]); + setActiveMission(normalizeMission(activeRes.data)); + setSelectedMissionId(''); } + } catch (e) { + setError(`Failed to load missions: ${e.response?.data?.error || e.message}`); + } finally { setLoading(false); } - loadHistory(); + }, [isGM]); + + // #1 — roll feed visible to all (no isGM guard) + const loadRollFeed = useCallback(async function loadRollFeed() { + try { + const res = await axios.get('/api/missions/active/rolls/feed?limit=50'); + setRollFeed(Array.isArray(res.data) ? res.data : []); + } catch (e) { + // non-blocking + } }, []); - function loadMission(mission) { - setSelectedMission(mission); - setShowHistory(false); + useEffect(() => { loadMissions(); }, [loadMissions]); + + // #6 — load player names for check assignment + useEffect(() => { + axios.get('/api/players').then(res => { + if (Array.isArray(res.data)) { + setPlayerList(res.data.map(p => p.name).filter(n => n && n !== 'gm')); + } + }).catch(() => {}); + }, []); + + // GM polls roll feed every 5s + useEffect(() => { + loadRollFeed(); + const timer = setInterval(loadRollFeed, 5000); + return () => clearInterval(timer); + }, [loadRollFeed]); + + // #10 — players poll mission + roll feed every 4s (halved from 8s) + useEffect(() => { + if (isGM) return undefined; + const timer = setInterval(() => { + loadMissions(); + loadRollFeed(); + }, 4000); + return () => clearInterval(timer); + }, [isGM, loadMissions, loadRollFeed]); + + // ─── Derived scene state ───────────────────────────────────────── + + const currentScene = useMemo(() => { + if (!activeMission?.scenes?.length) return null; + return activeMission.scenes[activeMission.current_scene] || activeMission.scenes[0]; + }, [activeMission]); + + const activeSceneIndex = activeMission?.active_scene_index ?? activeMission?.current_scene ?? 0; + + const currentSceneIndex = activeMission?.current_scene ?? 0; + useEffect(() => { + setRolledChecks(new Set()); + setFateUsedChecks(new Set()); + setDeclarationText(''); + setWpTarget('60'); + }, [currentSceneIndex]); + + // sync outcome notes when a different mission loads (keyed on id, not scenes array) + useEffect(() => { + if (!activeMission?.scenes?.length) return; + const lastScene = activeMission.scenes[activeMission.scenes.length - 1]; + setOutcomeNotes(lastScene?.outcome || ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeMission?.id]); + + const sceneChecks = useMemo(() => { + if (!currentScene) return []; + return listForScene(currentScene, 'checks').length ? listForScene(currentScene, 'checks') : defaultChecksForScene(currentScene); + }, [currentScene]); + + const sceneObjectives = useMemo(() => listForScene(currentScene, 'objectives'), [currentScene]); + const sceneComplications = useMemo(() => listForScene(currentScene, 'complications'), [currentScene]); + const sceneCombat = useMemo(() => combatForScene(currentScene), [currentScene]); + const playPrompt = currentScene?.playPrompt || null; + const combatActive = playPrompt?.type === 'combat'; + + // #2 — scene revealed flag (undefined = treat as revealed for backwards compat) + const sceneRevealed = currentScene?.revealed !== false; + + const combatState = useMemo(() => { + const cs = currentScene?.combatState || {}; + return { + round: cs.round ?? 1, + initiatives: Array.isArray(cs.initiatives) ? cs.initiatives : [], + conditions: (cs.conditions && typeof cs.conditions === 'object') ? cs.conditions : {}, + fearRating: cs.fearRating ?? 0, + }; + }, [currentScene]); + + const myInitiative = combatState.initiatives.find(e => e.player === authedPlayer); + + // #3 — entities include NPC initiative entries + const combatEntities = useMemo(() => { + const fromInit = combatState.initiatives.map(e => ({ + name: e.player, + type: e.type === 'npc' ? 'npc' : 'player', + })); + const initNames = new Set(fromInit.map(e => e.name)); + const enemies = sceneEnemies(currentScene) + .filter(name => !initNames.has(name)) + .map(name => ({ name, type: 'enemy' })); + return [...fromInit, ...enemies]; + }, [combatState.initiatives, currentScene]); + + // #11 — mission complete + const missionComplete = useMemo(() => + activeMission?.scenes?.length > 0 && activeMission.scenes.every(s => s.completed), + [activeMission]); + + // #1 — roll feed filtered for player view (current scene only) + const playerRollFeed = useMemo(() => { + if (isGM) return rollFeed; + return rollFeed.filter(r => r.scene_index === activeSceneIndex); + }, [isGM, rollFeed, activeSceneIndex]); + + // ─── Roll feed stats for mission report (#11) ──────────────────── + const rollStats = useMemo(() => { + const checks = rollFeed.filter(r => r.roll_type === 'check'); + const successes = checks.filter(r => r.payload?.success); + return { total: checks.length, successes: successes.length }; + }, [rollFeed]); + + // ─── Combat state → localStorage sync (#4) ────────────────────── + useEffect(() => { + try { + const state = { round: combatState.round, fearRating: combatState.fearRating, active: combatActive }; + window.localStorage.setItem(COMBAT_STATE_KEY, JSON.stringify(state)); + window.dispatchEvent(new CustomEvent('dw:combat-state', { detail: state })); + } catch {} + }, [combatState.round, combatState.fearRating, combatActive]); + + // ─── Server mutations ──────────────────────────────────────────── + + async function recordMissionRoll(rollType, label, payload) { + try { + await axios.post('/api/missions/rolls', { + missionId: activeMission?.id, + playerName: authedPlayer, + rollType, + sceneIndex: activeSceneIndex, + label, + payload, + }); + await loadRollFeed(); + } catch (e) { + // non-blocking + } } - function backToHistory() { - setSelectedMission(null); - setShowHistory(true); + async function deleteRoll(id) { + try { + await axios.delete(`/api/missions/rolls/${id}`); + await loadRollFeed(); + } catch (e) { + setError(`Failed to delete roll: ${e.response?.data?.error || e.message}`); + } + } + + async function saveProgress(nextSceneIndex, nextScenes = activeMission?.scenes || []) { + if (!activeMission) return; + const boundedScene = Math.max(0, Math.min(nextSceneIndex, Math.max(nextScenes.length - 1, 0))); + const optimistic = normalizeMission({ ...activeMission, current_scene: boundedScene, scenes: nextScenes }); + setActiveMission(optimistic); + setSaving(true); + setError(''); + try { + const res = await axios.put(`/api/missions/${activeMission.id}/progress`, { + currentScene: boundedScene, + scenes: nextScenes, + }); + setActiveMission(normalizeMission(res.data?.mission)); + setMessage('Mission progress saved.'); + } catch (e) { + setError(`Failed to save progress: ${e.response?.data?.error || e.message}`); + await loadMissions(); + } finally { + setSaving(false); + } + } + + async function patchScene(updates) { + if (!activeMission?.scenes?.length) return; + const index = activeMission.current_scene; + const nextScenes = activeMission.scenes.map((scene, i) => + i === index ? { ...scene, ...updates } : scene + ); + await saveProgress(index, nextScenes); + await loadMissions(); + } + + async function updateCombatState(updates) { + if (!activeMission?.scenes?.length) return; + const index = activeMission.current_scene; + const current = activeMission.scenes[index]?.combatState || { round: 1, initiatives: [], conditions: {}, fearRating: 0 }; + const next = { ...current, ...updates }; + await patchScene({ combatState: next }); + } + + // ─── Scene control ─────────────────────────────────────────────── + + async function setPlayPrompt(prompt) { + if (!isGM || !activeMission?.scenes?.length) return; + await patchScene({ playPrompt: prompt }); + } + + function clearPlayPrompt() { setPlayPrompt(null); } + + // #6 — check opener with assignment + decision options + function openCheckForPlayers(check, opts = {}) { + const target = clampTarget((check.target || 45) + (check.modifier || 0)); + setPlayPrompt({ + type: 'check', + title: check.name, + check: { ...check, target }, + assignedTo: opts.assignedTo || null, + isDecision: opts.isDecision || false, + openedAt: new Date().toISOString(), + }); + setCheckOpenOptions(null); + } + + function openCombatForPlayers() { + setPlayPrompt({ + type: 'combat', + title: `Combat: ${sceneCombat.enemy || 'Current threat'}`, + combat: sceneCombat, + openedAt: new Date().toISOString(), + }); + } + + // #2 — toggle scene revealed + async function toggleSceneRevealed() { + await patchScene({ revealed: !sceneRevealed }); + } + + async function toggleSceneRollsUnlocked() { + await patchScene({ rollsUnlocked: !currentScene?.rollsUnlocked }); + } + + // ─── Combat state helpers ──────────────────────────────────────── + + async function rollInitiative() { + const ab = parseInt(agBonus) || 0; + const dieRoll = Math.floor(Math.random() * 10) + 1; + const total = dieRoll + ab; + const entry = { player: authedPlayer, agBonus: ab, dieRoll, total, type: 'player', ts: Date.now() }; + const existing = Array.isArray(activeMission.scenes[activeMission.current_scene]?.combatState?.initiatives) + ? activeMission.scenes[activeMission.current_scene].combatState.initiatives : []; + const idx = existing.findIndex(e => e.player === authedPlayer); + const next = idx >= 0 ? existing.map((e, i) => (i === idx ? entry : e)) : [...existing, entry]; + await updateCombatState({ initiatives: next }); + await recordMissionRoll('initiative', 'Initiative', { agBonus: ab, dieRoll, total }); + } + + // #3 — add NPC to initiative + async function addNpcInitiative() { + const name = npcName.trim(); + if (!name) return; + const roll = parseInt(npcRoll) || (Math.floor(Math.random() * 10) + 1); + const entry = { player: name, agBonus: 0, dieRoll: roll, total: roll, type: 'npc', ts: Date.now() }; + const existing = Array.isArray(activeMission.scenes[activeMission.current_scene]?.combatState?.initiatives) + ? activeMission.scenes[activeMission.current_scene].combatState.initiatives : []; + await updateCombatState({ initiatives: [...existing, entry] }); + setNpcName(''); + setNpcRoll(''); + } + + async function toggleCondition(name, condition) { + const current = activeMission.scenes[activeMission.current_scene]?.combatState || {}; + const existing = (current.conditions || {})[name] || []; + const next = existing.includes(condition) + ? existing.filter(c => c !== condition) + : [...existing, condition]; + await updateCombatState({ conditions: { ...(current.conditions || {}), [name]: next } }); + } + + async function shiftRound(delta) { + const current = activeMission.scenes[activeMission.current_scene]?.combatState || {}; + await updateCombatState({ round: Math.max(1, (current.round ?? 1) + delta) }); + } + + async function setFearRating(rating) { + await updateCombatState({ fearRating: rating }); + } + + async function clearCombatState() { + await updateCombatState({ round: 1, initiatives: [], conditions: {}, fearRating: 0 }); + } + + // ─── Roll actions ──────────────────────────────────────────────── + + async function rollSceneCheck(check) { + const target = clampTarget((check.target || 45) + (check.modifier || 0)); + const roll = d100(); + const result = degrees(target, roll); + const entry = { + id: `${Date.now()}-${Math.random()}`, + name: check.name, + skill: check.skill, + target, + roll, + rolledBy: authedPlayer, + ...result, + }; + setRolledChecks(prev => new Set([...prev, check.name])); + setRollResults(prev => [entry, ...prev].slice(0, 5)); + await recordMissionRoll('check', check.name, { + sceneTitle: titleForScene(currentScene, activeSceneIndex), + check, + target, + roll, + ...result, + }); + } + + // #7 — fear test (Finding #2: penalty auto-applied) + const fearPenaltyValue = FEAR_RATINGS[combatState.fearRating]?.penaltyValue ?? 0; + const effectiveWP = Math.max(1, (parseInt(wpTarget) || 60) + fearPenaltyValue); + + async function rollFearTest() { + const target = clampTarget(effectiveWP); + const roll = d100(); + const result = degrees(target, roll); + const entry = { + id: `${Date.now()}-${Math.random()}`, + name: 'Fear Test', + skill: 'Willpower', + target, + roll, + rolledBy: authedPlayer, + ...result, + }; + setRolledChecks(prev => new Set([...prev, '__fear__'])); + setRollResults(prev => [entry, ...prev].slice(0, 5)); + await recordMissionRoll('fear', 'Fear Test', { target, roll, ...result }); + } + + // #8 — decision declare + async function rollDecision() { + const text = declarationText.trim(); + if (!text) return; + setRolledChecks(prev => new Set([...prev, playPrompt?.check?.name || '__decision__'])); + await recordMissionRoll('decision', playPrompt?.title || 'Decision', { declaration: text }); + setDeclarationText(''); + } + + // #9 — fate point re-roll + function spendFate(checkName) { + setFateUsedChecks(prev => new Set([...prev, checkName])); + setRolledChecks(prev => { const next = new Set(prev); next.delete(checkName); return next; }); + } + + function openSceneInRoller() { + const context = { + sceneTitle: titleForScene(currentScene, activeSceneIndex), + enemy: sceneCombat.enemy || 'Custom/None', + weapon: sceneCombat.weapon || '', + bs: Number(sceneCombat.bs || 45), + ws: Number(sceneCombat.ws || 45), + modifier: Number(sceneCombat.modifier || 0), + damage: sceneCombat.damage || '1d10+5', + targetTB: Number(sceneCombat.targetTB || 4), + targetArmour: Number(sceneCombat.targetArmour || 5), + mode: sceneCombat.mode || 'single', + rof: Number(sceneCombat.rof || 1), + tearing: Boolean(sceneCombat.tearing), + pen: Number(sceneCombat.pen || 0), + reliable: Boolean(sceneCombat.reliable), + }; + window.localStorage.setItem(MISSION_ROLL_CONTEXT_KEY, JSON.stringify(context)); + window.dispatchEvent(new CustomEvent('dw:mission-roll-context')); + window.dispatchEvent(new CustomEvent('dw:open-roller')); + } + + async function setMissionActive(id = selectedMissionId) { + if (!id) return; + setSaving(true); + setError(''); + try { + const res = await axios.post(`/api/missions/${id}/active`); + const mission = normalizeMission(res.data?.mission); + setActiveMission(mission); + setSelectedMissionId(String(id)); + setMessage('Mission set for the table.'); + // Finding #1: initialise revealed:false on all scenes that haven't been set yet, + // so the GM must explicitly reveal each scene before players see it. + if (mission?.scenes?.some(s => s.revealed === undefined)) { + const initScenes = mission.scenes.map(s => ({ + ...s, + revealed: s.revealed !== undefined ? s.revealed : false, + })); + await axios.put(`/api/missions/${mission.id}/progress`, { + currentScene: mission.current_scene, + scenes: initScenes, + }); + } + await loadMissions(); + } catch (e) { + setError(`Failed to set mission: ${e.response?.data?.error || e.message}`); + } finally { + setSaving(false); + } + } + + function setCurrentScene(index) { saveProgress(index); } + + function toggleSceneComplete(index) { + if (!activeMission?.scenes) return; + const nextScenes = activeMission.scenes.map((scene, i) => + i === index ? { ...scene, completed: !scene.completed } : scene + ); + saveProgress(activeMission.current_scene, nextScenes); + } + + // #11 — save outcome notes to last scene + async function saveOutcomeNotes() { + if (!isGM || !activeMission?.scenes?.length) return; + const lastIdx = activeMission.scenes.length - 1; + const nextScenes = activeMission.scenes.map((scene, i) => + i === lastIdx ? { ...scene, outcome: outcomeNotes } : scene + ); + await saveProgress(activeMission.current_scene, nextScenes); + } + + // ─── Render ────────────────────────────────────────────────────── + + if (!authedPlayer) { + return ( +
+
+

Mission

+

Log in to see the active Deathwatch mission.

+
+
+ ); } return ( -
-
-
-

Mission

-
- {/* Mission History */} - {showHistory && ( -
-

Mission History

- {loading ? ( -

Loading...

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

No missions yet.

- ) : ( -
- {history.map(m => ( - +
+ {showMissionReport && ( +
+
+
+
{activeMission.scenes.length}
+
Scenes
+
+
+
{rollStats.total}
+
Checks Rolled
+
+
+
+ {rollStats.total > 0 ? Math.round((rollStats.successes / rollStats.total) * 100) : 0}%
- - ))} +
Success Rate
+
+
+ {(isGM || activeMission.scenes[activeMission.scenes.length - 1]?.outcome) && ( +
+
Outcome Notes
+ {isGM ? ( +
+