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:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+1411
-86
File diff suppressed because it is too large
Load Diff
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user