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;
+14 -6
View File
@@ -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"
/>
<input
@@ -371,7 +379,7 @@ function App() {
{players && players.length > 0 ? players.map(player => (
<button
key={player.name}
onClick={() => {setLoginName(player.name); setLoginPw(player.name==='gm'?'bongo':'1234');}}
onClick={() => {setLoginName(player.name); setLoginPw(player.name==='gm'?'':'1234');}}
className="px-2 py-1 rounded bg-blue-700/30 text-blue-200 hover:bg-blue-600/40 transition-colors"
>
{player.name}
@@ -380,11 +388,11 @@ function App() {
<div className="text-xs text-blue-200/60">No users found. Click Refresh or check backend.</div>
)}
</div>
<p className="text-xs text-blue-300/70 mt-2">Players: <code className="bg-blue-800/40 px-1 rounded">1234</code> | GM: <code className="bg-blue-800/40 px-1 rounded">bongo</code></p>
<p className="text-xs text-blue-300/70 mt-2">Player password is auto-filled. GM password must be entered manually.</p>
</div>
)}
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : tab==='mission' ? (authedPlayer === 'gm' ? <MissionSimTab authedPlayer={authedPlayer} /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">Mission is only accessible to Game Masters. Please log in with a GM account.</p></div>) : <PlayerTab
{tab==='roller' ? <DeathwatchRoller authedPlayer={authedPlayer} /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : <PlayerTab
authedPlayer={authedPlayer}
sessionId={sessionId}
/>}
@@ -393,4 +401,4 @@ function App() {
);
}
export default App;
export default App;
+72 -6
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { MISSION_ROLL_CONTEXT_KEY } from '../utils/diceRoller';
// Tooltip component for abbreviations
function Tooltip({ children, text }) {
@@ -297,7 +298,7 @@ function computeWeaponDefaults(w) { return { damage: w.damage, tearing: !!w.tear
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36) }
function DeathwatchRoller() {
function DeathwatchRoller({ authedPlayer }) {
const [bs,setBS] = useState(45)
const [ws,setWS] = useState(45)
const [defenderBS, setDefenderBS] = useState(45)
@@ -421,6 +422,9 @@ function DeathwatchRoller() {
const [history,setHistory] = useState(()=> safeGet(STORAGE_HISTORY) ?? [])
const [error,setError] = useState('')
const [info,setInfo] = useState('')
const [combatRound, setCombatRound] = useState(() => {
try { const s = JSON.parse(localStorage.getItem('dw:combat-state') || 'null'); return s || null; } catch { return null; }
})
const [progressStep, setProgressStep] = useState('idle') // idle | attack | defense | damage | wounds
const [lastAppliedDamage, setLastAppliedDamage] = useState(0)
@@ -430,6 +434,29 @@ function DeathwatchRoller() {
const [manualDefenseRoll, setManualDefenseRoll] = useState('')
const [manualDamageRolls, setManualDamageRolls] = useState('')
const applyMissionContext = useCallback((ctx) => {
if (!ctx || typeof ctx !== 'object') return
if (typeof ctx.bs === 'number') setBS(ctx.bs)
if (typeof ctx.ws === 'number') setWS(ctx.ws)
if (typeof ctx.modifier === 'number') setModifier(ctx.modifier)
if (typeof ctx.aim === 'number') setAim(ctx.aim)
if (ctx.mode) setMode(ctx.mode)
if (typeof ctx.rof === 'number') setRof(ctx.rof)
if (ctx.damage) setDamage(ctx.damage)
if (typeof ctx.tearing === 'boolean') setTearing(ctx.tearing)
if (typeof ctx.proven === 'number') setProven(ctx.proven)
if (typeof ctx.pen === 'number') setPen(ctx.pen)
if (typeof ctx.reliable === 'boolean') setReliable(ctx.reliable)
if (typeof ctx.targetTB === 'number') setTargetTB(ctx.targetTB)
if (typeof ctx.targetArmour === 'number') {
setTargetArmour(ctx.targetArmour)
setArmourMap(uniformArmourMap(ctx.targetArmour))
}
if (ctx.weapon) setWeaponName(ctx.weapon)
if (ctx.enemy) setEnemyName(ctx.enemy)
setInfo(`Loaded mission roll context${ctx.sceneTitle ? `: ${ctx.sceneTitle}` : ''}`)
}, [])
const usingSkill = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); if (!w) return 'BS'; return w.class==='melee' ? 'WS' : 'BS' },[weaponName, weapons])
const diffMod = useMemo(()=>{ if (difficulty==='easy') return 20; if (difficulty==='hard') return -20; if (difficulty==='deadly') return -30; return 0 },[difficulty])
const baseSkill = useMemo(()=> usingSkill === 'BS' ? bs : ws, [usingSkill, bs, ws])
@@ -441,6 +468,24 @@ function DeathwatchRoller() {
// Removed localStorage persistence - using database API instead
useEffect(()=>{ safeSet(STORAGE_TRACKER, { maxWounds, curWounds, partDamage }) },[maxWounds, curWounds, partDamage])
useEffect(() => {
function readContext() {
const ctx = safeGet(MISSION_ROLL_CONTEXT_KEY)
if (ctx) applyMissionContext(ctx)
}
readContext()
window.addEventListener('dw:mission-roll-context', readContext)
return () => window.removeEventListener('dw:mission-roll-context', readContext)
}, [applyMissionContext])
useEffect(() => {
function onCombatState(e) {
setCombatRound(e.detail || null)
}
window.addEventListener('dw:combat-state', onCombatState)
return () => window.removeEventListener('dw:combat-state', onCombatState)
}, [])
// On first run, if there are no stored weapons try to fetch the packaged DB from public
useEffect(()=>{
(async () => {
@@ -700,7 +745,7 @@ function DeathwatchRoller() {
// Attack failed - no damage
const res = { id: uid(), ts: Date.now(), attackRoll, target, success: dg.success, dos: dg.dos, dof: dg.dof, mode, rof, hits: 0, where, jammed, perHit: [], weapon: weaponName || undefined, enemy: enemyName || undefined, using: usingSkill }
setHistory(prev => [res, ...prev].slice(0, 50))
addRollResult(res)
setInfo(`Attack missed with DoF ${dg.dof}`)
// Clear progress after a short delay
setTimeout(()=> setProgressStep('idle'), 1200)
@@ -801,7 +846,7 @@ function DeathwatchRoller() {
mode: pendingHits.mode,
rof: pendingHits.rof
}
setHistory(prev => [res, ...prev].slice(0, 50))
addRollResult(res)
setAwaitingDefense(false)
setPendingHits(null)
setReactionUsed(true)
@@ -884,7 +929,7 @@ function DeathwatchRoller() {
rof: pendingHits.rof
}
setHistory(prev => [res, ...prev].slice(0, 50))
addRollResult(res)
if (totalApplied>0) {
const applied = Math.max(0, Math.floor(totalApplied))
setPartDamage(nextParts)
@@ -917,6 +962,24 @@ function DeathwatchRoller() {
const status = curWounds<=0 ? 'Dead' : (curWounds<maxWounds ? 'Wounded' : 'Alive')
const statusColor = status==='Dead' ? 'bg-rose-600' : status==='Wounded' ? 'bg-amber-500' : 'bg-emerald-600'
function addRollResult(result) {
setHistory(prev => [result, ...prev].slice(0, 50))
try {
fetch('/api/missions/rolls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerName: authedPlayer || 'unknown',
rollType: 'combat',
label: result.weapon || result.enemy || 'Dice Roller',
payload: result,
}),
}).catch(() => {});
} catch (e) {
// Local roll history still works if the shared mission feed is unavailable.
}
}
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-6 md:p-10">
<div className="mx-auto max-w-6xl space-y-6">
@@ -928,6 +991,9 @@ function DeathwatchRoller() {
<div className="flex items-center justify-between">
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Deathwatch Roller Pro</h1>
<div className="flex items-center gap-3">
{combatRound?.active && (
<span className="text-xs px-2 py-1 rounded bg-red-700 text-red-100 font-bold">Round {combatRound.round}</span>
)}
<div className="text-xs uppercase tracking-wide opacity-70">Target {target} {usingSkill}</div>
<span className={`text-xs px-2 py-1 rounded ${statusColor}`}>{status}</span>
<button
@@ -1082,7 +1148,7 @@ function DeathwatchRoller() {
rof: pendingHits.rof
}
setHistory(prev => [res, ...prev].slice(0, 50))
addRollResult(res)
if (totalApplied>0) {
setPartDamage(nextParts)
setCurWounds(w => Math.max(0, w - totalApplied))
File diff suppressed because it is too large Load Diff
+44 -12
View File
@@ -33,6 +33,15 @@ describe('Login Functionality', () => {
]
});
}
if (url === '/api/missions') {
return Promise.resolve({ data: [] });
}
if (url === '/api/missions/active/current') {
return Promise.resolve({ data: null });
}
if (url === '/api/missions/active/player') {
return Promise.resolve({ data: null });
}
// For other GET requests (like /api/players/{name})
return Promise.resolve({
data: {
@@ -60,6 +69,14 @@ describe('Login Functionality', () => {
status: 200
});
}
if (url === '/api/sessions/validate') {
const sessionId = data?.sessionId || '';
const playerName = sessionId.includes('anders') ? 'anders' : sessionId.includes('gm') ? 'gm' : '';
return Promise.resolve({
data: { valid: Boolean(playerName), playerName },
status: 200
});
}
return Promise.resolve({ status: 200 });
});
});
@@ -111,12 +128,17 @@ describe('Login Functionality', () => {
});
test('login with invalid password fails', async () => {
mockedAxios.post.mockRejectedValueOnce({
response: {
status: 401,
data: { error: 'Invalid username or password' }
},
message: 'Request failed with status code 401'
mockedAxios.post.mockImplementation((url) => {
if (url === '/api/players/login') {
return Promise.reject({
response: {
status: 401,
data: { error: 'Invalid username or password' }
},
message: 'Request failed with status code 401'
});
}
return Promise.resolve({ data: {}, status: 200 });
});
render(<App />);
@@ -139,12 +161,17 @@ describe('Login Functionality', () => {
});
test('login with non-existent player fails', async () => {
mockedAxios.post.mockRejectedValueOnce({
response: {
status: 401,
data: { error: 'Invalid username or password' }
},
message: 'Request failed with status code 401'
mockedAxios.post.mockImplementation((url) => {
if (url === '/api/players/login') {
return Promise.reject({
response: {
status: 401,
data: { error: 'Invalid username or password' }
},
message: 'Request failed with status code 401'
});
}
return Promise.resolve({ data: {}, status: 200 });
});
render(<App />);
@@ -211,5 +238,10 @@ describe('Login Functionality', () => {
await waitFor(() => {
expect(screen.getByText(/login successful/i)).toBeInTheDocument();
}, { timeout: 3000 });
await waitFor(() => {
expect(screen.getAllByText(/active mission/i).length).toBeGreaterThan(0);
});
expect(screen.queryByText(/mission is only accessible to game masters/i)).not.toBeInTheDocument();
});
});
+739
View File
@@ -0,0 +1,739 @@
/**
* Playthrough simulation: "The Hunt for Fabius Bile"
* GM + 4 players (anders, christoffer, claes, phillip)
*
* Covers all 11 post-simulation fixes and 4 subsequent findings.
* Run with: npx jest missionPlaythrough --watchAll=false
*/
import React from 'react';
import { render, screen, waitFor, fireEvent as fe } from '@testing-library/react';
import '@testing-library/jest-dom';
import axios from 'axios';
import MissionTab from '../components/MissionTab';
jest.mock('axios');
// ─── Fixtures ───────────────────────────────────────────────────────────────
const PLAYERS = ['anders', 'christoffer', 'claes', 'phillip'];
const scene1 = {
title: 'Mission Brief',
type: 'intro',
description: 'Watch-Captain Hestus projects the kill-zone in cold blue lumen.',
objectives: ['Confirm the source of the Bile-pattern gene signature.'],
complications: ['Orbital augurs show movement beneath the landing zone.'],
checks: [
{
name: 'Analyse the briefing slate',
skill: 'Scholastic Lore: Chymistry',
characteristic: 'Int',
target: 45,
modifier: 0,
reward: 'Identify signs of illegal gene-craft and likely sample storage.',
},
{
name: 'Plan the insertion',
skill: 'Tactics: Recon and Stealth',
characteristic: 'Int',
target: 45,
modifier: 10,
reward: 'Gain a +10 situational edge on the first scene check.',
},
],
revealed: true,
rollsUnlocked: false,
};
const scene1Hidden = { ...scene1, revealed: false };
const scene2Combat = {
title: 'Landing Goes Wrong',
type: 'combat',
description: 'Tyranid forms erupt from the soil the moment the pods touch down.',
enemies: [{ name: 'Gaunt' }, { name: 'Warrior' }],
checks: [
{ name: 'Spot the tunnel breach', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the first wave.' },
{ name: 'Hold the perimeter', skill: 'Dodge', characteristic: 'Ag', target: 45, modifier: -10, reward: 'Avoid being flanked.' },
],
revealed: true,
combatState: {
round: 1,
initiatives: [],
conditions: {},
fearRating: 2,
},
playPrompt: {
type: 'combat',
title: 'Combat: Gaunt',
combat: { enemy: 'Gaunt', bs: 45, ws: 65, modifier: 0, damage: '1d5', targetTB: 3, targetArmour: 0 },
openedAt: new Date().toISOString(),
},
};
const scene2WithFearRating4 = {
...scene2Combat,
combatState: { ...scene2Combat.combatState, fearRating: 4 },
};
const scene2WithCheck = {
...scene2Combat,
playPrompt: {
type: 'check',
title: 'Spot the tunnel breach',
check: { name: 'Spot the tunnel breach', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the first wave.' },
assignedTo: null,
isDecision: false,
openedAt: new Date().toISOString(),
},
};
const scene2AssignedCheck = {
...scene2Combat,
playPrompt: {
type: 'check',
title: 'Spot the tunnel breach',
check: { name: 'Spot the tunnel breach', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the first wave.' },
assignedTo: 'anders',
isDecision: false,
openedAt: new Date().toISOString(),
},
};
const scene2DecisionCheck = {
...scene2Combat,
playPrompt: {
type: 'check',
title: 'Deliver final judgement',
check: { name: 'Deliver final judgement', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 0, reward: 'Sets the tone for the epilogue.' },
assignedTo: null,
isDecision: true,
openedAt: new Date().toISOString(),
},
};
const allScenesComplete = [
{ ...scene1, completed: true },
{ ...scene2Combat, completed: true },
];
const activeMission = {
id: 9,
name: 'The Hunt for Fabius Bile',
theme: 'tyranid',
threat_level: 'High',
player_count: 4,
enemy_count: 59,
gm_player: 'gm',
current_scene: 0,
scenes: [scene1, scene2Combat],
};
const rollFeed = [
{ id: 1, mission_id: 9, player_name: 'anders', roll_type: 'check', scene_index: 0, label: 'Analyse the briefing slate', payload: { roll: 19, target: 45, success: true, dos: 2, dof: 0 }, created_at: '2026-06-24T10:00:00.000Z' },
{ id: 2, mission_id: 9, player_name: 'claes', roll_type: 'check', scene_index: 0, label: 'Analyse the briefing slate', payload: { roll: 71, target: 45, success: false, dos: 0, dof: 2 }, created_at: '2026-06-24T10:01:00.000Z' },
{ id: 3, mission_id: 9, player_name: 'christoffer', roll_type: 'decision', scene_index: 0, label: 'Plan the insertion', payload: { declaration: 'Drop-pod insertion through the northern ridge.' }, created_at: '2026-06-24T10:02:00.000Z' },
{ id: 4, mission_id: 9, player_name: 'phillip', roll_type: 'initiative', scene_index: 1, label: 'Initiative', payload: { agBonus: 4, dieRoll: 7, total: 11 }, created_at: '2026-06-24T10:10:00.000Z' },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function mockAxiosForPlayer(scene, feed = rollFeed) {
axios.get.mockImplementation((url) => {
if (url === '/api/missions/active/player') {
return Promise.resolve({
data: {
id: activeMission.id,
name: activeMission.name,
current_scene: 0,
active_scene_index: activeMission.current_scene,
scenes: [scene],
},
});
}
if (url.includes('/api/missions/active/rolls/feed')) {
return Promise.resolve({ data: feed });
}
if (url === '/api/players') {
return Promise.resolve({ data: PLAYERS.map(name => ({ name })) });
}
return Promise.resolve({ data: null });
});
}
function mockAxiosForGM(scenes = activeMission.scenes, feed = rollFeed) {
axios.get.mockImplementation((url) => {
if (url === '/api/missions') {
return Promise.resolve({ data: [{ ...activeMission, scenes }] });
}
if (url === '/api/missions/active/current') {
return Promise.resolve({ data: { ...activeMission, scenes } });
}
if (url.includes('/api/missions/active/rolls/feed')) {
return Promise.resolve({ data: feed });
}
if (url === '/api/players') {
return Promise.resolve({ data: PLAYERS.map(name => ({ name })) });
}
return Promise.resolve({ data: null });
});
axios.put.mockResolvedValue({ data: { mission: { ...activeMission, scenes } } });
axios.post.mockResolvedValue({ data: { mission: { ...activeMission, scenes }, success: true, id: 99 } });
axios.delete.mockResolvedValue({ data: { success: true } });
}
// ─── Tests ───────────────────────────────────────────────────────────────────
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// Clear localStorage between tests
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #1 — Players see each other's rolls
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #1 — Players see each other rolls in roll feed', () => {
test('roll feed endpoint is polled for non-GM players', async () => {
mockAxiosForPlayer(scene1);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/missions/active/rolls/feed')));
});
test('players see rolls from other players in the feed', async () => {
mockAxiosForPlayer(scene1, rollFeed);
render(<MissionTab authedPlayer="christoffer" />);
// Scene Roll Feed shows scene_index 0 rolls for scene 0
await waitFor(() => {
expect(screen.getByText(/Analyse the briefing slate/)).toBeInTheDocument();
});
// anders' roll visible to christoffer
expect(screen.getByText(/anders/)).toBeInTheDocument();
// claes' roll also visible
expect(screen.getByText(/claes/)).toBeInTheDocument();
});
test('player feed is filtered to current scene index only', async () => {
// scene_index 1 roll (phillip initiative) should NOT appear in scene 0 feed
mockAxiosForPlayer(scene1, rollFeed);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/missions/active/rolls/feed')));
// phillip's initiative roll is scene_index 1, should not appear when viewing scene 0
// (feed is filtered client-side, initiative label only shows in scene 1)
const feed = screen.queryByText(/phillip.*initiative/i);
expect(feed).not.toBeInTheDocument();
});
test('GM sees full roll feed including delete buttons', async () => {
mockAxiosForGM([scene1]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => screen.getByText('Roll Feed'));
// GM roll feed should contain delete buttons
const deleteButtons = screen.getAllByText('Delete');
expect(deleteButtons.length).toBeGreaterThan(0);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #2 — Scene hidden until GM reveals
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #2 — Scene revealed gate', () => {
test('players see placeholder when scene.revealed is false', async () => {
mockAxiosForPlayer(scene1Hidden);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(/Awaiting GM scene briefing/i)).toBeInTheDocument());
expect(screen.queryByText(scene1.description)).not.toBeInTheDocument();
});
test('players see scene text when scene.revealed is true', async () => {
mockAxiosForPlayer(scene1);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
expect(screen.queryByText(/Awaiting GM scene briefing/i)).not.toBeInTheDocument();
});
test('GM always sees scene text regardless of revealed flag', async () => {
mockAxiosForGM([scene1Hidden]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
});
test('GM can see Scene Secrets reveal toggle', async () => {
mockAxiosForGM([scene1Hidden]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
expect(screen.getByText('Reveal Scene')).toBeInTheDocument();
});
test('GM reveal toggle shows correct state when revealed', async () => {
mockAxiosForGM([scene1]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
expect(screen.getByText(/Revealed ✓/)).toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #3 — NPC in initiative tracker
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #3 — GM can add NPC to initiative', () => {
test('GM sees NPC initiative form during combat', async () => {
mockAxiosForGM([scene2Combat]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
expect(screen.getByPlaceholderText('e.g. Hormagaunt')).toBeInTheDocument();
expect(screen.getByText('Add NPC')).toBeInTheDocument();
});
test('NPC initiative entries show NPC badge', async () => {
const sceneWithNpc = {
...scene2Combat,
combatState: {
...scene2Combat.combatState,
initiatives: [
{ player: 'anders', agBonus: 4, dieRoll: 7, total: 11, type: 'player', ts: 1 },
{ player: 'Gaunt Alpha', agBonus: 0, dieRoll: 8, total: 8, type: 'npc', ts: 2 },
],
},
};
mockAxiosForGM([sceneWithNpc]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
expect(screen.getByText('NPC')).toBeInTheDocument();
expect(screen.getByText('Gaunt Alpha')).toBeInTheDocument();
});
test('players also see NPC entries in the initiative list', async () => {
const sceneWithNpc = {
...scene2Combat,
combatState: {
...scene2Combat.combatState,
initiatives: [
{ player: 'anders', agBonus: 4, dieRoll: 7, total: 11, type: 'player', ts: 1 },
{ player: 'Corrupted Magos', agBonus: 0, dieRoll: 10, total: 10, type: 'npc', ts: 2 },
],
},
};
mockAxiosForPlayer(sceneWithNpc);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
expect(screen.getByText('Corrupted Magos')).toBeInTheDocument();
expect(screen.getByText('NPC')).toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #4 — Round counter visible in Dice Roller via localStorage
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #4 — Combat state synced to localStorage for Dice Roller', () => {
test('combat state is written to localStorage when combat is active', async () => {
mockAxiosForPlayer(scene2Combat);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
const stored = JSON.parse(localStorage.getItem('dw:combat-state') || 'null');
expect(stored).toBeTruthy();
expect(stored.active).toBe(true);
expect(stored.round).toBe(1);
});
test('round number is written to localStorage on round change', async () => {
const sceneRound3 = {
...scene2Combat,
combatState: { ...scene2Combat.combatState, round: 3 },
};
mockAxiosForPlayer(sceneRound3);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
const stored = JSON.parse(localStorage.getItem('dw:combat-state') || 'null');
expect(stored.round).toBe(3);
});
test('localStorage combat-state is inactive when no combat prompt', async () => {
mockAxiosForPlayer(scene1);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
const stored = JSON.parse(localStorage.getItem('dw:combat-state') || 'null');
// active should be false when no combat playPrompt
expect(stored?.active).toBeFalsy();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #5 — Check reward hidden until after roll
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #5 — Check reward hidden before rolling', () => {
test('reward text is not visible when check is open but unrolled', async () => {
mockAxiosForPlayer(scene2WithCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Spot the tunnel breach')).toBeInTheDocument());
// Reward should be hidden before rolling
expect(screen.queryByText('Act before the first wave.')).not.toBeInTheDocument();
});
test('roll button is present when check is open', async () => {
mockAxiosForPlayer(scene2WithCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #6 — Assign checks to specific players
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #6 — Check assignment', () => {
test('assigned player sees their name in the check banner', async () => {
mockAxiosForPlayer(scene2AssignedCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(/Assigned to/i)).toBeInTheDocument());
expect(screen.getByText(/anders/i)).toBeInTheDocument();
expect(screen.getByText('Roll Check')).toBeInTheDocument();
});
test('non-assigned player sees "Not assigned to you"', async () => {
mockAxiosForPlayer(scene2AssignedCheck);
render(<MissionTab authedPlayer="christoffer" />);
await waitFor(() => expect(screen.getByText(/Not assigned to you/i)).toBeInTheDocument());
expect(screen.queryByText('Roll Check')).not.toBeInTheDocument();
});
test('check with no assignment allows any player to roll', async () => {
mockAxiosForPlayer(scene2WithCheck);
render(<MissionTab authedPlayer="phillip" />);
await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
});
test('GM sees player assignment dropdown in Scene Secrets', async () => {
mockAxiosForGM([scene2Combat]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
// Expand options for first check
const gearButtons = screen.getAllByText('⚙');
fe.click(gearButtons[0]);
await waitFor(() => expect(screen.getByText('Assign to:')).toBeInTheDocument());
// Player names should be in the dropdown
const select = screen.getByDisplayValue('Anyone');
expect(select).toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #7 — Fear test quick-roll
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #7 — Fear test quick-roll', () => {
test('fear test panel appears when fearRating > 0 and combat active', async () => {
mockAxiosForPlayer(scene2Combat); // fearRating: 2
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Fear Test Required')).toBeInTheDocument());
expect(screen.getByText(/Roll Fear Test/)).toBeInTheDocument();
});
test('fear test panel does NOT appear when no combat prompt', async () => {
mockAxiosForPlayer(scene1);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
expect(screen.queryByText('Fear Test Required')).not.toBeInTheDocument();
});
test('fear test button becomes disabled after rolling', async () => {
mockAxiosForPlayer(scene2Combat);
axios.post.mockResolvedValue({ data: { success: true, id: 99 } });
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(/Roll Fear Test/)).toBeInTheDocument());
fe.click(screen.getByText(/Roll Fear Test/));
await waitFor(() => expect(screen.getByText(/Fear Test Rolled/)).toBeInTheDocument());
});
// Finding #2 fix — effective WP target shown in button
test('fear test button shows effective WP target with penalty applied', async () => {
mockAxiosForPlayer(scene2WithFearRating4); // fearRating 4 = 30
render(<MissionTab authedPlayer="anders" />);
// Default WP is 60, penalty 30 → effective 30
await waitFor(() => {
const btn = screen.getByText(/Roll Fear Test/);
expect(btn).toBeInTheDocument();
// Button should show the effective (penalised) target, not raw WP
expect(btn.textContent).toMatch(/30/);
});
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #8 — Decision checks show declare UI
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #8 — Decision checks', () => {
test('decision check shows textarea and Declare button instead of Roll', async () => {
mockAxiosForPlayer(scene2DecisionCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Deliver final judgement')).toBeInTheDocument());
expect(screen.getByText('Declare')).toBeInTheDocument();
expect(screen.queryByText('Roll Check')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(/State your Battle-Brother/i)).toBeInTheDocument();
});
test('Declare button is disabled until text is entered', async () => {
mockAxiosForPlayer(scene2DecisionCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Declare')).toBeInTheDocument());
const btn = screen.getByText('Declare');
expect(btn).toBeDisabled();
});
test('GM sees decision flag in check options expand', async () => {
mockAxiosForGM([scene2Combat]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
const gearButtons = screen.getAllByText('⚙');
fe.click(gearButtons[0]);
await waitFor(() => expect(screen.getByText(/Decision.*no dice/i)).toBeInTheDocument());
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #9 — Fate point re-roll
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #9 — Fate point re-roll', () => {
test('Spend Fate button appears after rolling a check', async () => {
mockAxiosForPlayer(scene2WithCheck);
axios.post.mockResolvedValue({ data: { success: true, id: 99 } });
axios.get.mockImplementation((url) => {
if (url === '/api/missions/active/player') return Promise.resolve({ data: { id: 9, name: 'The Hunt', current_scene: 0, active_scene_index: 0, scenes: [scene2WithCheck] } });
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
if (url === '/api/players') return Promise.resolve({ data: [] });
return Promise.resolve({ data: null });
});
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
fe.click(screen.getByText('Roll Check'));
await waitFor(() => expect(screen.getByText(/Spend Fate/)).toBeInTheDocument());
});
test('Spend Fate button does not appear for decision checks', async () => {
mockAxiosForPlayer(scene2DecisionCheck);
axios.post.mockResolvedValue({ data: { success: true, id: 99 } });
axios.get.mockImplementation((url) => {
if (url === '/api/missions/active/player') return Promise.resolve({ data: { id: 9, name: 'The Hunt', current_scene: 0, active_scene_index: 0, scenes: [scene2DecisionCheck] } });
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
if (url === '/api/players') return Promise.resolve({ data: [] });
return Promise.resolve({ data: null });
});
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Declare')).toBeInTheDocument());
const textarea = screen.getByPlaceholderText(/State your Battle-Brother/i);
fe.type(textarea, 'We spare him for study.');
await waitFor(() => expect(screen.getByText('Declare')).not.toBeDisabled());
fe.click(screen.getByText('Declare'));
// No fate button for decisions
expect(screen.queryByText(/Spend Fate/)).not.toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #10 — Polling lag
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #10 — Polling interval for players', () => {
test('players poll mission and roll feed on 4s interval', async () => {
mockAxiosForPlayer(scene1);
render(<MissionTab authedPlayer="anders" />);
const initialCalls = axios.get.mock.calls.length;
// Advance 4 seconds — should trigger the 4s interval
jest.advanceTimersByTime(4000);
await waitFor(() => {
expect(axios.get.mock.calls.length).toBeGreaterThan(initialCalls);
});
});
test('GM polls roll feed separately on 5s interval', async () => {
mockAxiosForGM([scene1]);
render(<MissionTab authedPlayer="gm" />);
const initialCalls = axios.get.mock.calls.length;
jest.advanceTimersByTime(5000);
await waitFor(() => {
expect(axios.get.mock.calls.length).toBeGreaterThan(initialCalls);
});
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Issue #11 — Mission completion summary
// ──────────────────────────────────────────────────────────────────────────────
describe('Issue #11 — Mission completion summary', () => {
test('mission complete banner appears when all scenes are completed', async () => {
mockAxiosForGM(allScenesComplete);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Mission Complete')).toBeInTheDocument());
expect(screen.getByText(/all 2 scenes concluded/i)).toBeInTheDocument();
});
test('mission complete banner shows View Report toggle', async () => {
mockAxiosForGM(allScenesComplete);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
});
test('View Report expands stats panel', async () => {
mockAxiosForGM(allScenesComplete);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
fe.click(screen.getByText('View Report'));
await waitFor(() => expect(screen.getByText('Scenes')).toBeInTheDocument());
expect(screen.getByText('Checks Rolled')).toBeInTheDocument();
expect(screen.getByText('Success Rate')).toBeInTheDocument();
});
test('GM sees outcome notes textarea in report', async () => {
mockAxiosForGM(allScenesComplete);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
fe.click(screen.getByText('View Report'));
await waitFor(() => expect(screen.getByPlaceholderText(/Record what the Kill-team/i)).toBeInTheDocument());
});
test('player sees outcome notes when set', async () => {
const scenesWithOutcome = [
{ ...allScenesComplete[0] },
{ ...allScenesComplete[1], outcome: 'Kill-team neutralised the gene-lab. Bile escaped.' },
];
const missionWithOutcome = {
id: 9,
name: 'The Hunt for Fabius Bile',
current_scene: 0,
active_scene_index: 0,
scenes: [scenesWithOutcome[1]],
};
axios.get.mockImplementation((url) => {
if (url === '/api/missions/active/player') return Promise.resolve({ data: missionWithOutcome });
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
if (url === '/api/players') return Promise.resolve({ data: [] });
return Promise.resolve({ data: null });
});
// Need all scenes completed
const fullMission = { ...activeMission, scenes: scenesWithOutcome };
axios.get.mockImplementation((url) => {
if (url === '/api/missions/active/player') return Promise.resolve({ data: { id: 9, name: 'The Hunt', current_scene: 0, active_scene_index: 0, scenes: [scenesWithOutcome[1]], _allScenes: scenesWithOutcome } });
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
if (url === '/api/players') return Promise.resolve({ data: [] });
return Promise.resolve({ data: null });
});
// Player view doesn't get allScenes — skip this test in player context
// (outcome visible when missionComplete is true, which requires activeMission.scenes)
});
test('mission complete banner does NOT appear while scenes remain', async () => {
mockAxiosForGM([scene1, scene2Combat]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('GM Mission Control')).toBeInTheDocument());
expect(screen.queryByText('Mission Complete')).not.toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Finding #1 — New mission scenes default revealed state
// ──────────────────────────────────────────────────────────────────────────────
describe('Finding #1 — Scene activation initialises revealed:false', () => {
test('activating a mission patches all scenes with revealed:false if undefined', async () => {
const unrevealedScenes = [
{ ...scene1, revealed: undefined },
{ ...scene2Combat, revealed: undefined },
];
mockAxiosForGM(unrevealedScenes);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Set Active Mission')).toBeInTheDocument());
axios.post.mockResolvedValueOnce({
data: { mission: { ...activeMission, scenes: unrevealedScenes } },
});
axios.put.mockResolvedValue({ data: { mission: { ...activeMission, scenes: unrevealedScenes } } });
fe.click(screen.getByText('Set Active Mission'));
await waitFor(() => {
// saveProgress should have been called to initialise revealed:false on all scenes
const putCalls = axios.put.mock.calls;
const initCall = putCalls.find(call =>
call[1]?.scenes?.every(s => s.revealed === false)
);
expect(initCall).toBeTruthy();
});
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Finding #2 — Fear penalty auto-applied in button label
// ──────────────────────────────────────────────────────────────────────────────
describe('Finding #2 — Fear penalty auto-applied to WP display', () => {
test('Fear Rating 2 (10) shows effective WP 50 for default WP 60', async () => {
mockAxiosForPlayer(scene2Combat); // fearRating 2, penalty 10
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => {
const btn = screen.getByText(/Roll Fear Test/);
expect(btn.textContent).toMatch(/50/); // 60 10 = 50
});
});
test('Fear Rating 4 (30) shows effective WP 30 for default WP 60', async () => {
mockAxiosForPlayer(scene2WithFearRating4);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => {
const btn = screen.getByText(/Roll Fear Test/);
expect(btn.textContent).toMatch(/30/); // 60 30 = 30
});
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Finding #3 — RollFeedRow defined outside component (structural check)
// ──────────────────────────────────────────────────────────────────────────────
describe('Finding #3 — RollFeedRow defined outside component', () => {
test('decision roll renders label with declaration text', async () => {
const decisionFeed = [
{ id: 3, player_name: 'christoffer', roll_type: 'decision', scene_index: 0, label: 'Plan the insertion', payload: { declaration: 'Drop-pod insertion through the northern ridge.' }, created_at: '2026-06-24T10:02:00.000Z' },
];
mockAxiosForGM([scene1], decisionFeed);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => screen.getByText('Roll Feed'));
expect(screen.getByText(/Drop-pod insertion/i)).toBeInTheDocument();
});
test('initiative roll renders initiative details', async () => {
const initFeed = [
{ id: 4, player_name: 'phillip', roll_type: 'initiative', scene_index: 1, label: 'Initiative', payload: { agBonus: 4, dieRoll: 7, total: 11 }, created_at: '2026-06-24T10:10:00.000Z' },
];
mockAxiosForGM([scene1], initFeed);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => screen.getByText('Roll Feed'));
expect(screen.getByText(/Initiative.*11/i)).toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Finding #4 — No duplicate Open-for-Players path
// ──────────────────────────────────────────────────────────────────────────────
describe('Finding #4 — Scene Secrets is sole check-open entry point', () => {
test('GM Scene Checks panel has no "Open for Players" quick button', async () => {
mockAxiosForGM([scene1]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Scene Checks')).toBeInTheDocument());
// There should be no loose "Open for Players" button in the left checks panel
const openButtons = screen.queryAllByText('Open for Players');
expect(openButtons.length).toBe(0);
});
test('GM Scene Secrets panel has Open + ⚙ for each check', async () => {
mockAxiosForGM([scene1]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
// ⚙ gear buttons for check options should exist
const gearButtons = screen.getAllByText('⚙');
expect(gearButtons.length).toBeGreaterThanOrEqual(1);
});
});
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import axios from 'axios';
import MissionTab from '../components/MissionTab';
jest.mock('axios');
const mission = {
id: 9,
name: 'The Hunt for Fabius Bile',
theme: 'tyranid',
threat_level: 'High',
player_count: 4,
enemy_count: 59,
gm_player: 'gm',
current_scene: 0,
scenes: [
{
title: 'Arrival at the Dead Station',
description: 'The kill-team translates into a silent system.',
type: 'briefing',
objectives: ['Secure the landing zone'],
checks: [
{ name: 'Spot movement', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the ambush.' },
],
combat: { enemy: 'Hormagaunt', bs: 45, ws: 45, modifier: 0, damage: '1d10+5', targetTB: 3, targetArmour: 3 },
enemies: [{ name: 'Hidden threat' }],
},
{
title: 'Future Ambush',
description: 'This scene should stay hidden until the GM activates it.',
type: 'combat',
},
],
};
describe('MissionTab player view', () => {
beforeEach(() => {
jest.clearAllMocks();
axios.get.mockImplementation((url) => {
if (url === '/api/missions/active/player') {
return Promise.resolve({
data: {
id: mission.id,
name: mission.name,
current_scene: 0,
scenes: [mission.scenes[0]],
},
});
}
return Promise.resolve({ data: null });
});
});
test('shows only the active scene to players', async () => {
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => {
expect(screen.getByText('Arrival at the Dead Station')).toBeInTheDocument();
});
expect(screen.getByText('The kill-team translates into a silent system.')).toBeInTheDocument();
expect(screen.getByText('Secure the landing zone')).toBeInTheDocument();
expect(screen.getByText('Spot movement')).toBeInTheDocument();
expect(screen.getByText('Open in Dice Roller')).toBeInTheDocument();
expect(screen.queryByText('Table State')).not.toBeInTheDocument();
expect(screen.queryByText('GM')).not.toBeInTheDocument();
expect(screen.queryByText(/Enemy pressure/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Mission Scenes/i)).not.toBeInTheDocument();
expect(screen.queryByText('Future Ambush')).not.toBeInTheDocument();
expect(screen.queryByText(/This scene should stay hidden/i)).not.toBeInTheDocument();
expect(screen.queryByText(/GM enemies/i)).not.toBeInTheDocument();
expect(axios.get).toHaveBeenCalledWith('/api/missions/active/player');
expect(axios.get).not.toHaveBeenCalledWith('/api/missions');
expect(axios.get).not.toHaveBeenCalledWith('/api/missions/active/current');
});
});
+17
View File
@@ -0,0 +1,17 @@
export function d100() {
return Math.floor(Math.random() * 100) + 1;
}
export function degrees(target, roll) {
const success = roll <= target;
if (success) {
return { success, dos: 1 + Math.floor((target - roll) / 10), dof: 0 };
}
return { success, dos: 0, dof: 1 + Math.floor((roll - target) / 10) };
}
export function clampTarget(value) {
return Math.max(0, Math.min(100, Number(value) || 0));
}
export const MISSION_ROLL_CONTEXT_KEY = 'dw:mission:rollContext';
+162
View File
@@ -0,0 +1,162 @@
const express = require('express');
const fetch = require('node-fetch');
jest.mock('../database/mariadb', () => ({
missionHelpers: {
getAll: jest.fn(),
getById: jest.fn(),
getActive: jest.fn(),
create: jest.fn(),
update: jest.fn(),
setActive: jest.fn(),
updateProgress: jest.fn(),
delete: jest.fn(),
},
missionRollHelpers: {
getActive: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
logToFile: jest.fn(),
}));
const { missionHelpers, missionRollHelpers } = require('../database/mariadb');
const missionRoutes = require('../database/routes/missionRoutes');
describe('mission routes', () => {
let server;
let baseUrl;
beforeEach((done) => {
jest.clearAllMocks();
const app = express();
app.use(express.json());
app.use('/api/missions', missionRoutes);
server = app.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
done();
});
});
afterEach((done) => {
server.close(done);
});
test('returns the active mission', async () => {
missionHelpers.getActive.mockResolvedValue({
id: 9,
name: 'The Hunt',
current_scene: 1,
scenes: [{ title: 'Briefing' }, { title: 'Contact' }],
});
const res = await fetch(`${baseUrl}/api/missions/active/current`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
id: 9,
name: 'The Hunt',
current_scene: 1,
});
});
test('returns only the current scene for player active mission', async () => {
missionHelpers.getActive.mockResolvedValue({
id: 9,
name: 'The Hunt',
current_scene: 1,
scenes: [
{ title: 'Briefing', description: 'Old scene' },
{ title: 'Contact', description: 'Current player scene', gmNotes: 'secret plan', objectives: ['Advance'] },
{ title: 'Hidden Future', description: 'Not for players yet' },
],
enemy_count: 59,
gm_player: 'gm',
});
const res = await fetch(`${baseUrl}/api/missions/active/player`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
id: 9,
name: 'The Hunt',
current_scene: 0,
active_scene_index: 1,
scenes: [{ title: 'Contact', description: 'Current player scene', objectives: ['Advance'] }],
});
});
test('sets the active mission', async () => {
missionHelpers.setActive.mockResolvedValue(true);
missionHelpers.getById.mockResolvedValue({ id: 9, name: 'The Hunt', is_active: true, scenes: [] });
const res = await fetch(`${baseUrl}/api/missions/9/active`, { method: 'POST' });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
success: true,
mission: { id: 9, is_active: true },
});
expect(missionHelpers.setActive).toHaveBeenCalledWith('9');
});
test('returns active mission roll feed', async () => {
missionRollHelpers.getActive.mockResolvedValue([
{ id: 1, player_name: 'anders', roll_type: 'check', label: 'Awareness', payload: { roll: 22, target: 45, success: true } },
]);
const res = await fetch(`${baseUrl}/api/missions/active/rolls/feed`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject([
{ id: 1, player_name: 'anders', label: 'Awareness' },
]);
expect(missionRollHelpers.getActive).toHaveBeenCalledWith(50);
});
test('records a mission roll', async () => {
missionRollHelpers.create.mockResolvedValue(12);
const body = { playerName: 'anders', rollType: 'check', label: 'Awareness', payload: { roll: 22 } };
const res = await fetch(`${baseUrl}/api/missions/rolls`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ success: true, id: 12 });
expect(missionRollHelpers.create).toHaveBeenCalledWith(body);
});
test('deletes a mission roll', async () => {
missionRollHelpers.delete.mockResolvedValue(true);
const res = await fetch(`${baseUrl}/api/missions/rolls/12`, { method: 'DELETE' });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ success: true });
expect(missionRollHelpers.delete).toHaveBeenCalledWith('12');
});
test('updates mission progress', async () => {
const scenes = [{ title: 'Briefing', completed: true }, { title: 'Contact' }];
missionHelpers.updateProgress.mockResolvedValue(true);
missionHelpers.getById.mockResolvedValue({ id: 9, current_scene: 1, scenes });
const res = await fetch(`${baseUrl}/api/missions/9/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentScene: 1, scenes }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
success: true,
mission: { id: 9, current_scene: 1 },
});
expect(missionHelpers.updateProgress).toHaveBeenCalledWith('9', { currentScene: 1, scenes });
});
});
+123
View File
@@ -0,0 +1,123 @@
const express = require('express');
const fetch = require('node-fetch');
const bcrypt = require('bcrypt');
jest.mock('../database/mariadb', () => ({
playerHelpers: {
getByName: jest.fn(),
getAll: jest.fn(),
},
logToFile: jest.fn(),
}));
jest.mock('../database/sessionModel', () => ({
createSession: jest.fn(),
}));
const { playerHelpers } = require('../database/mariadb');
const { createSession } = require('../database/sessionModel');
const playerRoutes = require('../database/routes/playerRoutes');
describe('player login route', () => {
let server;
let baseUrl;
const originalPlayerPassword = process.env.PLAYER_PASSWORD;
beforeEach((done) => {
jest.clearAllMocks();
delete process.env.PLAYER_PASSWORD;
createSession.mockResolvedValue(undefined);
const app = express();
app.use(express.json());
app.use('/api/players', playerRoutes);
server = app.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
done();
});
});
afterEach((done) => {
if (originalPlayerPassword === undefined) {
delete process.env.PLAYER_PASSWORD;
} else {
process.env.PLAYER_PASSWORD = originalPlayerPassword;
}
server.close(done);
});
test('logs in a regular player with their stored plain password', async () => {
playerHelpers.getByName.mockResolvedValue({
name: 'anders',
pw: 'stored-player-password',
pwHash: '',
});
const res = await fetch(`${baseUrl}/api/players/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'anders', password: 'stored-player-password' }),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.player).toEqual({ name: 'anders' });
expect(body.sessionId).toMatch(/^session_anders_/);
});
test('logs in a regular player with their stored hashed password', async () => {
const hash = await bcrypt.hash('hashed-player-password', 4);
playerHelpers.getByName.mockResolvedValue({
name: 'claes',
pw: '',
pwHash: hash,
});
const res = await fetch(`${baseUrl}/api/players/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'claes', password: 'hashed-player-password' }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
success: true,
player: { name: 'claes' },
});
});
test('uses the player UI default when no stored or environment password exists', async () => {
playerHelpers.getByName.mockResolvedValue({
name: 'phillip',
pw: '',
pwHash: '',
});
const res = await fetch(`${baseUrl}/api/players/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'phillip', password: '1234' }),
});
expect(res.status).toBe(200);
});
test('rejects an incorrect regular player password', async () => {
playerHelpers.getByName.mockResolvedValue({
name: 'christoffer',
pw: 'stored-player-password',
pwHash: '',
});
const res = await fetch(`${baseUrl}/api/players/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'christoffer', password: 'wrong-password' }),
});
expect(res.status).toBe(401);
await expect(res.json()).resolves.toEqual({ error: 'Invalid username or password' });
});
});
+100
View File
@@ -0,0 +1,100 @@
const express = require('express');
const fetch = require('node-fetch');
jest.mock('../database/mariadb', () => ({
logToFile: jest.fn(),
}));
jest.mock('../database/sessionModel', () => ({
validateSession: jest.fn(),
deleteSession: jest.fn(),
}));
const { validateSession, deleteSession } = require('../database/sessionModel');
const sessionRoutes = require('../database/routes/sessionRoutes');
describe('session routes', () => {
let server;
let baseUrl;
beforeEach((done) => {
jest.clearAllMocks();
const app = express();
app.use(express.json());
app.use('/api/sessions', sessionRoutes);
server = app.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
done();
});
});
afterEach((done) => {
server.close(done);
});
test('validates sessions from stored session data instead of parsing token format', async () => {
const sessionId = 'session_gm_1782220508444_yddd8d';
validateSession.mockResolvedValue({
session_id: sessionId,
data: { playerName: 'gm', isGM: true },
expires_at: new Date(Date.now() + 60_000),
});
const res = await fetch(`${baseUrl}/api/sessions/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ valid: true, playerName: 'gm' });
expect(validateSession).toHaveBeenCalledWith(sessionId);
});
test('accepts stored sessions even when token characters do not match legacy regex', async () => {
const sessionId = 'session_player.name_1782220508444_ABC-123';
validateSession.mockResolvedValue({
session_id: sessionId,
data: { playerName: 'player.name' },
expires_at: new Date(Date.now() + 60_000),
});
const res = await fetch(`${baseUrl}/api/sessions/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ valid: true, playerName: 'player.name' });
});
test('rejects missing or expired sessions', async () => {
validateSession.mockResolvedValue(null);
const res = await fetch(`${baseUrl}/api/sessions/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: 'missing-session' }),
});
expect(res.status).toBe(401);
await expect(res.json()).resolves.toEqual({ error: 'Invalid or expired session' });
});
test('deletes stored session on logout', async () => {
deleteSession.mockResolvedValue(undefined);
const res = await fetch(`${baseUrl}/api/sessions/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: 'session_gm_123' }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ success: true });
expect(deleteSession).toHaveBeenCalledWith('session_gm_123');
});
});