feat: major Deathwatch Roller overhaul with bestiary, mission, and player management

- src/components/DeathwatchRoller.jsx: +1495 lines, core roller
  component with expanded dice mechanics, character sheets, and
  mission tracking
- src/components/BestiaryTab.jsx: +158 lines, enemy bestiary with
  stat blocks and encounter builder
- src/components/MissionTab.jsx: +43 lines, mission briefing and
  objective tracking
- src/App.js: route updates for new tabs
- database/routes/missionRoutes.js: backend mission state routes
- src/tests/: new test files for roller reset, bestiary, mission,
  player management, requisition shop, and full mission playthrough
This commit is contained in:
2026-07-15 19:37:07 +02:00
parent b2dd5a44d3
commit 184b6e4371
5 changed files with 1676 additions and 115 deletions
+66 -18
View File
@@ -1,7 +1,41 @@
const express = require('express');
const { missionHelpers, missionRollHelpers, logToFile } = require('../mariadb');
const { battlemapVisibleForPlayer, buildBattlemapStateForScene } = require('../../src/shared/battlemapEngine');
const router = express.Router();
function ensureMissionBattlemaps(missionData = {}) {
const scenes = Array.isArray(missionData.scenes) ? missionData.scenes : [];
let changed = false;
const nextScenes = scenes.map((scene) => {
if (!scene || typeof scene !== 'object') return scene;
const nextBattlemapState = buildBattlemapStateForScene(scene, scene.battlemapState || {});
const sceneChanged = JSON.stringify(scene.battlemapState || null) !== JSON.stringify(nextBattlemapState);
if (sceneChanged) changed = true;
return {
...scene,
battlemapState: nextBattlemapState,
};
});
return {
changed,
mission: {
...missionData,
scenes: nextScenes,
},
};
}
async function persistMissionBattlemapsIfNeeded(mission) {
if (!mission?.id) return mission;
const normalized = ensureMissionBattlemaps(mission);
if (!normalized.changed) return normalized.mission;
await missionHelpers.updateProgress(mission.id, {
currentScene: Number.isInteger(mission.current_scene) ? mission.current_scene : 0,
scenes: normalized.mission.scenes,
});
return normalized.mission;
}
function playerScene(scene) {
if (!scene || typeof scene !== 'object') return null;
const {
@@ -25,6 +59,9 @@ function playerScene(scene) {
delete safeScene.skillSituationModifiers;
delete safeScene.skillCustomModifier;
}
if (!battlemapVisibleForPlayer(scene, 'player')) {
delete safeScene.battlemapState;
}
return safeScene;
}
@@ -89,7 +126,8 @@ function ensureSceneStoryHooks(missionData = {}) {
router.get('/', async (req, res) => {
try {
const missions = await missionHelpers.getAll();
res.json(missions);
const normalized = missions.map(mission => ensureMissionBattlemaps(mission).mission);
res.json(normalized);
} catch (error) {
logToFile('API: Failed to get missions', error);
res.status(500).json({ error: String(error) });
@@ -100,7 +138,8 @@ router.get('/', async (req, res) => {
router.get('/active/current', async (req, res) => {
try {
const mission = await missionHelpers.getActive();
res.json(mission || null);
const normalized = mission ? await persistMissionBattlemapsIfNeeded(mission) : null;
res.json(normalized || null);
} catch (error) {
logToFile('API: Failed to get active mission', error);
res.status(500).json({ error: String(error) });
@@ -112,14 +151,15 @@ router.get('/active/player', async (req, res) => {
try {
const mission = await missionHelpers.getActive();
if (!mission) return res.json(null);
const normalizedMission = await persistMissionBattlemapsIfNeeded(mission);
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 scenes = Array.isArray(normalizedMission.scenes) ? normalizedMission.scenes : [];
const currentIndex = Math.max(0, Math.min(Number(normalizedMission.current_scene || 0), Math.max(scenes.length - 1, 0)));
const currentScene = scenes[currentIndex] || null;
res.json({
id: mission.id,
name: mission.name,
id: normalizedMission.id,
name: normalizedMission.name,
current_scene: 0,
active_scene_index: currentIndex,
scenes: currentScene ? [playerScene(currentScene)] : [],
@@ -160,20 +200,22 @@ router.put('/active/scene', async (req, res) => {
try {
const mission = await missionHelpers.getActive();
if (!mission) return res.status(404).json({ error: 'No active mission' });
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 normalizedMission = ensureMissionBattlemaps(mission).mission;
const scenes = Array.isArray(normalizedMission.scenes) ? normalizedMission.scenes : [];
const currentIndex = Math.max(0, Math.min(Number(normalizedMission.current_scene || 0), Math.max(scenes.length - 1, 0)));
const currentScene = scenes[currentIndex] || {};
const patch = req.body?.patch || {};
const nextScenes = scenes.map((scene, index) => (
index === currentIndex ? { ...currentScene, ...patch } : scene
));
const ensuredMission = ensureMissionBattlemaps({ ...normalizedMission, scenes: nextScenes }).mission;
const ok = await missionHelpers.updateProgress(mission.id, {
currentScene: currentIndex,
scenes: nextScenes,
scenes: ensuredMission.scenes,
});
if (!ok) return res.status(500).json({ error: 'Failed to patch active scene' });
const updated = await missionHelpers.getById(mission.id);
res.json({ success: true, mission: updated, scene: nextScenes[currentIndex], currentScene: currentIndex });
res.json({ success: true, mission: updated, scene: ensuredMission.scenes[currentIndex], currentScene: currentIndex });
} catch (error) {
logToFile('API: Failed to patch active scene', error);
res.status(500).json({ error: String(error) });
@@ -209,7 +251,8 @@ router.get('/:id', async (req, res) => {
try {
const mission = await missionHelpers.getById(req.params.id);
if (!mission) return res.status(404).json({ error: 'Mission not found' });
res.json(mission);
const normalized = await persistMissionBattlemapsIfNeeded(mission);
res.json(normalized);
} catch (error) {
logToFile('API: Failed to get mission', req.params.id, error);
res.status(500).json({ error: String(error) });
@@ -219,7 +262,7 @@ router.get('/:id', async (req, res) => {
// Create mission
router.post('/', async (req, res) => {
try {
const missionData = ensureSceneStoryHooks(req.body || {});
const missionData = ensureMissionBattlemaps(ensureSceneStoryHooks(req.body || {})).mission;
const id = await missionHelpers.create(missionData);
if (!id) return res.status(500).json({ error: 'Failed to create mission' });
logToFile('API: Created mission', missionData.name);
@@ -236,7 +279,8 @@ router.post('/:id/active', async (req, res) => {
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 });
const normalized = mission ? await persistMissionBattlemapsIfNeeded(mission) : null;
res.json({ success: true, mission: normalized });
} catch (error) {
logToFile('API: Failed to set active mission', req.params.id, error);
res.status(500).json({ error: String(error) });
@@ -246,11 +290,14 @@ router.post('/:id/active', async (req, res) => {
// Update active play progress
router.put('/:id/progress', async (req, res) => {
try {
const progressData = ensureSceneStoryHooks(req.body || {});
const ok = await missionHelpers.updateProgress(req.params.id, progressData);
const progressData = ensureMissionBattlemaps(ensureSceneStoryHooks(req.body || {})).mission;
const ok = await missionHelpers.updateProgress(req.params.id, {
currentScene: Number.isInteger(progressData.currentScene) ? progressData.currentScene : Number(progressData.current_scene || 0),
scenes: progressData.scenes,
});
if (!ok) return res.status(404).json({ error: 'Mission not found' });
const mission = await missionHelpers.getById(req.params.id);
res.json({ success: true, mission });
res.json({ success: true, mission: mission ? ensureMissionBattlemaps(mission).mission : null });
} catch (error) {
logToFile('API: Failed to update mission progress', req.params.id, error);
res.status(500).json({ error: String(error) });
@@ -260,10 +307,11 @@ router.put('/:id/progress', async (req, res) => {
// Update mission
router.put('/:id', async (req, res) => {
try {
const missionData = ensureSceneStoryHooks(req.body || {});
const missionData = ensureMissionBattlemaps(ensureSceneStoryHooks(req.body || {})).mission;
const ok = await missionHelpers.update(req.params.id, missionData);
if (!ok) return res.status(404).json({ error: 'Mission not found' });
res.json({ success: true });
const mission = await missionHelpers.getById(req.params.id);
res.json({ success: true, mission: mission ? ensureMissionBattlemaps(mission).mission : null });
} catch (error) {
logToFile('API: Failed to update mission', req.params.id, error);
res.status(500).json({ error: String(error) });
+8 -1
View File
@@ -11,6 +11,7 @@ import PlayerManagement from './components/PlayerManagement';
import MissionTab from './components/MissionTab';
import SimulationTab from './components/SimulationTab';
import ErrorReports from './components/ErrorReports';
import BattlemapTab from './components/BattlemapTab';
import { useState, useEffect } from 'react';
import axios from 'axios';
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
@@ -322,6 +323,12 @@ function App() {
>
Weapons
</button>
<button
className={navButtonClass(tab === 'battlemap')}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'battlemap' }); setTab('battlemap')}}
>
Battlemap
</button>
<a
href="/cards.html"
target="_blank"
@@ -415,7 +422,7 @@ function App() {
</div>
)}
{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==='simulation' ? <SimulationTab /> : tab==='errors' ? <ErrorReports authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : <PlayerTab
{tab==='roller' ? <DeathwatchRoller authedPlayer={authedPlayer} /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='battlemap' ? <BattlemapTab authedPlayer={authedPlayer} sessionId={sessionId} /> : 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==='simulation' ? <SimulationTab /> : tab==='errors' ? <ErrorReports authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : <PlayerTab
authedPlayer={authedPlayer}
sessionId={sessionId}
/>}
+148 -10
View File
@@ -15,7 +15,6 @@ const STAT_DEFS = [
{ key: 'fel', label: 'Fel', ring: 'border-fuchsia-500/40', text: 'text-fuchsia-300' },
]
const ARMOUR_LABELS = { all: 'All', head: 'Head', body: 'Body', arm: 'Arms', arms: 'Arms', leg: 'Legs', legs: 'Legs', ra: 'R.Arm', la: 'L.Arm', rl: 'R.Leg', ll: 'L.Leg' }
const BOOK_ABBR = {
'Mark of the Xenos': 'MoX',
'First Founding': 'FF',
@@ -168,16 +167,141 @@ function attackLines(weapons, profile, maxLines = 4) {
// Render armour that may be a number, a string, or an object of locations.
function formatArmour(a){
const lines = formatArmourLines(a)
return lines ? lines.join(' / ') : null
}
function formatArmourLines(a){
if (a == null || a === '') return null
if (typeof a === 'number') return `All ${a}`
if (typeof a === 'string') return a
if (typeof a === 'object') {
const parts = Object.entries(a)
.filter(([, v]) => v != null && v !== '' && !Number.isNaN(+v))
.map(([k, v]) => `${ARMOUR_LABELS[k] || k} ${v}`)
return parts.length ? parts.join(' · ') : null
const normalized = {
all: null,
head: null,
body: null,
arms: null,
legs: null,
leftArm: null,
rightArm: null,
leftLeg: null,
rightLeg: null,
raw: null,
}
return String(a)
const setNum = (key, value) => {
const n = Number(value)
if (Number.isFinite(n)) normalized[key] = n
}
const parseText = (text) => {
const value = String(text || '').trim()
if (!value) return
if (/^none\.?$/i.test(value)) {
normalized.raw = 'None'
return
}
const compact = value.toLowerCase().replace(/\s+/g, ' ')
const matchers = [
['all', /\ball\s*(\d+)/i],
['head', /\bhead\s*(\d+)/i],
['body', /\bbody\s*(\d+)/i],
['arms', /\barms?\s*(\d+)/i],
['legs', /\blegs?\s*(\d+)/i],
['leftArm', /\bleft\s*arm\s*(\d+)/i],
['rightArm', /\bright\s*arm\s*(\d+)/i],
['leftLeg', /\bleft\s*leg\s*(\d+)/i],
['rightLeg', /\bright\s*leg\s*(\d+)/i],
]
for (const [key, regex] of matchers) {
const match = compact.match(regex)
if (match) setNum(key, match[1])
}
if (normalized.all == null) {
const generic = value.match(/\b(\d+)\b/)
if (generic && !normalized.raw) setNum('all', generic[1])
}
if (!normalized.raw) normalized.raw = value
}
if (typeof a === 'number') {
normalized.all = a
} else if (typeof a === 'string') {
parseText(a)
} else if (typeof a === 'object') {
const entries = Object.entries(a)
for (const [key, value] of entries) {
if (/^\d+$/.test(key)) continue
const k = key.toLowerCase()
if (k === 'all' || k === 'value') setNum('all', value)
else if (k === 'head') setNum('head', value)
else if (k === 'body') setNum('body', value)
else if (k === 'arms' || k === 'arm') setNum('arms', value)
else if (k === 'legs' || k === 'leg') setNum('legs', value)
else if (k === 'leftarm' || k === 'la') setNum('leftArm', value)
else if (k === 'rightarm' || k === 'ra') setNum('rightArm', value)
else if (k === 'leftleg' || k === 'll') setNum('leftLeg', value)
else if (k === 'rightleg' || k === 'rl') setNum('rightLeg', value)
else if (k === 'locations' && Array.isArray(value)) {
const nums = value.map(v => Number(v)).filter(v => Number.isFinite(v))
if (nums.length) normalized.all = Math.max(...nums)
} else if (k === 'type' && !normalized.raw) {
normalized.raw = String(value || '').trim() || null
}
}
if (normalized.all == null) {
const fallback = Object.values(a).find(v => Number.isFinite(Number(v)))
if (fallback != null) normalized.all = Number(fallback)
}
if (!normalized.raw) normalized.raw = null
}
if (
normalized.all == null &&
normalized.head == null &&
normalized.body == null &&
normalized.arms == null &&
normalized.legs == null &&
normalized.leftArm == null &&
normalized.rightArm == null &&
normalized.leftLeg == null &&
normalized.rightLeg == null
) {
return normalized.raw ? [normalized.raw] : null
}
const lines = []
if (normalized.all != null) {
lines.push(`All ${normalized.all}`)
return lines
}
const main = []
if (normalized.head != null) main.push(`H${normalized.head}`)
if (normalized.body != null) main.push(`B${normalized.body}`)
if (normalized.arms != null) main.push(`A${normalized.arms}`)
if (normalized.legs != null) main.push(`L${normalized.legs}`)
if (main.length) lines.push(main.join(' '))
const split = []
if (normalized.leftArm != null || normalized.rightArm != null) {
if (normalized.leftArm != null && normalized.rightArm != null && normalized.leftArm === normalized.rightArm) {
split.push(`A${normalized.leftArm}`)
} else {
if (normalized.leftArm != null) split.push(`LA${normalized.leftArm}`)
if (normalized.rightArm != null) split.push(`RA${normalized.rightArm}`)
}
}
if (normalized.leftLeg != null || normalized.rightLeg != null) {
if (normalized.leftLeg != null && normalized.rightLeg != null && normalized.leftLeg === normalized.rightLeg) {
split.push(`L${normalized.leftLeg}`)
} else {
if (normalized.leftLeg != null) split.push(`LL${normalized.leftLeg}`)
if (normalized.rightLeg != null) split.push(`RL${normalized.rightLeg}`)
}
}
if (split.length) lines.push(split.join(' '))
if (!lines.length && normalized.raw) lines.push(normalized.raw)
return lines.length ? lines : null
}
// Colour a card by threat tier, inferred from Wounds when no explicit tier.
@@ -223,6 +347,19 @@ function DamageBlock({ weapons, profile }) {
)
}
function ArmourBlock({ armour, title }) {
const lines = formatArmourLines(armour)
if (!lines || !lines.length) return null
return (
<div className={`rounded-md border px-2 py-1.5 min-h-[92px] overflow-visible ${BOX_TONES.armour}`} title={title || String(armour || '')}>
<div className="text-[9px] uppercase font-bold tracking-wide opacity-70 leading-none">AP</div>
<div className="text-xs text-slate-50 leading-snug mt-1 space-y-0.5">
{lines.map(line => <div key={line} className="whitespace-normal break-words">{line}</div>)}
</div>
</div>
)
}
function QuickBox({ label, value, tone, title, wrap = false, className = '' }) {
return (
<div className={`rounded-md border px-2 py-1 min-w-[58px] ${wrap ? 'max-w-none min-w-[170px]' : 'max-w-[112px]'} text-center ${BOX_TONES[tone] || BOX_TONES.source} ${className}`} title={title || String(value || '')}>
@@ -290,6 +427,7 @@ function normalizeEntry(en){
movement: formatField(en.movement || stats.movement),
toughness: formatField(en.toughness || stats.toughness),
armour: formatArmour(en.armour || stats.armour),
armourLines: formatArmourLines(en.armour || stats.armour),
skills: formatField(en.skills || stats.skills),
talents: formatField(en.talents || stats.talents),
traits: formatField(en.traits || stats.traits),
@@ -529,7 +667,7 @@ export default function BestiaryTab(){
<QuickBox label="Move" value={firstValue(d.movement, 12) || '—'} tone="move" title={d.movement} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-1.5 items-stretch">
<QuickBox label="AP" value={d.armour || '—'} tone="armour" title={d.armour} wrap className="h-full" />
<ArmourBlock armour={d.armourLines || d.armour} title={d.armour} />
<DamageBlock weapons={d.weapons} profile={d.profile} />
</div>
</div>
File diff suppressed because it is too large Load Diff
+43
View File
@@ -1096,6 +1096,7 @@ export default function MissionTab({ authedPlayer }) {
modifier: current.modifier ?? 0,
initPlayer: current.initPlayer || defaultPlayer,
initAgBonus: current.initAgBonus || '',
enemyInitAgBonus: current.enemyInitAgBonus || '',
};
}
@@ -1305,6 +1306,23 @@ export default function MissionTab({ authedPlayer }) {
await recordMissionRollForScene(feedSceneIndex, name, 'initiative', 'Initiative', { mini: true, agBonus: ab, dieRoll, total });
}
async function addMiniEnemyInitiative(sceneIndex, scene) {
const state = miniState(sceneIndex, scene);
const enemies = sceneEnemyObjects(scene);
const enemy = enemies.find(e => e.name === state.enemyName) || enemies[0];
if (!enemy) return;
const ab = state.enemyInitAgBonus === '' ? Math.floor((Number(enemy.ag) || 30) / 10) : (parseInt(state.enemyInitAgBonus, 10) || 0);
const dieRoll = Math.floor(Math.random() * 10) + 1;
const total = dieRoll + ab;
const entry = { player: enemy.name, agBonus: ab, dieRoll, total, type: 'npc', ts: Date.now() };
const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex;
const current = scene?.combatState || {};
const existing = Array.isArray(current.initiatives) ? current.initiatives : [];
const next = [...existing, entry];
await updateSceneCombatState(sceneIndex, { initiatives: next });
await recordMissionRollForScene(feedSceneIndex, enemy.name, 'initiative', 'Initiative', { mini: true, agBonus: ab, dieRoll, total, enemy: true });
}
async function advanceMiniCombatTurn(sceneIndex, scene, delta = 1) {
if (!scene?.combatState) return;
const order = sortInitiatives(scene.combatState.initiatives);
@@ -1706,6 +1724,27 @@ export default function MissionTab({ authedPlayer }) {
Roll Initiative
</button>
</div>
{isGM && (
<div className="mt-2 grid min-w-0 gap-2 md:grid-cols-[minmax(180px,1fr)_160px_140px] xl:grid-cols-[minmax(240px,1fr)_180px_160px]">
<div className="min-w-0 truncate rounded border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-300">
Enemy: <span className="text-slate-100">{selectedEnemy ? selectedEnemy.name : 'No enemy selected'}</span>
</div>
<input
type="number"
placeholder="Ag bonus auto"
value={state.enemyInitAgBonus}
onChange={e => setMiniState(sceneIndex, { enemyInitAgBonus: e.target.value })}
className="min-w-0 w-full rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
/>
<button
onClick={() => addMiniEnemyInitiative(sceneIndex, scene)}
disabled={!selectedEnemy}
className="h-full w-full rounded bg-purple-700 px-3 py-2 text-sm hover:bg-purple-600 disabled:opacity-40"
>
Roll Enemy Initiative
</button>
</div>
)}
</div>
</div>
@@ -3306,6 +3345,10 @@ export default function MissionTab({ authedPlayer }) {
<label className="mb-0.5 block text-xs text-slate-500">WS</label>
<input type="number" value={enemy.ws || 45} onChange={e => seEnemy(i, 'ws', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Ag</label>
<input type="number" value={enemy.ag || 30} onChange={e => seEnemy(i, 'ag', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Damage</label>
<input type="text" value={enemy.damage || '1d10+4'} onChange={e => seEnemy(i, 'damage', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />