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 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 10:16:35 +02:00
parent fe1b77ec68
commit fb019057bb
3 changed files with 791 additions and 661 deletions

View File

@@ -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
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='roller' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
@@ -279,14 +280,14 @@ function App() {
>
Dice Roller
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='shop' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='shop' ? '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: 'shop' }); setTab('shop')}}
>
Requisition Shop
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='player' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='player' ? '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: 'player' }); setTab('player')}}
>
Character Sheet
@@ -303,12 +304,6 @@ function App() {
>
Weapons
</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
href="/cards.html"
target="_blank"
@@ -327,20 +322,26 @@ function App() {
</a>
{authedPlayer === 'gm' && (
<>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='bestiary' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='missionsim' ? '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: 'missionsim' }); setTab('missionsim')}}
>
Mission Sim
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='bestiary' ? '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: 'bestiary' }); setTab('bestiary')}}
>
Bestiary
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='players' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='players' ? '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: 'players' }); setTab('players')}}
>
Player Management
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='gmkit' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='gmkit' ? '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: 'gmkit' }); setTab('gmkit')}}
>
GM Kit
@@ -384,7 +385,7 @@ function App() {
</div>
)}
{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
{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} /> : tab==='missionsim' ? (authedPlayer === 'gm' ? <MissionSimTab authedPlayer={authedPlayer} /> : <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">Mission Simulation is only accessible to Game Masters. Please log in with a GM account.</p></div>) : <PlayerTab
authedPlayer={authedPlayer}
sessionId={sessionId}
/>}

View File

@@ -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 (
<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>
)}
<button
onClick={() => setShowHistory(!showHistory)}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg"
>
{showHistory ? 'Hide History' : 'Mission History'}
</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 Theme</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={enemyTheme}
onChange={e => setEnemyTheme(e.target.value)}
>
{Object.entries(ENEMY_THEMES).map(([key, theme]) => (
<option key={key} value={key}>{theme.label}</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>
{/* Copilot Narrative */}
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">Narrative</h3>
{copilotLoading && <span className="text-xs text-slate-400">Loading...</span>}
</div>
<div className="text-sm text-slate-300 italic leading-relaxed">
{copilotText || (copilotLoading ? 'Generating...' : scenes[currentScene]?.description || '')}
</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>
)}
{/* History Panel */}
{showHistory && (
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6">
<h2 className="text-xl font-semibold mb-4">Mission History</h2>
{loadingHistory ? (
<p className="text-slate-400">Loading...</p>
) : history.length === 0 ? (
<p className="text-slate-400">No missions yet.</p>
) : (
<div className="space-y-3">
{history.map(m => (
<div key={m.id} className="p-3 rounded-lg bg-slate-700 border border-slate-600">
<div className="flex items-center justify-between">
<div>
<span className="font-medium">{m.name}</span>
<span className="ml-2 text-xs text-slate-400">
{m.theme} · {m.threat_level} · {m.scene_count} scenes
</span>
</div>
<span className="text-xs text-slate-500">
{new Date(m.created_at).toLocaleDateString('da-DK')}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</section>
);
}
export default MissionTab;

View File

@@ -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 (
<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>
);
function backToHistory() {
setSelectedMission(null);
setShowHistory(true);
}
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>
)}
<button
onClick={() => setShowHistory(!showHistory)}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg"
>
{showHistory ? 'Hide History' : 'Mission History'}
</button>
</div>
<h1 className="text-3xl font-bold">Mission</h1>
<button
onClick={() => setShowHistory(!showHistory)}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg"
>
{showHistory ? 'Hide History' : 'Mission History'}
</button>
</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 Theme</label>
<select
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
value={enemyTheme}
onChange={e => setEnemyTheme(e.target.value)}
>
{Object.entries(ENEMY_THEMES).map(([key, theme]) => (
<option key={key} value={key}>{theme.label}</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>
{/* Copilot Narrative */}
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">Narrative</h3>
{copilotLoading && <span className="text-xs text-slate-400">Loading...</span>}
</div>
<div className="text-sm text-slate-300 italic leading-relaxed">
{copilotText || (copilotLoading ? 'Generating...' : scenes[currentScene]?.description || '')}
</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>
)}
{/* History Panel */}
{/* Mission History */}
{showHistory && (
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6">
<h2 className="text-xl font-semibold mb-4">Mission History</h2>
{loadingHistory ? (
{loading ? (
<p className="text-slate-400">Loading...</p>
) : history.length === 0 ? (
<p className="text-slate-400">No missions yet.</p>
) : (
<div className="space-y-3">
{history.map(m => (
<div key={m.id} className="p-3 rounded-lg bg-slate-700 border border-slate-600">
<button
key={m.id}
onClick={() => loadMission(m)}
className="w-full p-3 rounded-lg bg-slate-700 border border-slate-600 hover:bg-slate-600 text-left"
>
<div className="flex items-center justify-between">
<div>
<span className="font-medium">{m.name}</span>
@@ -689,6 +71,50 @@ function MissionTab({ authedPlayer }) {
{new Date(m.created_at).toLocaleDateString('da-DK')}
</span>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Selected Mission Detail */}
{selectedMission && (
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6">
<button onClick={backToHistory} className="mb-4 text-sm text-blue-400 hover:text-blue-300">
Back to History
</button>
<h2 className="text-2xl font-bold mb-2">{selectedMission.name}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-xs text-slate-300">Theme</div>
<div className="font-medium">{selectedMission.theme}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-xs text-slate-300">Threat</div>
<div className="font-medium">{selectedMission.threat_level}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-xs text-slate-300">Scenes</div>
<div className="font-medium">{selectedMission.scene_count}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-xs text-slate-300">Enemies</div>
<div className="font-medium">{selectedMission.enemy_count}</div>
</div>
</div>
{selectedMission.scenes && selectedMission.scenes.length > 0 && (
<div className="space-y-2">
<h3 className="text-lg font-semibold">Scenes</h3>
{selectedMission.scenes.map((s, i) => (
<div key={s.id || i} className="p-3 rounded-lg bg-slate-700 border border-slate-600">
<div className="flex items-center justify-between">
<span className="font-medium">{s.name || `Scene ${i + 1}`}</span>
<span className="text-xs text-slate-400">{s.type}</span>
</div>
{s.description && (
<p className="text-sm text-slate-300 mt-1">{s.description}</p>
)}
</div>
))}
</div>