From fb019057bbb2fd356b5fc97926298506acbc91a8 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 23 Apr 2026 10:16:35 +0200 Subject: [PATCH] Restructure Mission tab: Mission for all, Mission Sim for GM only - Rename MissionTab to MissionSimTab (combat simulation, GM only) - New MissionTab shows mission history (all users) - Mission Sim tab added to GM-only navigation - MissionSimTab.jsx moved to src/components/ Co-Authored-By: Claude Opus 4.7 --- src/App.js | 37 +- src/components/MissionSimTab.jsx | 703 ++++++++++++++++++++++++++++++ src/components/MissionTab.jsx | 712 +++---------------------------- 3 files changed, 791 insertions(+), 661 deletions(-) create mode 100644 src/components/MissionSimTab.jsx diff --git a/src/App.js b/src/App.js index b2aabb2..2593e9a 100755 --- a/src/App.js +++ b/src/App.js @@ -9,6 +9,7 @@ import WeaponsTab from './components/WeaponsTab'; import GMKit from './components/GMKit'; import PlayerManagement from './components/PlayerManagement'; import MissionTab from './components/MissionTab'; +import MissionSimTab from './components/MissionSimTab'; import { useState, useEffect } from 'react'; import axios from 'axios'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; @@ -271,7 +272,7 @@ function App() { 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 + Mission - - - {authedPlayer === 'gm' && ( <> - + - - + )} + {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} +
+ ))} +
+
+
+ + {/* Copilot Narrative */} +
+
+

Narrative

+ {copilotLoading && Loading...} +
+
+ {copilotText || (copilotLoading ? 'Generating...' : scenes[currentScene]?.description || '')} +
+
+ + {/* 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)} +
+
+
+
+ )} + + {/* History Panel */} + {showHistory && ( +
+

Mission History

+ {loadingHistory ? ( +

Loading...

+ ) : history.length === 0 ? ( +

No missions yet.

+ ) : ( +
+ {history.map(m => ( +
+
+
+ {m.name} + + {m.theme} · {m.threat_level} · {m.scene_count} scenes + +
+ + {new Date(m.created_at).toLocaleDateString('da-DK')} + +
+
+ ))} +
+ )} +
+ )} +
+ + ); +} + +export default MissionTab; diff --git a/src/components/MissionTab.jsx b/src/components/MissionTab.jsx index 7b8b178..1dd9a1f 100644 --- a/src/components/MissionTab.jsx +++ b/src/components/MissionTab.jsx @@ -1,683 +1,65 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import axios from 'axios'; -// ─── Combat helpers (mirrors DeathwatchRoller) ─── -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)); -} -function hitLocationFromRoll(roll) { - const rev = Number(String(roll).padStart(2, '0').split('').reverse().join('')); - if (rev >= 1 && rev <= 10) return 'Head'; - if (rev <= 20) return 'Right Arm'; - if (rev <= 30) return 'Left Arm'; - if (rev <= 70) return 'Body'; - if (rev <= 85) return 'Right Leg'; - return 'Left Leg'; -} -function mitigateDamage(dmg, tb, armour) { return Math.max(0, dmg - tb - armour); } -function rollDie(faces) { return Math.floor(Math.random() * faces) + 1; } -function rollDice(terms, opts) { - const tearing = !!(opts && opts.tearing); - const proven = Math.max(0, opts && typeof opts.proven === 'number' ? opts.proven : 0); - const out = []; - for (const t of terms) { - for (let i = 0; i < t.count; i++) { - let r = rollDie(t.faces); - if (tearing) { const alt = rollDie(t.faces); r = Math.max(r, alt); } - if (proven > 0 && t.faces === 10) r = Math.max(r, proven); - out.push(r); - } - } - return { rolls: out, total: out.reduce((a, b) => a + b, 0) }; -} -function parseDice(spec) { - const s = String(spec || '').replace(/\s+/g, '').toLowerCase(); - const parts = s.split('+'); - let flat = 0; - let terms = []; - for (const p of parts) { - if (!p) continue; - const m = p.match(/^(\d+)d(\d+)$/); - if (m) { const c = parseInt(m[1], 10); const f = parseInt(m[2], 10); if (!Number.isFinite(c) || !Number.isFinite(f) || c < 1 || f < 2) throw new Error('Invalid dice bounds'); terms.push({ count: c, faces: f }); } - else { const n = Number(p); if (!Number.isNaN(n)) flat += n; else throw new Error(`Invalid dice term: ${p}`); } - } - if (terms.length === 0) terms = [{ count: 1, faces: 10 }]; - return { terms, flat }; -} - -// ─── Enemy data ─── -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 ENEMY_THEMES = { - tyranid: { - label: 'Tyranid Swarm', - enemies: ['Hormagaunt', 'Termagant', 'Tyranid Warrior', 'Hive Tyrant'], - flavor: 'The swarm descends upon you, a tide of chitin and chitin-clawed hunger.', - sceneNames: ['The Swarm Approaches', 'First Contact', 'The Hive Mind Awakens', 'The Tyrant Rises', 'Extermination', 'The Last Stand'], - descriptions: [ - 'A distant tremor grows into a roar — the swarm is upon you. Wave after wave of chitin and claws, driven by a hunger older than the Imperium itself.', - 'The first wave hits with terrifying speed. Hormagaunts pour over the ridge, their screeching filling the air as they close in from every direction.', - 'Through the chaos, a larger shape emerges — a Tyranid Warrior, its carapace gleaming with the intelligence of the Hive Mind. It directs the swarm with terrifying purpose.', - 'The ground shakes as the Hive Tyrant rises to its full height. A creature of pure destruction, it commands the swarm with the full might of the Hive Mind.', - 'The swarm thins, but the Tyrant remains. Its carapace is thick, its claws deadly. The Astartes stand firm, but the cost of victory will be high.', - 'One by one, the creatures fall. The swarm is broken. But the Hive Mind will send more. The Astartes stand victorious, but the war is far from over.' - ] - }, - chaos: { - label: 'Chaos Forces', - enemies: ['Chaos Space Marine', 'Industrial Servitor'], - flavor: 'The corrupted ones march forth, their weapons raised against the light of the Emperor.', - sceneNames: ['The Enemy Rises', 'First Blood', 'The Battle Intensifies', 'The Champion Falls', 'The Last Stand', 'Victory'], - descriptions: [ - 'The enemy emerges from the shadows, their corrupted armor gleaming with the taint of the Ruinous Powers. They march with purpose, their weapons raised against the light of the Emperor.', - 'The first exchange of fire is brutal. Chaos Space Marines return fire with devastating accuracy, their bolters roaring as they push forward.', - 'The battle intensifies as more enemies pour through the breach. The Astartes hold the line, but the cost of holding the position grows with each passing moment.', - 'A Chaos Champion steps forward, his power weapon crackling with dark energy. He challenges the Astartes to single combat, his eyes burning with corrupted fervor.', - 'The Champion falls, his corrupted armor shattered. But the battle is far from over — more enemies pour through the breach, their numbers seemingly endless.', - 'The last of the corrupted ones falls. The Astartes stand victorious, but the cost of victory is high. The Emperor's light shines through the darkness.' - ] - }, - xenos: { - label: 'Xenos Threat', - enemies: ['Tau Commander (Crisis Suit)', 'Industrial Servitor'], - flavor: 'The alien threat emerges from the shadows, their weapons trained on the Astartes.', - sceneNames: ['The Alien Threat', 'First Contact', 'The Battle Begins', 'The Commander Falls', 'The Last Stand', 'Victory'], - descriptions: [ - 'The alien threat emerges from the shadows, their weapons trained on the Astartes. The Tau Commander stands at the head of his forces, his Crisis Suit gleaming with advanced technology.', - 'The first exchange of fire is brutal. The Tau Commander\'s Crisis Suit returns fire with devastating accuracy, its plasma cannon roaring as it pushes forward.', - 'The battle intensifies as more enemies pour through the breach. The Astartes hold the line, but the cost of holding the position grows with each passing moment.', - 'The Tau Commander falls, his Crisis Suit shattered. But the battle is far from over — more enemies pour through the breach, their numbers seemingly endless.', - 'The last of the xenos falls. The Astartes stand victorious, but the cost of victory is high. The Emperor\'s light shines through the darkness.', - 'The alien threat is broken. The Astartes stand victorious, but the war is far from over. The Emperor\'s light shines through the darkness.' - ] - }, - ork: { - label: 'Ork Waaagh!', - enemies: ['Ork Boy', 'Ork Nob'], - flavor: 'The Orks charge forth, their Waaagh! echoing across the battlefield.', - sceneNames: ['The Waaagh! Begins', 'First Blood', 'The Battle Intensifies', 'The Nob Falls', 'The Last Stand', 'Victory'], - descriptions: [ - 'The Orks charge forth, their Waaagh! echoing across the battlefield. They come in waves, their crude weapons raised against the Astartes.', - 'The first exchange of fire is brutal. Ork Boys return fire with devastating accuracy, their shootas roaring as they push forward.', - 'The battle intensifies as more Orks pour through the breach. The Astartes hold the line, but the cost of holding the position grows with each passing moment.', - 'An Ork Nob steps forward, his power klaw crackling with dark energy. He challenges the Astartes to single combat, his eyes burning with Ork fervor.', - 'The Nob falls, his power klaw shattered. But the battle is far from over — more Orks pour through the breach, their numbers seemingly endless.', - 'The last of the Orks falls. The Astartes stand victorious, but the cost of victory is high. The Emperor\'s light shines through the darkness.' - ] - } -}; - -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); } - -// ─── Copilot integration ─── -async function callCopilot(prompt, systemPrompt = 'You are a Warhammer 40k Deathwatch RPG narrator. Write in the style of a grimdark tabletop RPG. Keep responses concise (2-3 sentences). Use vivid, atmospheric language appropriate to the Warhammer 40k universe.') { - try { - const res = await fetch('https://copilot-api.github.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${process.env.REACT_APP_COPILOT_API_KEY || ''}` - }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: prompt } - ], - max_tokens: 300, - temperature: 0.8 - }) - }); - if (res.ok) { - const data = await res.json(); - return data.choices?.[0]?.message?.content?.trim() || ''; - } - } catch (e) { - console.warn('Copilot call failed:', e.message); - } - return null; -} - -// ─── Mission generation ─── -function generateMission(config) { - const theme = ENEMY_THEMES[config.theme] || ENEMY_THEMES.tyranid; - const sceneCount = config.sceneCount; - const enemyCount = config.enemyCount; - const threatMod = THREAT_MODIFIERS[config.threat]; - - const scenes = []; - 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 === theme.enemies[j % theme.enemies.length]) || ENEMY_TYPES[1]; - const wounds = Math.floor(baseEnemy.wounds * (1 + threatMod.enemyBonus / 100)); - sceneEnemies.push({ - ...baseEnemy, - id: uid(), - currentWounds: wounds, - maxWounds: wounds, - name: `${baseEnemy.name} #${j + 1}` - }); - } - - scenes.push({ - id: uid(), - number: i + 1, - type: sceneType, - name: theme.sceneNames[i] || `Scene ${i + 1}`, - description: theme.descriptions[i] || `Scene ${i + 1} of the mission.`, - enemies: sceneEnemies, - completed: false, - result: null, - threatMod: threatMod.sceneBonus - }); - } - - return scenes; -} - -const THREAT_MODIFIERS = { - 'Low': { enemyBonus: -10, sceneBonus: 0 }, - 'Medium': { enemyBonus: 0, sceneBonus: 0 }, - 'High': { enemyBonus: 10, sceneBonus: 1 }, - 'Extreme': { enemyBonus: 20, sceneBonus: 2 } -}; - function MissionTab({ authedPlayer }) { - const [scenes, setScenes] = useState([]); - const [currentScene, setCurrentScene] = useState(0); - const [threat, setThreat] = useState('Medium'); - const [missionName, setMissionName] = 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); - const [copilotText, setCopilotText] = useState(''); - const [copilotLoading, setCopilotLoading] = useState(false); - - // Setup state - const [sceneCount, setSceneCount] = useState(4); - const [enemyTheme, setEnemyTheme] = useState('tyranid'); - const [enemyCount, setEnemyCount] = useState(3); - const [playerCount, setPlayerCount] = useState(3); - - // Combat state - const [selectedEnemy, setSelectedEnemy] = useState(null); - const [selectedPlayer, setSelectedPlayer] = useState(null); - - // History const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedMission, setSelectedMission] = useState(null); const [showHistory, setShowHistory] = useState(false); - const [loadingHistory, setLoadingHistory] = useState(false); - // Load mission history useEffect(() => { async function loadHistory() { - setLoadingHistory(true); + setLoading(true); try { const res = await axios.get('/api/missions'); setHistory(res.data || []); } catch (e) { console.warn('Failed to load missions:', e.message); } - setLoadingHistory(false); + setLoading(false); } loadHistory(); }, []); - // Save mission to DB when it completes - useEffect(() => { - if (showResults && scenes.length > 0) { - const missionData = { - name: missionName || 'Untitled Mission', - theme: enemyTheme, - sceneCount, - enemyCount, - threatLevel: threat, - playerCount, - scenes, - gmPlayer: authedPlayer - }; - async function saveMission() { - try { - await axios.post('/api/missions', missionData); - } catch (e) { - console.warn('Failed to save mission:', e.message); - } - } - saveMission(); - } - }, [showResults]); - - // Preload Copilot text on mission generation - useEffect(() => { - if (showCombat && scenes.length > 0 && !copilotText) { - const scene = scenes[currentScene]; - if (scene) { - setCopilotLoading(true); - callCopilot( - `Write a 2-3 sentence opening description for this Deathwatch mission scene:\n\nMission: ${missionName || 'Untitled'}\nScene: ${scene.name}\nType: ${scene.type}\nEnemies: ${scene.enemies.map(e => e.name).join(', ')}\nTheme: ${ENEMY_THEMES[enemyTheme]?.label || 'Unknown'}\n\nWrite in the style of a grimdark tabletop RPG narrator.`, - 'You are a Warhammer 40k Deathwatch RPG narrator. Write in the style of a grimdark tabletop RPG. Keep responses concise (2-3 sentences). Use vivid, atmospheric language appropriate to the Warhammer 40k universe.' - ).then(text => { - if (text) setCopilotText(text); - setCopilotLoading(false); - }); - } - } - }, [showCombat, currentScene, scenes.length]); - - function generateMission() { - const config = { - theme: enemyTheme, - sceneCount, - enemyCount, - threat - }; - const newScenes = generateMission(config); - setScenes(newScenes); - setCurrentScene(0); - setCombatLog([]); - setSceneComplete(false); - setSceneResult(null); - setCopilotText(''); - setShowSetup(false); - setShowCombat(true); + function loadMission(mission) { + setSelectedMission(mission); + setShowHistory(false); } - function runCombat() { - const scene = scenes[currentScene]; - if (!scene) return; - - const log = []; - let totalDamage = 0; - let enemiesDefeated = 0; - - for (let round = 1; round <= 3; round++) { - for (const enemy of scene.enemies) { - if (enemy.currentWounds <= 0) { - enemiesDefeated++; - continue; - } - - 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 dmgSpec = parseDice('1d10+5'); - const r = rollDice(dmgSpec.terms, {}); - const damage = Math.max(0, r.total + dmgSpec.flat - 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 - }); - } - - 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([]); - setCopilotText(''); - } else { - setShowCombat(false); - setShowResults(true); - } - } - - function resetMission() { - setShowSetup(true); - setShowCombat(false); - setShowResults(false); - setScenes([]); - setCurrentScene(0); - setCombatLog([]); - setSceneComplete(false); - setSceneResult(null); - setCopilotText(''); - } - - if (!authedPlayer) { - return ( -
-
-
-

Mission Simulation

-

Please log in to access mission simulation

-
-
-
- ); + function backToHistory() { + setSelectedMission(null); + setShowHistory(true); } return (
-

Mission Simulation

-
- {showSetup && ( - - )} - {showResults && ( - - )} - -
+

Mission

+
- {/* 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} -
- ))} -
-
-
- - {/* Copilot Narrative */} -
-
-

Narrative

- {copilotLoading && Loading...} -
-
- {copilotText || (copilotLoading ? 'Generating...' : scenes[currentScene]?.description || '')} -
-
- - {/* 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)} -
-
-
-
- )} - - {/* History Panel */} + {/* Mission History */} {showHistory && (

Mission History

- {loadingHistory ? ( + {loading ? (

Loading...

) : history.length === 0 ? (

No missions yet.

) : (
{history.map(m => ( -
+ + ))} +
+ )} +
+ )} + + {/* Selected Mission Detail */} + {selectedMission && ( +
+ +

{selectedMission.name}

+
+
+
Theme
+
{selectedMission.theme}
+
+
+
Threat
+
{selectedMission.threat_level}
+
+
+
Scenes
+
{selectedMission.scene_count}
+
+
+
Enemies
+
{selectedMission.enemy_count}
+
+
+ {selectedMission.scenes && selectedMission.scenes.length > 0 && ( +
+

Scenes

+ {selectedMission.scenes.map((s, i) => ( +
+
+ {s.name || `Scene ${i + 1}`} + {s.type} +
+ {s.description && ( +

{s.description}

+ )}
))}