diff --git a/src/App.js b/src/App.js index fa82ecd..e2b9e38 100755 --- a/src/App.js +++ b/src/App.js @@ -8,6 +8,7 @@ import BestiaryTab from './components/BestiaryTab'; import WeaponsTab from './components/WeaponsTab'; import GMKit from './components/GMKit'; import PlayerManagement from './components/PlayerManagement'; +import MissionTab from './components/MissionTab'; import { useState, useEffect } from 'react'; import axios from 'axios'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; @@ -296,6 +297,12 @@ function App() { > Weapons + )} - {tab==='roller' ? : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? :

Access Denied

The Bestiary is only accessible to Game Masters. Please log in with a GM account.

) : tab==='players' ? : tab==='gmkit' ? : : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='mission' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? :

Access Denied

The Bestiary is only accessible to Game Masters. Please log in with a GM account.

) : tab==='players' ? : tab==='gmkit' ? : } diff --git a/src/components/MissionTab.jsx b/src/components/MissionTab.jsx new file mode 100644 index 0000000..aa64568 --- /dev/null +++ b/src/components/MissionTab.jsx @@ -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 ( +
+
+
+

Mission Simulation

+

Please log in to access mission simulation

+
+
+
+ ); + } + + return ( +
+
+
+

Mission Simulation

+
+ {showSetup && ( + + )} + {showResults && ( + + )} +
+
+ + {/* Setup Panel */} + {showSetup && ( +
+

Mission Setup

+
+
+ + setMissionName(e.target.value)} + placeholder="Mission Name" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ )} + + {/* Combat Panel */} + {showCombat && scenes.length > 0 && ( +
+ {/* Scene Progress */} +
+
+

+ Scene {currentScene + 1}: {scenes[currentScene]?.name} +

+
+ {scenes.map((s, i) => ( +
+ {s.completed ? '✓' : i + 1} +
+ ))} +
+
+
+ + {/* Enemy List */} +
+

Enemies

+
+ {scenes[currentScene]?.enemies.map(enemy => ( +
+
+ {enemy.name} + + {enemy.currentWounds}/{enemy.maxWounds} W + +
+
+
+
+
+
+
+ ))} +
+
+ + {/* Combat Controls */} +
+

Combat

+
+
+ + +
+
+ + +
+
+
+ + {sceneComplete && ( + + )} +
+
+ + {/* Combat Log */} + {combatLog.length > 0 && ( +
+

Combat Log

+
+ {combatLog.map((entry, i) => ( +
{entry}
+ ))} +
+
+ )} +
+ )} + + {/* Results Panel */} + {showResults && ( +
+

Mission Complete

+
+
+
Scenes
+
{scenes.length}
+
+
+
Completed
+
+ {scenes.filter(s => s.completed).length} +
+
+
+
Total Damage
+
+ {scenes.reduce((sum, s) => sum + (s.result?.damage || 0), 0)} +
+
+
+
Enemies Defeated
+
+ {scenes.reduce((sum, s) => sum + (s.result?.enemiesDefeated || 0), 0)} +
+
+
+
+ )} +
+
+ ); +} + +export default MissionTab;