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:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user