Add Mission Tab: combat simulation for GM mission planning

- New MissionTab component with scene-based combat simulation
- Enemy roster from DeathwatchRoller (12 enemy types)
- Scene progression (4-6 scenes) with threat levels
- Combat log and results tracking
- Added to App.js navigation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 08:47:40 +02:00
parent 42dd4cfb53
commit ca4c8068f8
2 changed files with 463 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import BestiaryTab from './components/BestiaryTab';
import WeaponsTab from './components/WeaponsTab'; import WeaponsTab from './components/WeaponsTab';
import GMKit from './components/GMKit'; import GMKit from './components/GMKit';
import PlayerManagement from './components/PlayerManagement'; import PlayerManagement from './components/PlayerManagement';
import MissionTab from './components/MissionTab';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
@@ -296,6 +297,12 @@ function App() {
> >
Weapons Weapons
</button> </button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='mission' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'mission' }); setTab('mission')}}
>
Mission Sim
</button>
<a <a
href="/cards.html" href="/cards.html"
target="_blank" target="_blank"
@@ -371,7 +378,7 @@ function App() {
</div> </div>
)} )}
{tab==='roller' ? <DeathwatchRoller /> : 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} /> : <PlayerTab {tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : 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} /> : <PlayerTab
authedPlayer={authedPlayer} authedPlayer={authedPlayer}
sessionId={sessionId} sessionId={sessionId}
/>} />}

View File

@@ -0,0 +1,455 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Tooltip } from './DeathwatchRoller';
// Inline combat helpers (mirrors DeathwatchRoller logic)
function d100() { return Math.floor(Math.random() * 100) + 1; }
function degrees(target, roll) {
const success = roll <= target;
if (success) { const diff = target - roll; return { success, dos: 1 + Math.floor(diff / 10), dof: 0 }; }
const diff = roll - target; return { success, dos: 0, dof: 1 + Math.floor(diff / 10) };
}
function hitsFromDoS(mode, dos, rof) {
const r = rof && rof > 0 ? rof : 1;
if (mode === 'single') return Math.min(1, r);
if (mode === 'semi') return Math.max(1, Math.min(1 + Math.floor(dos / 2), r));
return Math.max(1, Math.min(1 + dos, r));
}
const SCENE_TYPES = ['Ambush', 'Assault', 'Defense', 'Infiltration', 'Search', 'Escort'];
const THREAT_LEVELS = ['Low', 'Medium', 'High', 'Extreme'];
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
function MissionTab({ authedPlayer }) {
const [active, setActive] = useState(false);
const [scenes, setScenes] = useState([]);
const [currentScene, setCurrentScene] = useState(0);
const [threat, setThreat] = useState('Medium');
const [missionName, setMissionName] = useState('');
const [enemies, setEnemies] = useState([]);
const [players, setPlayers] = useState([]);
const [combatLog, setCombatLog] = useState([]);
const [sceneComplete, setSceneComplete] = useState(false);
const [sceneResult, setSceneResult] = useState(null);
const [showSetup, setShowSetup] = useState(true);
const [showCombat, setShowCombat] = useState(false);
const [showResults, setShowResults] = useState(false);
// Setup state
const [sceneCount, setSceneCount] = useState(4);
const [enemyType, setEnemyType] = useState('Tyranid Warrior');
const [enemyCount, setEnemyCount] = useState(3);
const [playerCount, setPlayerCount] = useState(2);
// Combat state
const [selectedEnemy, setSelectedEnemy] = useState(null);
const [selectedPlayer, setSelectedPlayer] = useState(null);
const [attackRoll, setAttackRoll] = useState(null);
const [defenseRoll, setDefenseRoll] = useState(null);
const [damageRoll, setDamageRoll] = useState(null);
const [attackResult, setAttackResult] = useState(null);
// Predefined enemy types from DeathwatchRoller
const ENEMY_TYPES = [
{ name: 'Custom/None', tb: 4, armour: 5, wounds: 20 },
{ name: 'Imperial Guardsman', tb: 3, armourByLoc: { 'Head': 4, 'Body': 4, 'Left Arm': 4, 'Right Arm': 4, 'Left Leg': 4, 'Right Leg': 4 }, wounds: 10 },
{ name: 'Chaos Space Marine', tb: 8, armourByLoc: { 'Head': 8, 'Body': 10, 'Left Arm': 8, 'Right Arm': 8, 'Left Leg': 8, 'Right Leg': 8 }, wounds: 29 },
{ name: 'Tyranid Warrior', tb: 10, armourByLoc: { 'Head': 8, 'Body': 8, 'Left Arm': 8, 'Right Arm': 8, 'Left Leg': 8, 'Right Leg': 8 }, wounds: 48 },
{ name: 'Hormagaunt', tb: 3, armourByLoc: { 'Head': 3, 'Body': 3, 'Left Arm': 3, 'Right Arm': 3, 'Left Leg': 3, 'Right Leg': 3 }, wounds: 9 },
{ name: 'Termagant', tb: 3, armourByLoc: { 'Head': 3, 'Body': 3, 'Left Arm': 3, 'Right Arm': 3, 'Left Leg': 3, 'Right Leg': 3 }, wounds: 9 },
{ name: 'Hive Tyrant', tb: 15, armourByLoc: { 'Head': 10, 'Body': 10, 'Left Arm': 10, 'Right Arm': 10, 'Left Leg': 10, 'Right Leg': 10 }, wounds: 120 },
{ name: 'Tau Commander (Crisis Suit)', tb: 10, armourByLoc: { 'Head': 9, 'Body': 9, 'Left Arm': 9, 'Right Arm': 9, 'Left Leg': 9, 'Right Leg': 9 }, wounds: 90 },
{ name: 'Industrial Servitor', tb: 5, armourByLoc: { 'Head': 7, 'Body': 7, 'Left Arm': 7, 'Right Arm': 7, 'Left Leg': 7, 'Right Leg': 7 }, wounds: 20 },
{ name: 'Ork Boy', tb: 5, armour: 3, wounds: 13 },
{ name: 'Ork Nob', tb: 6, armour: 4, wounds: 22 },
{ name: 'Genestealer', tb: 6, armour: 6, wounds: 22 }
];
const THREAT_MODIFIERS = {
'Low': { enemyBonus: -10, sceneBonus: 0 },
'Medium': { enemyBonus: 0, sceneBonus: 0 },
'High': { enemyBonus: 10, sceneBonus: 1 },
'Extreme': { enemyBonus: 20, sceneBonus: 2 }
};
// Generate mission scenes
function generateMission() {
const newScenes = [];
const threatMod = THREAT_MODIFIERS[threat];
for (let i = 0; i < sceneCount; i++) {
const sceneType = SCENE_TYPES[i % SCENE_TYPES.length];
const sceneEnemies = [];
for (let j = 0; j < enemyCount; j++) {
const baseEnemy = ENEMY_TYPES.find(e => e.name === enemyType) || ENEMY_TYPES[1];
sceneEnemies.push({
...baseEnemy,
id: uid(),
currentWounds: baseEnemy.wounds,
maxWounds: baseEnemy.wounds,
name: `${baseEnemy.name} #${j + 1}`
});
}
newScenes.push({
id: uid(),
number: i + 1,
type: sceneType,
name: `${sceneType} ${i + 1}`,
enemies: sceneEnemies,
completed: false,
result: null,
threatMod: threatMod.sceneBonus
});
}
setScenes(newScenes);
setCurrentScene(0);
setCombatLog([]);
setSceneComplete(false);
setSceneResult(null);
setShowSetup(false);
setShowCombat(true);
}
// Run combat for current scene
function runCombat() {
const scene = scenes[currentScene];
if (!scene) return;
const log = [];
let totalDamage = 0;
let enemiesDefeated = 0;
// Simulate combat rounds
for (let round = 1; round <= 3; round++) {
for (const enemy of scene.enemies) {
if (enemy.currentWounds <= 0) {
enemiesDefeated++;
continue;
}
// Player attacks enemy
const playerBS = 50 + Math.floor(Math.random() * 20);
const attack = d100();
const dg = degrees(playerBS, attack);
if (dg.success) {
const hits = hitsFromDoS('single', dg.dos, 1);
const damage = Math.max(0, Math.floor(Math.random() * 10) + 5 - enemy.armour);
enemy.currentWounds = Math.max(0, enemy.currentWounds - damage);
totalDamage += damage;
log.push(`Round ${round}: ${selectedPlayer} hits ${enemy.name} for ${damage} damage`);
if (enemy.currentWounds <= 0) {
enemiesDefeated++;
log.push(` ${enemy.name} defeated!`);
}
} else {
log.push(`Round ${round}: ${selectedPlayer} misses ${enemy.name}`);
}
}
}
setCombatLog(log);
setSceneComplete(true);
setSceneResult({
damage: totalDamage,
enemiesDefeated,
totalEnemies: scene.enemies.length
});
}
// Complete scene
function completeScene() {
const newScenes = [...scenes];
newScenes[currentScene] = {
...newScenes[currentScene],
completed: true,
result: sceneResult
};
setScenes(newScenes);
if (currentScene < sceneCount - 1) {
setCurrentScene(currentScene + 1);
setSceneComplete(false);
setSceneResult(null);
setCombatLog([]);
} else {
setShowCombat(false);
setShowResults(true);
}
}
// Reset mission
function resetMission() {
setShowSetup(true);
setShowCombat(false);
setShowResults(false);
setScenes([]);
setCurrentScene(0);
setCombatLog([]);
setSceneComplete(false);
setSceneResult(null);
}
if (!authedPlayer) {
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-5xl">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Mission Simulation</h1>
<p className="text-slate-400">Please log in to access mission simulation</p>
</div>
</div>
</section>
);
}
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Mission Simulation</h1>
<div className="flex gap-2">
{showSetup && (
<button onClick={generateMission} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg">
Generate Mission
</button>
)}
{showResults && (
<button onClick={resetMission} className="px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg">
New Mission
</button>
)}
</div>
</div>
{/* Setup Panel */}
{showSetup && (
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6 space-y-4">
<h2 className="text-xl font-semibold">Mission Setup</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="text-xs uppercase opacity-70">Mission Name</label>
<input
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={missionName}
onChange={e => setMissionName(e.target.value)}
placeholder="Mission Name"
/>
</div>
<div>
<label className="text-xs uppercase opacity-70">Scenes</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={sceneCount}
onChange={e => setSceneCount(parseInt(e.target.value))}
>
{[4, 5, 6].map(n => <option key={n} value={n}>{n} scenes</option>)}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Enemy Type</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={enemyType}
onChange={e => setEnemyType(e.target.value)}
>
{ENEMY_TYPES.map(e => <option key={e.name} value={e.name}>{e.name}</option>)}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Enemy Count</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={enemyCount}
onChange={e => setEnemyCount(parseInt(e.target.value))}
>
{[1, 2, 3, 4, 5, 6].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs uppercase opacity-70">Threat Level</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={threat}
onChange={e => setThreat(e.target.value)}
>
{THREAT_LEVELS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Player Count</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={playerCount}
onChange={e => setPlayerCount(parseInt(e.target.value))}
>
{[1, 2, 3, 4, 5, 6].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
</div>
)}
{/* Combat Panel */}
{showCombat && scenes.length > 0 && (
<div className="space-y-6">
{/* Scene Progress */}
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">
Scene {currentScene + 1}: {scenes[currentScene]?.name}
</h2>
<div className="flex gap-2">
{scenes.map((s, i) => (
<div
key={s.id}
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${
i === currentScene ? 'bg-blue-600' :
s.completed ? 'bg-green-600' : 'bg-slate-700'
}`}
>
{s.completed ? '✓' : i + 1}
</div>
))}
</div>
</div>
</div>
{/* Enemy List */}
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
<h3 className="text-lg font-semibold mb-3">Enemies</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{scenes[currentScene]?.enemies.map(enemy => (
<div
key={enemy.id}
className={`p-3 rounded-lg border ${
enemy.currentWounds <= 0 ? 'bg-red-900/20 border-red-600' : 'bg-slate-700 border-slate-600'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">{enemy.name}</span>
<span className="text-red-400">
{enemy.currentWounds}/{enemy.maxWounds} W
</span>
</div>
<div className="mt-2">
<div className="h-2 bg-slate-800 rounded overflow-hidden">
<div
className="h-2 bg-red-500"
style={{ width: `${(enemy.currentWounds / enemy.maxWounds) * 100}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Combat Controls */}
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
<h3 className="text-lg font-semibold mb-3">Combat</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="text-xs uppercase opacity-70">Attacker</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={selectedPlayer || ''}
onChange={e => setSelectedPlayer(e.target.value)}
>
<option value="">Select Player</option>
{Array.from({ length: playerCount }, (_, i) => (
<option key={i} value={`Player ${i + 1}`}>Player {i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Target</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={selectedEnemy || ''}
onChange={e => setSelectedEnemy(e.target.value)}
>
<option value="">Select Enemy</option>
{scenes[currentScene]?.enemies.map(enemy => (
<option key={enemy.id} value={enemy.name} disabled={enemy.currentWounds <= 0}>
{enemy.name} {enemy.currentWounds <= 0 ? '(Defeated)' : ''}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-2">
<button
onClick={runCombat}
disabled={!selectedPlayer || !selectedEnemy}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg disabled:opacity-50"
>
Run Combat
</button>
{sceneComplete && (
<button
onClick={completeScene}
className="px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg"
>
Complete Scene
</button>
)}
</div>
</div>
{/* Combat Log */}
{combatLog.length > 0 && (
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
<h3 className="text-lg font-semibold mb-3">Combat Log</h3>
<div className="space-y-1 text-sm font-mono">
{combatLog.map((entry, i) => (
<div key={i} className="text-slate-300">{entry}</div>
))}
</div>
</div>
)}
</div>
)}
{/* Results Panel */}
{showResults && (
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6">
<h2 className="text-2xl font-bold mb-4">Mission Complete</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-700 rounded-lg p-4 text-center">
<div className="text-sm text-slate-300">Scenes</div>
<div className="text-2xl font-bold">{scenes.length}</div>
</div>
<div className="bg-slate-700 rounded-lg p-4 text-center">
<div className="text-sm text-slate-300">Completed</div>
<div className="text-2xl font-bold text-green-400">
{scenes.filter(s => s.completed).length}
</div>
</div>
<div className="bg-slate-700 rounded-lg p-4 text-center">
<div className="text-sm text-slate-300">Total Damage</div>
<div className="text-2xl font-bold text-red-400">
{scenes.reduce((sum, s) => sum + (s.result?.damage || 0), 0)}
</div>
</div>
<div className="bg-slate-700 rounded-lg p-4 text-center">
<div className="text-sm text-slate-300">Enemies Defeated</div>
<div className="text-2xl font-bold text-yellow-400">
{scenes.reduce((sum, s) => sum + (s.result?.enemiesDefeated || 0), 0)}
</div>
</div>
</div>
</div>
)}
</div>
</section>
);
}
export default MissionTab;