-
Enemies
-
{selectedMission.enemy_count}
+ {/* GM mission selector */}
+ {isGM && (
+
+
+
+
GM Mission Control
+
Choose a story from the database and make it the mission everyone plays from this tab.
+
{missions.length} saved
- {selectedMission.scenes && selectedMission.scenes.length > 0 && (
-
-
Scenes
- {selectedMission.scenes.map((s, i) => (
-
-
- {s.name || `Scene ${i + 1}`}
- {s.type}
-
- {s.description && (
-
{s.description}
+
+
+
+
+
+ )}
+
+ {loading ? (
+
Loading mission data...
+ ) : !activeMission ? (
+
+
No Active Mission
+
+ {isGM ? 'Select a saved mission above to begin play.' : 'The GM has not selected a mission yet.'}
+
+
+ ) : (
+ <>
+
+
+ {/* ── Left: scene content ── */}
+
+
+
+
{activeMission.name}
+ {isGM && (
+
+ {missionTheme(activeMission)} threat · {activeMission.threat_level || 'Medium'} · {activeMission.player_count || '?'} battle-brothers
+
)}
- ))}
+ {isGM && (
+
+ Scene {activeMission.current_scene + 1} / {activeMission.scenes.length || 1}
+
+ )}
+
+
+ {currentScene ? (
+
+
+ {/* ── Scene briefing ── */}
+
+
+
+
+ {isGM ? 'Current Scene' : 'Mission Briefing'}
+
+
{titleForScene(currentScene, activeMission.current_scene)}
+
+ {isGM && (
+
{currentScene.type || 'Scene'}
+ )}
+
+
+ {/* #2 — scene text gated by revealed flag */}
+ {isGM || sceneRevealed ? (
+ <>
+
{textForScene(currentScene) || 'No scene text stored.'}
+ {sceneObjectives.length > 0 && (
+
+
Objectives
+
+ {sceneObjectives.map((obj, i) => (
+
{obj}
+ ))}
+
+
+ )}
+ {sceneComplications.length > 0 && (
+
+
Pressure
+
+ {sceneComplications.map((comp, i) => (
+
{comp}
+ ))}
+
+
+ )}
+ >
+ ) : (
+
+
🔒
+
Awaiting GM scene briefing.
Stand by, Battle-Brother.
+
+ )}
+
+ {isGM && currentScene.completed && (
+
+ Scene completed
+
+ )}
+
+
+ {/* ── Checks + Combat grid ── */}
+
+
+ {/* Scene Checks */}
+
+
+
Scene Checks
+
+
+ {/* Player: active check banner */}
+ {!isGM && playPrompt?.type === 'check' && playPrompt.check && (() => {
+ const check = playPrompt.check;
+ const assignedTo = playPrompt.assignedTo;
+ const isDecision = playPrompt.isDecision;
+ const isAssignedToMe = !assignedTo || assignedTo === authedPlayer;
+ const hasRolled = rolledChecks.has(check.name);
+ const canFate = hasRolled && !fateUsedChecks.has(check.name) && !isDecision;
+ return (
+
+
{playPrompt.title}
+ {assignedTo && (
+
+ Assigned to {assignedTo}
+
+ )}
+
+ {isDecision ? 'Declare your intent for this moment.' : 'Roll this check when your Battle-Brother acts.'}
+
+
+ {isAssignedToMe && (
+ isDecision ? (
+
+
+ ) : (
+
+
+ {/* #9 — fate point re-roll */}
+ {canFate && (
+
+ )}
+
+ )
+ )}
+ {!isAssignedToMe && (
+
Not assigned to you.
+ )}
+ {/* #5 — reward only shown after rolling */}
+ {hasRolled && check.reward && (
+
+ Reward: {check.reward}
+
+ )}
+
+ );
+ })()}
+
+ {/* Player: awaiting */}
+ {!isGM && (!playPrompt || playPrompt.type !== 'check') && (
+
+
📋
+
No check open yet.
Awaiting GM orders.
+
+ )}
+
+ {/* GM: full check list */}
+ {isGM && (
+
+ {sceneChecks.map((check, index) => {
+ const target = clampTarget((check.target || 45) + (check.modifier || 0));
+ return (
+
+
+
+
{check.name}
+
+ {check.skill || 'Skill'} ({check.characteristic || 'Characteristic'}) target {target}
+ {check.modifier ? ` (${check.modifier > 0 ? '+' : ''}${check.modifier})` : ''}
+
+
+
+
+ {check.reward &&
{check.reward}
}
+
+ );
+ })}
+
+ )}
+
+ {/* Recent rolls (all players) */}
+ {rollResults.length > 0 && (
+
+
Recent Scene Rolls
+
+ {rollResults.map(result => (
+
+
+ {result.rolledBy || 'Unknown'}
+
+ Roll {result.roll} vs {result.target} ·{' '}
+ {result.success
+ ? Success, {result.dos} DoS
+ : Fail, {result.dof} DoF}
+
+
+
{result.name} · {result.skill}
+
+ ))}
+
+
+ )}
+
+
+ {/* Combat Setup */}
+
+
Combat Setup
+ {!isGM && !combatActive ? (
+
+
⚔️
+
Combat briefing is classified.
Awaiting GM to initiate combat.
+
+ ) : (
+ <>
+
+
+ Enemy
+ {sceneCombat.enemy || 'Custom/None'}
+
+
+ Attack target
+ BS {sceneCombat.bs || 45} / WS {sceneCombat.ws || 45}
+
+
+ Modifier
+ {Number(sceneCombat.modifier || 0) >= 0 ? '+' : ''}{sceneCombat.modifier || 0}
+
+
+ Damage
+ {sceneCombat.damage || '1d10+5'}
+
+
+ Target soak
+ TB {sceneCombat.targetTB || 4} / AR {sceneCombat.targetArmour || 5}
+
+
+
+ >
+ )}
+ {isGM && (
+
+
+
+
+ )}
+
+
+
+ {/* ── Combat state panels (initiative, conditions, action ref, fear) ── */}
+ {combatActive && (
+
+
+ {/* Round counter + fear rating */}
+
+
+
+ Round
+ {isGM && }
+ {combatState.round}
+ {isGM && }
+
+ {isGM ? (
+
+ Fear:
+
+
+ ) : combatState.fearRating > 0 ? (
+
+ {FEAR_RATINGS[combatState.fearRating]?.label}
+ {FEAR_RATINGS[combatState.fearRating]?.penalty && (
+ {FEAR_RATINGS[combatState.fearRating].penalty}
+ )}
+
+ ) : null}
+ {isGM && (
+
+ )}
+
+
+
+ {/* #7 — Fear test quick-roll (Finding #2: penalty auto-applied) */}
+ {combatState.fearRating > 0 && (
+
+
Fear Test Required
+
+
+
+ setWpTarget(e.target.value)}
+ className="w-20 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
+ />
+
+
+ {rolledChecks.has('__fear__') && !fateUsedChecks.has('__fear__') && (
+
+ )}
+
+
+ )}
+
+ {/* Initiative tracker */}
+
+
Initiative Order
+
+ {/* Player roll form */}
+ {!myInitiative && (
+
+
+
+ setAgBonus(e.target.value)}
+ placeholder="e.g. 4"
+ className="w-24 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
+ />
+
+
+
+ )}
+ {myInitiative && !isGM && (
+
+ Your initiative: {myInitiative.total} (d10:{myInitiative.dieRoll} + Ab:{myInitiative.agBonus})
+
+
+ )}
+
+ {/* #3 — GM NPC form */}
+ {isGM && (
+
+
+
+ setNpcName(e.target.value)}
+ placeholder="e.g. Hormagaunt"
+ className="rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
+ onKeyDown={e => e.key === 'Enter' && addNpcInitiative()}
+ />
+
+
+
+ setNpcRoll(e.target.value)}
+ placeholder="1–20"
+ className="w-20 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
+ />
+
+
+
+ )}
+
+ {/* Initiative list */}
+ {combatState.initiatives.length === 0 ? (
+
No initiatives submitted yet.
+ ) : (
+
+ {[...combatState.initiatives].sort((a, b) => b.total - a.total).map((entry, i) => (
+
+ {i + 1}
+ {entry.type === 'npc' && NPC}
+ {entry.player}
+ {entry.total}
+ {entry.type !== 'npc' && d10:{entry.dieRoll} + Ab:{entry.agBonus}}
+ {isGM && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Status conditions */}
+ {(isGM || combatEntities.some(e => (combatState.conditions[e.name] || []).length > 0)) && (
+
+
Conditions
+ {combatEntities.length === 0 ? (
+
Roll initiative to populate combatants.
+ ) : (
+
+ {combatEntities.map(entity => {
+ const active = combatState.conditions[entity.name] || [];
+ if (!isGM && active.length === 0) return null;
+ return (
+
+
+
+ {entity.type === 'player' ? 'Battle-Brother' : entity.type === 'npc' ? 'NPC' : 'Enemy'}
+
+ {entity.name}
+
+
+ {CONDITIONS.map(cond => {
+ const on = active.includes(cond);
+ return isGM ? (
+
+ ) : on ? (
+ {cond}
+ ) : null;
+ })}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* Action economy reference */}
+
+
+ {showActionRef && (
+
+ {ACTION_ECONOMY.map(row => (
+
+
{row.type}
+
{row.examples}
+
+ ))}
+
+
Each Turn
+
2 Half Actions or 1 Full Action · 1 Reaction · Unlimited Free Actions
+
+
+ )}
+
+
+ )}
+
+ {/* #1 — player roll feed (current scene) */}
+ {!isGM && playerRollFeed.length > 0 && (
+
+
Scene Roll Feed
+
+ {playerRollFeed.map(roll => (
+
+ ))}
+
+
+ )}
+
+
+ ) : (
+
This mission has no scenes stored.
+ )}
+
+ {/* GM scene navigation */}
+ {isGM && currentScene && (
+
+
+
+
+
+ )}
+
+
+ {/* ── Right: GM Scene Secrets + Table State ── */}
+ {isGM && (
+
+
+ {/* Scene Secrets */}
+
+
+
Scene Secrets
+ {playPrompt && (
+
+ )}
+
+
+ {/* #2 — reveal scene toggle */}
+
+
+
Scene Briefing
+
{sceneRevealed ? 'Visible to players' : 'Hidden from players'}
+
+
+
+
+ {/* Currently revealed */}
+
+ {playPrompt ? (
+
+
+
Revealed to players
+
{playPrompt.title || playPrompt.type}
+ {playPrompt.type === 'check' && playPrompt.check && (
+
+ {playPrompt.check.skill} ({playPrompt.check.characteristic}) · {playPrompt.check.target}
+ {playPrompt.assignedTo && ` · Assigned: ${playPrompt.assignedTo}`}
+ {playPrompt.isDecision && ' · Decision'}
+
+ )}
+ {playPrompt.type === 'combat' && (
+
Initiative order · Combat stats
+ )}
+
+
+
+ ) : (
+
Nothing revealed to players yet.
+ )}
+
+
+ {/* Checks — #6 assignment + #8 decision options */}
+
+
Checks
+
+ {sceneChecks.map((check) => {
+ const target = clampTarget((check.target || 45) + (check.modifier || 0));
+ const isOpen = playPrompt?.type === 'check' && playPrompt.title === check.name;
+ const isExpanded = checkOpenOptions?.name === check.name;
+ return (
+
+
+
+
{check.name}
+
{check.skill} ({check.characteristic}) · {target}
+
+ {isOpen ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {/* #6 + #8 — expanded options */}
+ {isExpanded && !isOpen && (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* Combat */}
+
+
Combat
+
+
+
{sceneCombat.enemy || 'Custom/None'}
+
BS {sceneCombat.bs || 45} / WS {sceneCombat.ws || 45} · {sceneCombat.damage || '1d10+5'}
+
+
+
+
+
+ {/* Roll limit */}
+
+ Player roll limit
+
+
+
+
+ {/* Table State */}
+
+
Table State
+
+
+ GM
+ {activeMission.gm_player || 'Unassigned'}
+
+
+ Scenes complete
+ {activeMission.scenes.filter(s => s.completed).length} / {activeMission.scenes.length}
+
+
+ Enemy pressure
+ {activeMission.enemy_count || '?'} per scene
+
+
+ Last update
+ {activeMission.updated_at ? new Date(activeMission.updated_at).toLocaleString('da-DK') : 'Unknown'}
+
+
+
+
+ )}
+
+
+ {/* #1 — GM Roll Feed (full, with delete) */}
+ {isGM && (
+
+
+
Roll Feed
+
+
+ {rollFeed.length === 0 ? (
+
No mission rolls recorded yet.
+ ) : (
+
+ {rollFeed.map(roll => )}
+
+ )}
)}
-
+
+ {/* GM Mission Scenes overview */}
+ {isGM && (
+
+
Mission Scenes
+
+ {activeMission.scenes.map((scene, index) => {
+ const active = index === activeMission.current_scene;
+ const enemies = sceneEnemies(scene);
+ return (
+
+
+
+
+ Scene {index + 1}
+ {active && Current}
+ {scene.completed && Complete}
+ {scene.revealed === false && Hidden}
+
+
{titleForScene(scene, index)}
+
{textForScene(scene) || 'No story text stored.'}
+ {enemies.length > 0 && (
+
Enemies: {enemies.join(', ')}
+ )}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+ >
)}
);
}
-
-export default MissionTab;
diff --git a/src/tests/login.test.js b/src/tests/login.test.js
index 2ecce3a..3a8bfc8 100644
--- a/src/tests/login.test.js
+++ b/src/tests/login.test.js
@@ -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(
);
@@ -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(
);
@@ -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();
});
});
diff --git a/src/tests/missionPlaythrough.test.js b/src/tests/missionPlaythrough.test.js
new file mode 100644
index 0000000..1d32c22
--- /dev/null
+++ b/src/tests/missionPlaythrough.test.js
@@ -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(
);
+ 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(
);
+ // 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
+ });
+
+ test('GM can see Scene Secrets reveal toggle', async () => {
+ mockAxiosForGM([scene1Hidden]);
+ render(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
+ });
+
+ test('GM sees player assignment dropdown in Scene Secrets', async () => {
+ mockAxiosForGM([scene2Combat]);
+ render(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ // 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+
+ 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(
);
+
+ 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(
);
+ 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(
);
+ await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
+ });
+
+ test('View Report expands stats panel', async () => {
+ mockAxiosForGM(allScenesComplete);
+ render(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
+ // ⚙ gear buttons for check options should exist
+ const gearButtons = screen.getAllByText('⚙');
+ expect(gearButtons.length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/tests/missionTab.test.js b/src/tests/missionTab.test.js
new file mode 100644
index 0000000..ce458af
--- /dev/null
+++ b/src/tests/missionTab.test.js
@@ -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(
);
+
+ 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');
+ });
+});
diff --git a/src/utils/diceRoller.js b/src/utils/diceRoller.js
new file mode 100644
index 0000000..755d5f2
--- /dev/null
+++ b/src/utils/diceRoller.js
@@ -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';
diff --git a/tests/missionRoutes.test.js b/tests/missionRoutes.test.js
new file mode 100644
index 0000000..19e92d1
--- /dev/null
+++ b/tests/missionRoutes.test.js
@@ -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 });
+ });
+});
diff --git a/tests/playerRoutesLogin.test.js b/tests/playerRoutesLogin.test.js
new file mode 100644
index 0000000..247c6cb
--- /dev/null
+++ b/tests/playerRoutesLogin.test.js
@@ -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' });
+ });
+});
diff --git a/tests/sessionRoutes.test.js b/tests/sessionRoutes.test.js
new file mode 100644
index 0000000..e6691b5
--- /dev/null
+++ b/tests/sessionRoutes.test.js
@@ -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');
+ });
+});