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 Dice Roller - {logUserAction('navigation', 'Tab switch', { from: tab, to: 'shop' }); setTab('shop')}} > Requisition Shop - {logUserAction('navigation', 'Tab switch', { from: tab, to: 'player' }); setTab('player')}} > Character Sheet @@ -303,12 +304,6 @@ function App() { > Weapons - {logUserAction('navigation', 'Tab switch', { from: tab, to: 'mission' }); setTab('mission')}} - > - Mission Sim - {authedPlayer === 'gm' && ( <> - {logUserAction('navigation', 'Tab switch', { from: tab, to: 'missionsim' }); setTab('missionsim')}} + > + Mission Sim + + {logUserAction('navigation', 'Tab switch', { from: tab, to: 'bestiary' }); setTab('bestiary')}} > Bestiary - {logUserAction('navigation', 'Tab switch', { from: tab, to: 'players' }); setTab('players')}} > Player Management - {logUserAction('navigation', 'Tab switch', { from: tab, to: 'gmkit' }); setTab('gmkit')}} > GM Kit @@ -384,7 +385,7 @@ function App() { )} - {tab==='roller' ? : tab==='shop' ? : tab==='rules' ? : tab==='weapons' ? : tab==='mission' ? : tab==='bestiary' ? (authedPlayer === 'gm' ? : Access DeniedThe 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 DeniedThe Bestiary is only accessible to Game Masters. Please log in with a GM account.) : tab==='players' ? : tab==='gmkit' ? : tab==='missionsim' ? (authedPlayer === 'gm' ? : Access DeniedMission Simulation is only accessible to Game Masters. Please log in with a GM account.) : } diff --git a/src/components/MissionSimTab.jsx b/src/components/MissionSimTab.jsx new file mode 100644 index 0000000..7b8b178 --- /dev/null +++ b/src/components/MissionSimTab.jsx @@ -0,0 +1,703 @@ +import React, { useState, useEffect, useCallback } 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 [showHistory, setShowHistory] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(false); + + // Load mission history + useEffect(() => { + async function loadHistory() { + setLoadingHistory(true); + try { + const res = await axios.get('/api/missions'); + setHistory(res.data || []); + } catch (e) { + console.warn('Failed to load missions:', e.message); + } + setLoadingHistory(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 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 + + + + ); + } + + return ( + + + + Mission Simulation + + {showSetup && ( + + Generate Mission + + )} + {showResults && ( + + New Mission + + )} + setShowHistory(!showHistory)} + className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg" + > + {showHistory ? 'Hide History' : 'Mission History'} + + + + + {/* Setup Panel */} + {showSetup && ( + + Mission Setup + + + Mission Name + setMissionName(e.target.value)} + placeholder="Mission Name" + /> + + + Scenes + setSceneCount(parseInt(e.target.value))} + > + {[4, 5, 6].map(n => {n} scenes)} + + + + Enemy Theme + setEnemyTheme(e.target.value)} + > + {Object.entries(ENEMY_THEMES).map(([key, theme]) => ( + {theme.label} + ))} + + + + Enemy Count + setEnemyCount(parseInt(e.target.value))} + > + {[1, 2, 3, 4, 5, 6].map(n => {n})} + + + + + + Threat Level + setThreat(e.target.value)} + > + {THREAT_LEVELS.map(t => {t})} + + + + Player Count + setPlayerCount(parseInt(e.target.value))} + > + {[1, 2, 3, 4, 5, 6].map(n => {n})} + + + + + )} + + {/* 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 + + + Attacker + setSelectedPlayer(e.target.value)} + > + Select Player + {Array.from({ length: playerCount }, (_, i) => ( + Player {i + 1} + ))} + + + + Target + setSelectedEnemy(e.target.value)} + > + Select Enemy + {scenes[currentScene]?.enemies.map(enemy => ( + + {enemy.name} {enemy.currentWounds <= 0 ? '(Defeated)' : ''} + + ))} + + + + + + Run Combat + + {sceneComplete && ( + + Complete Scene + + )} + + + + {/* 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 && ( - - Generate Mission - - )} - {showResults && ( - - New Mission - - )} - setShowHistory(!showHistory)} - className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg" - > - {showHistory ? 'Hide History' : 'Mission History'} - - + Mission + setShowHistory(!showHistory)} + className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg" + > + {showHistory ? 'Hide History' : 'Mission History'} + - {/* Setup Panel */} - {showSetup && ( - - Mission Setup - - - Mission Name - setMissionName(e.target.value)} - placeholder="Mission Name" - /> - - - Scenes - setSceneCount(parseInt(e.target.value))} - > - {[4, 5, 6].map(n => {n} scenes)} - - - - Enemy Theme - setEnemyTheme(e.target.value)} - > - {Object.entries(ENEMY_THEMES).map(([key, theme]) => ( - {theme.label} - ))} - - - - Enemy Count - setEnemyCount(parseInt(e.target.value))} - > - {[1, 2, 3, 4, 5, 6].map(n => {n})} - - - - - - Threat Level - setThreat(e.target.value)} - > - {THREAT_LEVELS.map(t => {t})} - - - - Player Count - setPlayerCount(parseInt(e.target.value))} - > - {[1, 2, 3, 4, 5, 6].map(n => {n})} - - - - - )} - - {/* 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 - - - Attacker - setSelectedPlayer(e.target.value)} - > - Select Player - {Array.from({ length: playerCount }, (_, i) => ( - Player {i + 1} - ))} - - - - Target - setSelectedEnemy(e.target.value)} - > - Select Enemy - {scenes[currentScene]?.enemies.map(enemy => ( - - {enemy.name} {enemy.currentWounds <= 0 ? '(Defeated)' : ''} - - ))} - - - - - - Run Combat - - {sceneComplete && ( - - Complete Scene - - )} - - - - {/* 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 => ( - + loadMission(m)} + className="w-full p-3 rounded-lg bg-slate-700 border border-slate-600 hover:bg-slate-600 text-left" + > {m.name} @@ -689,6 +71,50 @@ function MissionTab({ authedPlayer }) { {new Date(m.created_at).toLocaleDateString('da-DK')} + + ))} + + )} + + )} + + {/* Selected Mission Detail */} + {selectedMission && ( + + + ← Back to History + + {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} + )} ))}
The Bestiary is only accessible to Game Masters. Please log in with a GM account.
Mission Simulation is only accessible to Game Masters. Please log in with a GM account.
Please log in to access mission simulation
Loading...
No missions yet.
{s.description}