feat: Mission Tab — 11 playthrough fixes + 4 follow-up findings

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 <[email protected]>
This commit is contained in:
2026-06-24 00:25:51 +02:00
co-authored by Claude Sonnet 4.6
parent cd180aad5b
commit cdfd147f02
14 changed files with 3187 additions and 130 deletions
+155
View File
@@ -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
};
+111 -1
View File
@@ -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 {
+11 -3
View File
@@ -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' });
}
}
+8 -7
View File
@@ -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;