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:
37
src/App.js
37
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
|
||||
</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}
|
||||
/>}
|
||||
|
||||
703
src/components/MissionSimTab.jsx
Normal file
703
src/components/MissionSimTab.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user