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