Add mission persistence: missions table, API routes, and history UI

- MariaDB missions table with scenes JSON
- /api/missions CRUD routes
- MissionTab saves completed missions to DB
- Mission history panel in UI

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
2026-04-23 10:07:46 +02:00
co-authored by Claude Opus 4.7
parent dbb79c2537
commit fe1b77ec68
5 changed files with 505 additions and 67 deletions
+93
View File
@@ -88,6 +88,26 @@ const createTables = async () => {
)
`);
// Missions table
await connection.execute(`
CREATE TABLE IF NOT EXISTS missions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(500) NOT NULL DEFAULT 'Untitled Mission',
theme VARCHAR(100) NOT NULL DEFAULT 'tyranid',
scene_count INT NOT NULL DEFAULT 4,
enemy_count INT NOT NULL DEFAULT 3,
threat_level VARCHAR(50) NOT NULL DEFAULT 'Medium',
player_count INT NOT NULL DEFAULT 3,
scenes JSON DEFAULT ('[]'),
gm_player VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_created_at ON missions(created_at)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_theme ON missions(theme)`);
// Create indexes
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_players_name ON players(name)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)`);
@@ -441,6 +461,78 @@ const bestiaryHelpers = {
}
};
// Mission helpers
const missionHelpers = {
getAll: async () => {
try {
const [rows] = await pool.execute('SELECT * FROM missions ORDER BY created_at DESC');
return rows.map(row => ({
...row,
scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes
}));
} catch (error) {
logToFile('MariaDB: Error getting all missions', error);
return [];
}
},
getById: async (id) => {
try {
const [rows] = await pool.execute('SELECT * FROM missions WHERE id = ?', [id]);
if (rows.length === 0) return null;
const row = rows[0];
return {
...row,
scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes
};
} catch (error) {
logToFile('MariaDB: Error getting mission', id, error);
return null;
}
},
create: async (missionData) => {
try {
const { name, theme, sceneCount, enemyCount, threatLevel, playerCount, scenes, gmPlayer } = missionData;
const [result] = await pool.execute(
'INSERT INTO missions (name, theme, scene_count, enemy_count, threat_level, player_count, scenes, gm_player) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[name, theme, sceneCount, enemyCount, threatLevel, playerCount, JSON.stringify(scenes || []), gmPlayer || null]
);
logToFile('MariaDB: Created mission', name);
return result.insertId;
} catch (error) {
logToFile('MariaDB: Error creating mission', error);
return null;
}
},
update: async (id, missionData) => {
try {
const { name, theme, sceneCount, enemyCount, threatLevel, playerCount, scenes } = missionData;
const [result] = await pool.execute(
'UPDATE missions SET name = ?, theme = ?, scene_count = ?, enemy_count = ?, threat_level = ?, player_count = ?, scenes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[name, theme, sceneCount, enemyCount, threatLevel, playerCount, JSON.stringify(scenes), id]
);
logToFile('MariaDB: Updated mission', id);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error updating mission', id, error);
return false;
}
},
delete: async (id) => {
try {
const [result] = await pool.execute('DELETE FROM missions WHERE id = ?', [id]);
logToFile('MariaDB: Deleted mission', id);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error deleting mission', id, error);
return false;
}
}
};
// Initialize database
createTables().catch(error => {
console.error('Failed to initialize MariaDB:', error);
@@ -456,5 +548,6 @@ module.exports = {
stagingHelpers,
weaponsHelpers,
bestiaryHelpers,
missionHelpers,
logToFile
};
+66
View File
@@ -0,0 +1,66 @@
const express = require('express');
const { missionHelpers, logToFile } = require('../mariadb');
const router = express.Router();
// Get all missions
router.get('/', async (req, res) => {
try {
const missions = await missionHelpers.getAll();
res.json(missions);
} catch (error) {
logToFile('API: Failed to get missions', error);
res.status(500).json({ error: String(error) });
}
});
// Get mission by ID
router.get('/:id', async (req, res) => {
try {
const mission = await missionHelpers.getById(req.params.id);
if (!mission) return res.status(404).json({ error: 'Mission not found' });
res.json(mission);
} catch (error) {
logToFile('API: Failed to get mission', req.params.id, error);
res.status(500).json({ error: String(error) });
}
});
// Create mission
router.post('/', async (req, res) => {
try {
const id = await missionHelpers.create(req.body);
if (!id) return res.status(500).json({ error: 'Failed to create mission' });
logToFile('API: Created mission', req.body.name);
res.json({ success: true, id });
} catch (error) {
logToFile('API: Failed to create mission', error);
res.status(500).json({ error: String(error) });
}
});
// Update mission
router.put('/:id', async (req, res) => {
try {
const ok = await missionHelpers.update(req.params.id, req.body);
if (!ok) return res.status(404).json({ error: 'Mission not found' });
res.json({ success: true });
} catch (error) {
logToFile('API: Failed to update mission', req.params.id, error);
res.status(500).json({ error: String(error) });
}
});
// Delete mission
router.delete('/:id', async (req, res) => {
try {
const ok = await missionHelpers.delete(req.params.id);
if (!ok) return res.status(404).json({ error: 'Mission not found' });
res.json({ success: true });
} catch (error) {
logToFile('API: Failed to delete mission', req.params.id, error);
res.status(500).json({ error: String(error) });
}
});
console.log('Mission routes registered (MariaDB)');
module.exports = router;
+12 -1
View File
@@ -11,6 +11,7 @@ const rulesRoutes = require('./routes/rulesRoutes');
const bestiaryRoutes = require('./routes/bestiaryRoutes');
const weaponsRoutes = require('./routes/weaponsRoutes');
const rulesStagingRoutes = require('./routes/rulesStagingRoutes');
const missionRoutes = require('./routes/missionRoutes');
// const rulesRoutes = require('./routes/rulesRoutes-simple');
const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit');
@@ -92,7 +93,17 @@ try {
console.log('Rules staging routes registered');
} catch (e) {
console.error('Error mounting /api/rules/staging:', e && e.stack ? e.stack : e);
} // Expose gamemaster kit files and a simple listing API for GM-only resources
}
try {
console.log('Registering /api/missions');
app.use('/api/missions', missionRoutes);
console.log('Mission routes registered');
} catch (e) {
console.error('Error mounting /api/missions:', e && e.stack ? e.stack : e);
}
// Expose gamemaster kit files and a simple listing API for GM-only resources
try {
console.log('Registering /api/gmkit and /gmkit static');
app.get('/api/gmkit/list', (req, res) => {
+9 -3
View File
@@ -14,7 +14,7 @@ import axios from 'axios';
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
function App() {
const [tab, setTab] = useState('roller');
const [tab, setTab] = useState('mission');
// Global login/session state
const [authedPlayer, setAuthedPlayer] = useState(() => localStorage.getItem('dw:shop:authedPlayer') ? JSON.parse(localStorage.getItem('dw:shop:authedPlayer')) : '');
const [sessionId, setSessionId] = useState(() => localStorage.getItem('dw:shop:sessionId') ? JSON.parse(localStorage.getItem('dw:shop:sessionId')) : '');
@@ -267,8 +267,14 @@ function App() {
{/* Tab Navigation */}
<div className="flex gap-1">
<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'}`}
<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>
<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'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'roller' }); setTab('roller')}}
>
Dice Roller
+325 -63
View File
@@ -1,19 +1,215 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
// Inline combat helpers (mirrors DeathwatchRoller logic)
// ─── 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) };
}
// Combat helpers (mirrors DeathwatchRoller logic)
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);
@@ -25,82 +221,98 @@ function MissionTab({ authedPlayer }) {
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 [enemyType, setEnemyType] = useState('Tyranid Warrior');
const [enemyTheme, setEnemyTheme] = useState('tyranid');
const [enemyCount, setEnemyCount] = useState(3);
const [playerCount, setPlayerCount] = useState(2);
const [playerCount, setPlayerCount] = useState(3);
// Combat state
const [selectedEnemy, setSelectedEnemy] = useState(null);
const [selectedPlayer, setSelectedPlayer] = useState(null);
// Predefined enemy types from DeathwatchRoller
const ENEMY_TYPES = [
{ name: 'Custom/None', tb: 4, armour: 5, wounds: 20 },
{ name: 'Imperial Guardsman', tb: 3, armourByLoc: { 'Head': 4, 'Body': 4, 'Left Arm': 4, 'Right Arm': 4, 'Left Leg': 4, 'Right Leg': 4 }, wounds: 10 },
{ name: 'Chaos Space Marine', tb: 8, armourByLoc: { 'Head': 8, 'Body': 10, 'Left Arm': 8, 'Right Arm': 8, 'Left Leg': 8, 'Right Leg': 8 }, wounds: 29 },
{ name: 'Tyranid Warrior', tb: 10, armourByLoc: { 'Head': 8, 'Body': 8, 'Left Arm': 8, 'Right Arm': 8, 'Left Leg': 8, 'Right Leg': 8 }, wounds: 48 },
{ name: 'Hormagaunt', tb: 3, armourByLoc: { 'Head': 3, 'Body': 3, 'Left Arm': 3, 'Right Arm': 3, 'Left Leg': 3, 'Right Leg': 3 }, wounds: 9 },
{ name: 'Termagant', tb: 3, armourByLoc: { 'Head': 3, 'Body': 3, 'Left Arm': 3, 'Right Arm': 3, 'Left Leg': 3, 'Right Leg': 3 }, wounds: 9 },
{ name: 'Hive Tyrant', tb: 15, armourByLoc: { 'Head': 10, 'Body': 10, 'Left Arm': 10, 'Right Arm': 10, 'Left Leg': 10, 'Right Leg': 10 }, wounds: 120 },
{ name: 'Tau Commander (Crisis Suit)', tb: 10, armourByLoc: { 'Head': 9, 'Body': 9, 'Left Arm': 9, 'Right Arm': 9, 'Left Leg': 9, 'Right Leg': 9 }, wounds: 90 },
{ name: 'Industrial Servitor', tb: 5, armourByLoc: { 'Head': 7, 'Body': 7, 'Left Arm': 7, 'Right Arm': 7, 'Left Leg': 7, 'Right Leg': 7 }, wounds: 20 },
{ name: 'Ork Boy', tb: 5, armour: 3, wounds: 13 },
{ name: 'Ork Nob', tb: 6, armour: 4, wounds: 22 },
{ name: 'Genestealer', tb: 6, armour: 6, wounds: 22 }
];
// History
const [history, setHistory] = useState([]);
const [showHistory, setShowHistory] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false);
const THREAT_MODIFIERS = {
'Low': { enemyBonus: -10, sceneBonus: 0 },
'Medium': { enemyBonus: 0, sceneBonus: 0 },
'High': { enemyBonus: 10, sceneBonus: 1 },
'Extreme': { enemyBonus: 20, sceneBonus: 2 }
};
// 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();
}, []);
// Generate mission scenes
function generateMission() {
const newScenes = [];
const threatMod = THREAT_MODIFIERS[threat];
// 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]);
for (let i = 0; i < sceneCount; i++) {
const sceneType = SCENE_TYPES[i % SCENE_TYPES.length];
const sceneEnemies = [];
for (let j = 0; j < enemyCount; j++) {
const baseEnemy = ENEMY_TYPES.find(e => e.name === enemyType) || ENEMY_TYPES[1];
sceneEnemies.push({
...baseEnemy,
id: uid(),
currentWounds: baseEnemy.wounds,
maxWounds: baseEnemy.wounds,
name: `${baseEnemy.name} #${j + 1}`
// 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);
});
}
newScenes.push({
id: uid(),
number: i + 1,
type: sceneType,
name: `${sceneType} ${i + 1}`,
enemies: sceneEnemies,
completed: false,
result: null,
threatMod: threatMod.sceneBonus
});
}
}, [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);
}
// Run combat for current scene
function runCombat() {
const scene = scenes[currentScene];
if (!scene) return;
@@ -109,7 +321,6 @@ function MissionTab({ authedPlayer }) {
let totalDamage = 0;
let enemiesDefeated = 0;
// Simulate combat rounds
for (let round = 1; round <= 3; round++) {
for (const enemy of scene.enemies) {
if (enemy.currentWounds <= 0) {
@@ -117,13 +328,15 @@ function MissionTab({ authedPlayer }) {
continue;
}
// Player attacks enemy
const playerBS = 50 + Math.floor(Math.random() * 20);
const attack = d100();
const dg = degrees(playerBS, attack);
if (dg.success) {
const damage = Math.max(0, Math.floor(Math.random() * 10) + 5 - enemy.armour);
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`);
@@ -147,7 +360,6 @@ function MissionTab({ authedPlayer }) {
});
}
// Complete scene
function completeScene() {
const newScenes = [...scenes];
newScenes[currentScene] = {
@@ -162,13 +374,13 @@ function MissionTab({ authedPlayer }) {
setSceneComplete(false);
setSceneResult(null);
setCombatLog([]);
setCopilotText('');
} else {
setShowCombat(false);
setShowResults(true);
}
}
// Reset mission
function resetMission() {
setShowSetup(true);
setShowCombat(false);
@@ -178,6 +390,7 @@ function MissionTab({ authedPlayer }) {
setCombatLog([]);
setSceneComplete(false);
setSceneResult(null);
setCopilotText('');
}
if (!authedPlayer) {
@@ -209,6 +422,12 @@ function MissionTab({ authedPlayer }) {
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>
@@ -237,13 +456,15 @@ function MissionTab({ authedPlayer }) {
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Enemy Type</label>
<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={enemyType}
onChange={e => setEnemyType(e.target.value)}
value={enemyTheme}
onChange={e => setEnemyTheme(e.target.value)}
>
{ENEMY_TYPES.map(e => <option key={e.name} value={e.name}>{e.name}</option>)}
{Object.entries(ENEMY_THEMES).map(([key, theme]) => (
<option key={key} value={key}>{theme.label}</option>
))}
</select>
</div>
<div>
@@ -307,6 +528,17 @@ function MissionTab({ authedPlayer }) {
</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>
@@ -433,6 +665,36 @@ function MissionTab({ authedPlayer }) {
</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>
);