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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user