Files
dwroller/database/routes/missionRoutes.js
Alex cdfd147f02 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 <noreply@anthropic.com>
2026-06-24 00:25:51 +02:00

177 lines
5.5 KiB
JavaScript

const express = require('express');
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 {
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 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 {
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) });
}
});
// 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 {
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;