import React, { useCallback, useEffect, useMemo, useState } from 'react'; import axios from 'axios'; import { MISSION_ROLL_CONTEXT_KEY, clampTarget, d100, degrees } from '../utils/diceRoller'; const COMBAT_STATE_KEY = 'dw:combat-state'; function titleForScene(scene, index) { return scene?.title || scene?.name || `Scene ${index + 1}`; } function textForScene(scene) { return scene?.description || scene?.briefing || scene?.summary || ''; } function endingTextForScene(scene) { return scene?.endingText || scene?.ending_text || scene?.outro || scene?.completionText || ''; } function listForScene(scene, key) { return Array.isArray(scene?.[key]) ? scene[key] : []; } function extraChallengesForScene(scene) { const stored = Array.isArray(scene?.extraChallenges) ? scene.extraChallenges : []; const source = stored.length ? stored : listForScene(scene, 'complications').map((text, index) => ({ id: `complication-${index}`, text, rewardType: 'renown', rewardValue: 1, completed: false, })); return source .map((item, index) => { const challenge = typeof item === 'string' ? { text: item } : item || {}; const rewardType = challenge.rewardType || challenge.reward_type || 'renown'; const defaultValue = rewardType === 'reserve_req' ? 10 : 1; return { id: challenge.id || `extra-${index}`, text: challenge.text || challenge.name || challenge.description || `Extra challenge ${index + 1}`, rewardType, rewardValue: Number(challenge.rewardValue ?? challenge.reward_value ?? defaultValue) || defaultValue, completed: Boolean(challenge.completed), }; }) .filter(challenge => challenge.text); } function rewardLabel(challenge) { if (!challenge) return ''; if (challenge.rewardType === 'reserve_req') return `+${challenge.rewardValue} Reserve Req`; if (challenge.rewardType === 'xp') return `+${challenge.rewardValue} XP`; return `+${challenge.rewardValue} Renown`; } function isCombatScene(scene) { const type = String(scene?.type || '').toLowerCase(); return type.includes('combat') || type.includes('boss') || Boolean(scene?.combat); } function defaultChecksForScene(scene) { const type = String(scene?.type || '').toLowerCase(); const squadTacticsCheck = { name: 'Squad Tactics', skill: 'Tactics', characteristic: 'Int', target: 45, modifier: 0, rulesNote: 'Core: Tactics is an Advanced Intelligence Skill. Use it to read battlefield doctrine, manoeuvres, ambushes, deployments, or the best approach. It does not grant free Squad Mode abilities or Cohesion.', reward: 'On success, the team identifies the best tactical approach for this scene; extra Degrees of Success may reveal a better position, priority threat, or safer route.', }; if (type.includes('combat')) { return [ squadTacticsCheck, { name: 'Identify priority target', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Learn the most dangerous enemy or ambush vector.' }, { name: 'Hold firing position', skill: 'Dodge', characteristic: 'Ag', target: 45, modifier: -10, reward: 'Avoid being driven out of cover this round.' }, ]; } if (type.includes('investigation') || type.includes('puzzle')) { return [ squadTacticsCheck, { name: 'Read the spoor and auspex ghosts', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Reveal the next clue or hidden movement.' }, { name: 'Decode corrupted systems', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: -10, reward: 'Recover data before the trail degrades.' }, ]; } if (type.includes('social')) { return [ squadTacticsCheck, { name: 'Stabilise frightened survivors', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 0, reward: 'Gain cooperation and a useful witness detail.' }, { name: 'Interrogate conflicting accounts', skill: 'Scrutiny', characteristic: 'Per', target: 45, modifier: -10, reward: 'Separate panic from truth.' }, ]; } return [ squadTacticsCheck, { name: 'Maintain tactical awareness', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Spot the next useful detail.' }, { name: 'Coordinate the kill-team', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 0, reward: 'Grant a clear tactical direction.' }, ]; } function combatForScene(scene) { if (scene?.combat && typeof scene.combat === 'object') return scene.combat; const enemies = sceneEnemies(scene); return { enemy: enemies[0] || 'Custom/None', weapon: '', bs: 45, ws: 45, modifier: String(scene?.type || '').toLowerCase().includes('combat') ? 0 : -10, damage: '1d10+5', targetTB: 4, targetArmour: 5, mode: 'single', rof: 1, }; } function missionTheme(mission) { return mission?.theme ? String(mission.theme).replace(/_/g, ' ') : 'Unassigned'; } function sceneEnemies(scene) { if (!Array.isArray(scene?.enemies)) return []; return scene.enemies.map(enemy => enemy.name || enemy.type || 'Unknown enemy'); } function sceneEnemyObjects(scene) { if (!Array.isArray(scene?.enemies)) return []; return scene.enemies .map(enemy => typeof enemy === 'string' ? { name: enemy } : enemy) .filter(enemy => enemy?.name || enemy?.type) .map(enemy => ({ ...enemy, name: enemy.name || enemy.type })); } const CHARACTERISTIC_KEYS = { WS: ['WS', 'Ws', 'ws'], BS: ['BS', 'Bs', 'bs'], S: ['S', 's'], T: ['T', 't'], Ag: ['Ag', 'AG', 'ag'], Int: ['Int', 'INT', 'int'], Per: ['Per', 'PER', 'per'], WP: ['WP', 'Wp', 'wp'], Wp: ['WP', 'Wp', 'wp'], Fel: ['Fel', 'FEL', 'fel'], }; function characteristicValue(chars, key, fallback = 45) { const keys = CHARACTERISTIC_KEYS[key] || [key]; for (const k of keys) { if (typeof chars?.[k] === 'number') return chars[k]; } return fallback; } function skillTrainingBonus(skill) { if (skill?.plus20) return 20; if (skill?.plus10) return 10; if (skill?.trained) return 0; return -20; } function skillTrainingLabel(skill) { if (skill?.plus20) return '+20 trained'; if (skill?.plus10) return '+10 trained'; if (skill?.trained) return '+0 trained'; return '-20 untrained'; } function matchingSheetSkill(skills, check) { if (!skills || !check) return null; const wanted = String(check.skill || check.name || '').toLowerCase(); return Object.entries(skills).find(([name]) => { const clean = name.toLowerCase().replace(/\s*\([^)]*\)/g, ''); return clean === wanted || clean.includes(wanted) || wanted.includes(clean); })?.[1] || null; } function woundsLabel(wounds) { if (typeof wounds === 'number') return `${wounds} W`; if (!wounds || typeof wounds !== 'object') return 'W ?'; const current = wounds.current ?? wounds.cur ?? wounds.total ?? wounds.max ?? '?'; const total = wounds.total ?? wounds.max ?? current; const fatigue = wounds.fatigue ? `, Fatigue ${wounds.fatigue}` : ''; return `${current}/${total} W${fatigue}`; } function rollSummary(roll) { if (!roll) return ''; const payload = roll.payload || {}; if (roll.roll_type === 'check') { return `Last: ${roll.player_name || 'unknown'} rolled ${payload.roll ?? '?'} vs ${payload.target ?? '?'} - ${payload.success ? `${payload.dos || 0} DoS` : `${payload.dof || 0} DoF`}`; } if (roll.roll_type === 'attack') { const damage = payload.success ? `, damage ${payload.appliedDamage ?? 0} (${payload.woundsBefore ?? '?'} -> ${payload.woundsAfter ?? '?'})` : ''; const dead = payload.defeated ? ' DEAD' : ''; return `Last: ${roll.player_name || payload.attacker || 'unknown'} attack ${payload.attackRoll ?? '?'} vs ${payload.target ?? '?'} - ${payload.success ? 'Hit' : 'Miss'}${damage}${dead}${payload.enemy ? ` (${payload.enemy})` : ''}`; } return ''; } function parseDamageExpression(value) { const text = String(value || ''); const match = text.match(/(\d+)d(\d+)(?:\s*([+-])\s*(\d+))?/i); if (!match) return { count: 1, faces: 10, flat: 0, expression: '1d10' }; const count = Math.max(1, parseInt(match[1], 10) || 1); const faces = Math.max(2, parseInt(match[2], 10) || 10); const sign = match[3] === '-' ? -1 : 1; const flat = match[4] ? sign * (parseInt(match[4], 10) || 0) : 0; return { count, faces, flat, expression: match[0].replace(/\s+/g, '') }; } function rollDamageExpression(value) { const parsed = parseDamageExpression(value); const rolls = Array.from({ length: parsed.count }, () => Math.floor(Math.random() * parsed.faces) + 1); return { ...parsed, rolls, total: rolls.reduce((sum, roll) => sum + roll, 0) + parsed.flat, }; } function firstWeaponDamage(tab, attackSkill) { const weapons = Array.isArray(tab?.weapons) ? tab.weapons : []; const wantedMelee = attackSkill === 'WS'; const weapon = weapons.find(item => { const cls = String(item.class || item.type || '').toLowerCase(); return item.damage && (wantedMelee ? cls.includes('melee') : !cls.includes('melee')); }) || weapons.find(item => item.damage); return { name: weapon?.name || 'Default attack', damage: weapon?.damage || (wantedMelee ? '1d10+5' : '2d10+5'), pen: Number(weapon?.pen || 0) || 0, }; } function normalizeMission(mission) { if (!mission) return null; const scenes = Array.isArray(mission.scenes) ? mission.scenes : []; const currentScene = Math.max(0, Math.min(Number(mission.current_scene || 0), Math.max(scenes.length - 1, 0))); return { ...mission, scenes, current_scene: currentScene }; } const CONDITIONS = ['Pinned', 'Stunned', 'Bleeding', 'Grappled', 'Knocked Down', 'Fatigued']; const FEAR_RATINGS = [ { value: 0, label: 'None', penaltyValue: 0 }, { value: 1, label: 'Disturbing (1)', penalty: '+0 to Fear test', penaltyValue: 0 }, { value: 2, label: 'Frightening (2)', penalty: '–10 to Fear test', penaltyValue: -10 }, { value: 3, label: 'Horrifying (3)', penalty: '–20 to Fear test', penaltyValue: -20 }, { value: 4, label: 'Terrifying (4)', penalty: '–30 to Fear test', penaltyValue: -30 }, ]; const ACTION_ECONOMY = [ { type: 'Half Action', color: 'text-blue-300', border: 'border-blue-800', bg: 'bg-blue-950/30', examples: 'Standard Attack · Move · Aim · Ready · Feint · Stand/Mount' }, { type: 'Full Action', color: 'text-amber-300', border: 'border-amber-800', bg: 'bg-amber-950/30', examples: 'Charge · All Out Attack · Run · Disengage · Grapple · Overwatch' }, { type: 'Reaction', color: 'text-emerald-300', border: 'border-emerald-800', bg: 'bg-emerald-950/30', examples: 'Dodge · Parry (once per round, only when not your turn)' }, { type: 'Free Action', color: 'text-slate-400', border: 'border-slate-700', bg: 'bg-slate-900/30', examples: 'Speak briefly · Drop item · Activate auto-senses' }, ]; const SKILL_DIFFICULTIES = [ { label: 'Trivial', value: 60 }, { label: 'Elementary', value: 50 }, { label: 'Simple', value: 40 }, { label: 'Easy', value: 30 }, { label: 'Routine', value: 20 }, { label: 'Ordinary', value: 10 }, { label: 'Challenging', value: 0 }, { label: 'Difficult', value: -10 }, { label: 'Hard', value: -20 }, { label: 'Very Hard', value: -30 }, ]; const SKILL_SITUATION_MODIFIERS = [ { id: 'assistance', label: 'Assistance', value: 10 }, { id: 'good_tools', label: 'Good tools/auspex', value: 10 }, { id: 'extra_time', label: 'Extra time', value: 10 }, { id: 'poor_tools', label: 'Poor/no tools', value: -10 }, { id: 'rushed', label: 'Rushed', value: -10 }, { id: 'hostile_environment', label: 'Hostile environment', value: -10 }, { id: 'damaged_system', label: 'Damaged/unstable system', value: -10 }, { id: 'severe_interference', label: 'Severe interference', value: -20 }, ]; // ─── Shared roll feed row ──────────────────────────────────────────────────── function RollFeedRow({ roll, showDelete, onDelete }) { const payload = roll.payload || {}; const success = payload.success; return (
{roll.player_name || 'unknown'} · {roll.roll_type} · {roll.label || payload.name || 'Roll'}
{roll.created_at ? new Date(roll.created_at).toLocaleTimeString('da-DK') : ''} {showDelete && ( )}
{roll.roll_type === 'decision' && payload.declaration && ( "{payload.declaration}" )} {typeof payload.roll !== 'undefined' && ( <> Roll {payload.roll} vs {payload.target}{' '} {success ? Success {payload.dos ? `(${payload.dos} DoS)` : ''} : Fail {payload.dof ? `(${payload.dof} DoF)` : ''}} )} {typeof payload.attackRoll !== 'undefined' && ( <>Attack {payload.attackRoll} vs {payload.target} · Hits {payload.hits || 0}{payload.where ? ` · ${payload.where}` : ''} )} {typeof payload.total !== 'undefined' && roll.roll_type === 'initiative' && ( {`Initiative ${payload.total} (d10:${payload.dieRoll} + Ab:${payload.agBonus})`} )}
); } export default function MissionTab({ authedPlayer }) { const isGM = authedPlayer === 'gm'; // Core state const [missions, setMissions] = useState([]); const [activeMission, setActiveMission] = useState(null); const [selectedMissionId, setSelectedMissionId] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); // Roll tracking const [rollResults, setRollResults] = useState([]); const [rollFeed, setRollFeed] = useState([]); const [rolledChecks, setRolledChecks] = useState(new Set()); const [fateUsedChecks, setFateUsedChecks] = useState(new Set()); // #9 fate point // Combat UI state const [agBonus, setAgBonus] = useState(''); const [npcName, setNpcName] = useState(''); // #3 NPC initiative const [npcRoll, setNpcRoll] = useState(''); const [wpTarget, setWpTarget] = useState('60'); // #7 fear test const [showActionRef, setShowActionRef] = useState(false); const [declarationText, setDeclarationText] = useState(''); // #8 decision checks // Scene Secrets UI — check options expand (#6) const [checkOpenOptions, setCheckOpenOptions] = useState(null); // { name, assignedTo, isDecision } const [playerList, setPlayerList] = useState([]); // Mission completion (#11) const [showMissionReport, setShowMissionReport] = useState(false); const [outcomeNotes, setOutcomeNotes] = useState(''); // Scene editor const [editingScene, setEditingScene] = useState(false); const [sceneEdit, setSceneEdit] = useState(null); // GM manual roll entry const [gmManualRoll, setGmManualRoll] = useState(null); // { checkName, playerName, value } const [playerSheets, setPlayerSheets] = useState({}); const [miniRollerState, setMiniRollerState] = useState({}); // ─── Data loading ─────────────────────────────────────────────── const loadMissions = useCallback(async function loadMissions({ silent = false } = {}) { if (!silent) { setLoading(true); setError(''); } try { if (isGM) { const [allRes, activeRes] = await Promise.all([ axios.get('/api/missions'), axios.get('/api/missions/active/current'), ]); const all = Array.isArray(allRes.data) ? allRes.data.map(normalizeMission) : []; const active = normalizeMission(activeRes.data); setMissions(prev => JSON.stringify(prev) !== JSON.stringify(all) ? all : prev); setActiveMission(prev => JSON.stringify(prev) !== JSON.stringify(active) ? active : prev); if (!silent) setSelectedMissionId(active?.id ? String(active.id) : (all[0]?.id ? String(all[0].id) : '')); } else { const activeRes = await axios.get('/api/missions/active/player'); const active = normalizeMission(activeRes.data); setActiveMission(prev => JSON.stringify(prev) !== JSON.stringify(active) ? active : prev); } } catch (e) { if (!silent) setError(`Failed to load missions: ${e.response?.data?.error || e.message}`); } finally { if (!silent) setLoading(false); } }, [isGM]); // #1 — roll feed visible to all (no isGM guard) const loadRollFeed = useCallback(async function loadRollFeed() { try { const res = await axios.get('/api/missions/active/rolls/feed?limit=50'); const next = Array.isArray(res.data) ? res.data : []; setRollFeed(prev => JSON.stringify(prev) !== JSON.stringify(next) ? next : prev); } catch (e) { // non-blocking } }, []); useEffect(() => { loadMissions(); }, [loadMissions]); // #6 — load player names for check assignment useEffect(() => { axios.get('/api/players').then(res => { if (Array.isArray(res.data)) { const names = res.data.map(p => p.name).filter(n => n && n !== 'gm'); setPlayerList(names); Promise.all(names.map(name => axios.get(`/api/players/${name}`).then(playerRes => [name, playerRes.data]).catch(() => [name, null]) )).then(entries => { const sheets = {}; entries.forEach(([name, player]) => { if (player) sheets[name] = player; }); setPlayerSheets(sheets); }); } }).catch(() => {}); }, []); // GM polls roll feed every 5s useEffect(() => { loadRollFeed(); const timer = setInterval(loadRollFeed, 5000); return () => clearInterval(timer); }, [loadRollFeed]); // #10 — players poll mission + roll feed every 4s (halved from 8s) useEffect(() => { if (isGM) return undefined; const timer = setInterval(() => { loadMissions({ silent: true }); loadRollFeed(); }, 4000); return () => clearInterval(timer); }, [isGM, loadMissions, loadRollFeed]); // ─── Derived scene state ───────────────────────────────────────── const currentScene = useMemo(() => { if (!activeMission?.scenes?.length) return null; return activeMission.scenes[activeMission.current_scene] || activeMission.scenes[0]; }, [activeMission]); const activeSceneIndex = activeMission?.active_scene_index ?? activeMission?.current_scene ?? 0; const currentSceneIndex = activeMission?.current_scene ?? 0; useEffect(() => { setRolledChecks(new Set()); setFateUsedChecks(new Set()); setDeclarationText(''); setWpTarget('60'); }, [currentSceneIndex]); // sync outcome notes when a different mission loads (keyed on id, not scenes array) useEffect(() => { if (!activeMission?.scenes?.length) return; const lastScene = activeMission.scenes[activeMission.scenes.length - 1]; setOutcomeNotes(lastScene?.outcome || ''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeMission?.id]); const sceneChecks = useMemo(() => { if (!currentScene) return []; return listForScene(currentScene, 'checks').length ? listForScene(currentScene, 'checks') : defaultChecksForScene(currentScene); }, [currentScene]); const sceneObjectives = useMemo(() => listForScene(currentScene, 'objectives'), [currentScene]); const sceneComplications = useMemo(() => listForScene(currentScene, 'complications'), [currentScene]); const sceneExtraChallenges = useMemo(() => extraChallengesForScene(currentScene), [currentScene]); const sceneCombat = useMemo(() => combatForScene(currentScene), [currentScene]); const playPrompt = currentScene?.playPrompt || null; const combatActive = playPrompt?.type === 'combat'; // #2 — per-element reveal flags // New scenes use scene.reveal.{title,mood,description,objectives,complications} // Old scenes with revealed:true are treated as fully revealed for backwards compat const sceneReveal = useMemo(() => { const legacy = currentScene?.revealed === true; // old all-or-nothing flag const r = currentScene?.reveal || {}; return { title: r.title ?? legacy, mood: r.mood ?? legacy, description: r.description ?? legacy, objectives: r.objectives ?? legacy, complications: r.complications ?? false, // never auto-revealed checks: r.checks || {}, combat: r.combat ?? false, }; }, [currentScene]); const combatState = useMemo(() => { const cs = currentScene?.combatState || {}; return { round: cs.round ?? 1, initiatives: Array.isArray(cs.initiatives) ? cs.initiatives : [], conditions: (cs.conditions && typeof cs.conditions === 'object') ? cs.conditions : {}, fearRating: cs.fearRating ?? 0, }; }, [currentScene]); const myInitiative = combatState.initiatives.find(e => e.player === authedPlayer); // #3 — entities include NPC initiative entries const combatEntities = useMemo(() => { const fromInit = combatState.initiatives.map(e => ({ name: e.player, type: e.type === 'npc' ? 'npc' : 'player', })); const initNames = new Set(fromInit.map(e => e.name)); const enemies = sceneEnemies(currentScene) .filter(name => !initNames.has(name)) .map(name => ({ name, type: 'enemy' })); return [...fromInit, ...enemies]; }, [combatState.initiatives, currentScene]); // #11 — mission complete const missionComplete = useMemo(() => activeMission?.scenes?.length > 0 && activeMission.scenes.every(s => s.completed), [activeMission]); const earnedMissionRewards = useMemo(() => { const totals = { renown: 0, reserveReq: 0, xp: 0, completed: 0, total: 0 }; if (!activeMission?.scenes?.length) return totals; activeMission.scenes.forEach(scene => { extraChallengesForScene(scene).forEach(challenge => { totals.total += 1; if (!challenge.completed) return; totals.completed += 1; if (challenge.rewardType === 'reserve_req') totals.reserveReq += challenge.rewardValue; else if (challenge.rewardType === 'xp') totals.xp += challenge.rewardValue; else totals.renown += challenge.rewardValue; }); }); return totals; }, [activeMission]); const missionXpEstimate = useMemo(() => { const checkSuccesses = rollFeed.filter(roll => roll.roll_type === 'check' && roll.payload?.success).length; const checkXp = checkSuccesses * 50; const sceneXp = (activeMission?.scenes || []).reduce((sum, scene) => { if (!scene.completed || isCombatScene(scene)) return sum; return sum + 100; }, 0); const combatXp = (activeMission?.scenes || []).reduce((sum, scene, index) => { if (!isCombatScene(scene)) return sum; if (scene.completed) return sum + 200; const hasCombatRolls = rollFeed.some(roll => roll.scene_index === index && ['attack', 'damage', 'initiative', 'fear'].includes(roll.roll_type) ); return hasCombatRolls ? sum + 75 : sum; }, 0); return { checkXp, sceneXp, combatXp, bonusXp: earnedMissionRewards.xp, total: checkXp + sceneXp + combatXp + earnedMissionRewards.xp, checkSuccesses, }; }, [activeMission, rollFeed, earnedMissionRewards.xp]); // #1 — roll feed filtered for player view (current scene only) const playerRollFeed = useMemo(() => { if (isGM) return rollFeed; return rollFeed.filter(r => r.scene_index === activeSceneIndex); }, [isGM, rollFeed, activeSceneIndex]); // ─── Roll feed stats for mission report (#11) ──────────────────── const rollStats = useMemo(() => { const checks = rollFeed.filter(r => r.roll_type === 'check'); const successes = checks.filter(r => r.payload?.success); return { total: checks.length, successes: successes.length }; }, [rollFeed]); // ─── Combat state → localStorage sync (#4) ────────────────────── useEffect(() => { try { const state = { round: combatState.round, fearRating: combatState.fearRating, active: combatActive, conditions: combatState.conditions, }; window.localStorage.setItem(COMBAT_STATE_KEY, JSON.stringify(state)); window.dispatchEvent(new CustomEvent('dw:combat-state', { detail: state })); } catch {} }, [combatState.round, combatState.fearRating, combatActive, combatState.conditions]); // ─── Server mutations ──────────────────────────────────────────── async function recordMissionRoll(rollType, label, payload) { try { await axios.post('/api/missions/rolls', { missionId: activeMission?.id, playerName: authedPlayer, rollType, sceneIndex: activeSceneIndex, label, payload, }); await loadRollFeed(); } catch (e) { // non-blocking } } async function recordMissionRollForScene(sceneIndex, playerName, rollType, label, payload) { try { await axios.post('/api/missions/rolls', { missionId: activeMission?.id, playerName: playerName || authedPlayer, rollType, sceneIndex, label, payload, }); await loadRollFeed(); } catch (e) { // non-blocking } } async function deleteRoll(id) { try { await axios.delete(`/api/missions/rolls/${id}`); await loadRollFeed(); } catch (e) { setError(`Failed to delete roll: ${e.response?.data?.error || e.message}`); } } async function saveProgress(nextSceneIndex, nextScenes = activeMission?.scenes || []) { if (!activeMission) return; const boundedScene = Math.max(0, Math.min(nextSceneIndex, Math.max(nextScenes.length - 1, 0))); const optimistic = normalizeMission({ ...activeMission, current_scene: boundedScene, scenes: nextScenes }); setActiveMission(optimistic); setSaving(true); setError(''); try { const res = await axios.put(`/api/missions/${activeMission.id}/progress`, { currentScene: boundedScene, scenes: nextScenes, }); setActiveMission(normalizeMission(res.data?.mission)); setMessage('Mission progress saved.'); } catch (e) { setError(`Failed to save progress: ${e.response?.data?.error || e.message}`); await loadMissions(); } finally { setSaving(false); } } async function patchScene(updates) { if (!activeMission?.scenes?.length) return; const index = activeMission.current_scene; const nextScenes = activeMission.scenes.map((scene, i) => i === index ? { ...scene, ...updates } : scene ); await saveProgress(index, nextScenes); await loadMissions({ silent: true }); } async function patchSceneAt(sceneIndex, updates) { if (!activeMission?.scenes?.length) return; if (!isGM) { try { const res = await axios.put('/api/missions/active/scene', { patch: updates }); const mission = normalizeMission(res.data?.mission); if (mission) { const current = mission.scenes?.[mission.current_scene]; setActiveMission({ ...activeMission, scenes: current ? [current] : activeMission.scenes, current_scene: 0, active_scene_index: mission.current_scene, }); } await loadMissions({ silent: true }); } catch (e) { setError(`Failed to update active scene: ${e.response?.data?.error || e.message}`); } return; } const nextScenes = activeMission.scenes.map((scene, i) => i === sceneIndex ? { ...scene, ...updates } : scene ); await saveProgress(activeMission.current_scene, nextScenes); await loadMissions({ silent: true }); } async function updateCombatState(updates) { if (!activeMission?.scenes?.length) return; const index = activeMission.current_scene; const current = activeMission.scenes[index]?.combatState || { round: 1, initiatives: [], conditions: {}, fearRating: 0 }; const next = { ...current, ...updates }; await patchScene({ combatState: next }); } async function updateSceneCombatState(sceneIndex, updates) { if (!activeMission?.scenes?.length) return; const current = activeMission.scenes[sceneIndex]?.combatState || { round: 1, initiatives: [], conditions: {}, fearRating: 0 }; const next = { ...current, ...updates }; await patchSceneAt(sceneIndex, { combatState: next }); } // ─── Scene control ─────────────────────────────────────────────── async function setPlayPrompt(prompt) { if (!isGM || !activeMission?.scenes?.length) return; await patchScene({ playPrompt: prompt }); } function clearPlayPrompt() { setPlayPrompt(null); } // #6 — check opener with assignment + decision options function openCheckForPlayers(check, opts = {}) { const target = clampTarget((check.target || 45) + (check.modifier || 0)); setPlayPrompt({ type: 'check', title: check.name, check: { ...check, target }, assignedTo: opts.assignedTo || null, isDecision: opts.isDecision || false, openedAt: new Date().toISOString(), }); setCheckOpenOptions(null); } function openCombatForPlayers() { setPlayPrompt({ type: 'combat', title: `Combat: ${sceneCombat.enemy || 'Current threat'}`, combat: sceneCombat, openedAt: new Date().toISOString(), }); } // #2 — toggle individual reveal element async function toggleReveal(key) { const current = currentScene?.reveal || {}; await patchScene({ reveal: { ...current, [key]: !sceneReveal[key] } }); } async function revealAll() { await patchScene({ reveal: { title: true, mood: true, description: true, objectives: true, complications: true } }); } async function lockAll() { const currentChecks = currentScene?.reveal?.checks || {}; const lockedChecks = Object.fromEntries(Object.keys(currentChecks).map(k => [k, false])); await patchScene({ reveal: { title: false, mood: false, description: false, objectives: false, complications: false, checks: lockedChecks } }); } async function toggleRevealCheck(checkName) { const current = currentScene?.reveal || {}; const currentChecks = current.checks || {}; await patchScene({ reveal: { ...current, checks: { ...currentChecks, [checkName]: !currentChecks[checkName] } } }); } async function submitGmManualRoll() { if (!gmManualRoll) return; const { checkName, playerName, value } = gmManualRoll; const check = sceneChecks.find(c => c.name === checkName); if (!check || !playerName || !value) return; const target = clampTarget((check.target || 45) + (check.modifier || 0)); const roll = parseInt(value); if (isNaN(roll) || roll < 1 || roll > 100) return; const result = degrees(target, roll); try { await axios.post('/api/missions/rolls', { missionId: activeMission?.id, playerName, rollType: 'check', sceneIndex: activeSceneIndex, label: checkName, payload: { name: checkName, skill: check.skill, roll, target, ...result }, }); await loadRollFeed(); setGmManualRoll(null); } catch (e) { /* non-blocking */ } } async function toggleSceneRollsUnlocked() { await patchScene({ rollsUnlocked: !currentScene?.rollsUnlocked }); } // ─── Combat state helpers ──────────────────────────────────────── async function rollInitiative() { const ab = parseInt(agBonus) || 0; const dieRoll = Math.floor(Math.random() * 10) + 1; const total = dieRoll + ab; const entry = { player: authedPlayer, agBonus: ab, dieRoll, total, type: 'player', ts: Date.now() }; const existing = Array.isArray(activeMission.scenes[activeMission.current_scene]?.combatState?.initiatives) ? activeMission.scenes[activeMission.current_scene].combatState.initiatives : []; const idx = existing.findIndex(e => e.player === authedPlayer); const next = idx >= 0 ? existing.map((e, i) => (i === idx ? entry : e)) : [...existing, entry]; await updateCombatState({ initiatives: next }); await recordMissionRoll('initiative', 'Initiative', { agBonus: ab, dieRoll, total }); } // #3 — add NPC to initiative async function addNpcInitiative() { const name = npcName.trim(); if (!name) return; const roll = parseInt(npcRoll) || (Math.floor(Math.random() * 10) + 1); const entry = { player: name, agBonus: 0, dieRoll: roll, total: roll, type: 'npc', ts: Date.now() }; const existing = Array.isArray(activeMission.scenes[activeMission.current_scene]?.combatState?.initiatives) ? activeMission.scenes[activeMission.current_scene].combatState.initiatives : []; await updateCombatState({ initiatives: [...existing, entry] }); setNpcName(''); setNpcRoll(''); } async function toggleCondition(name, condition) { const current = activeMission.scenes[activeMission.current_scene]?.combatState || {}; const existing = (current.conditions || {})[name] || []; const next = existing.includes(condition) ? existing.filter(c => c !== condition) : [...existing, condition]; await updateCombatState({ conditions: { ...(current.conditions || {}), [name]: next } }); } async function shiftRound(delta) { const current = activeMission.scenes[activeMission.current_scene]?.combatState || {}; await updateCombatState({ round: Math.max(1, (current.round ?? 1) + delta) }); } async function setFearRating(rating) { await updateCombatState({ fearRating: rating }); } async function clearCombatState() { await updateCombatState({ round: 1, initiatives: [], conditions: {}, fearRating: 0 }); } // ─── Roll actions ──────────────────────────────────────────────── async function rollSceneCheck(check) { const target = clampTarget((check.target || 45) + (check.modifier || 0)); const roll = d100(); const result = degrees(target, roll); const entry = { id: `${Date.now()}-${Math.random()}`, name: check.name, skill: check.skill, target, roll, rolledBy: authedPlayer, ...result, }; setRolledChecks(prev => new Set([...prev, check.name])); setRollResults(prev => [entry, ...prev].slice(0, 5)); await recordMissionRoll('check', check.name, { sceneTitle: titleForScene(currentScene, activeSceneIndex), check, target, roll, ...result, }); } function miniState(sceneIndex, scene) { const checks = listForScene(scene, 'checks').length ? listForScene(scene, 'checks') : defaultChecksForScene(scene); const enemies = sceneEnemyObjects(scene); const current = miniRollerState[sceneIndex] || {}; const defaultPlayer = isGM ? (playerList[0] || '') : authedPlayer; return { player: current.player || defaultPlayer, checkName: current.checkName || checks[0]?.name || '', skillDifficultyModifier: current.skillDifficultyModifier ?? 0, skillSituationModifiers: Array.isArray(current.skillSituationModifiers) ? current.skillSituationModifiers : [], skillCustomModifier: current.skillCustomModifier ?? 0, enemyName: current.enemyName || enemies[0]?.name || '', attackSkill: current.attackSkill || 'BS', modifier: current.modifier ?? 0, initPlayer: current.initPlayer || defaultPlayer, initAgBonus: current.initAgBonus || '', }; } function setMiniState(sceneIndex, updates) { setMiniRollerState(prev => ({ ...prev, [sceneIndex]: { ...(prev[sceneIndex] || {}), ...updates }, })); } function playerSheet(name) { return playerSheets[name]?.tabInfo || {}; } function selectedSkillSituationModifiers(ids = []) { const selected = new Set(ids || []); return SKILL_SITUATION_MODIFIERS.filter(mod => selected.has(mod.id)); } function targetBreakdownForPlayerCheck(playerName, check, skillModifiers = {}) { const tab = playerSheet(playerName); const chars = tab.characteristics || {}; const skill = matchingSheetSkill(tab.skills || {}, check); const characteristic = check.characteristic || 'Int'; const checkModifier = Number(check.modifier || 0); const difficultyModifier = Number(skillModifiers.difficulty || 0); const situationMods = selectedSkillSituationModifiers(skillModifiers.situations || []); const situationTotal = situationMods.reduce((sum, item) => sum + Number(item.value || 0), 0); const customModifier = Number(skillModifiers.custom || 0); const training = Object.keys(chars).length ? skillTrainingBonus(skill) : 0; const base = Object.keys(chars).length ? characteristicValue(chars, characteristic, check.target || 45) : (check.target || 45); const target = clampTarget(base + training + checkModifier + difficultyModifier + situationTotal + customModifier); return { target, base, characteristic, training, trainingLabel: Object.keys(chars).length ? skillTrainingLabel(skill) : 'sheet missing', checkModifier, difficultyModifier, situationMods, situationTotal, customModifier, }; } function hasMiniSkillRolled(sceneIndex, scene, playerName, check) { if (!playerName || !check?.name) return false; const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; const locked = Boolean(scene?.rollLocks?.skills?.[check.name]?.[playerName]); const recorded = rollFeed.some(roll => roll.scene_index === feedSceneIndex && roll.roll_type === 'check' && roll.label === check.name && roll.player_name === playerName && roll.payload?.mini ); return locked || recorded; } async function rollMiniSceneCheck(sceneIndex, scene) { const state = miniState(sceneIndex, scene); const checks = listForScene(scene, 'checks').length ? listForScene(scene, 'checks') : defaultChecksForScene(scene); const check = checks.find(c => c.name === state.checkName) || checks[0]; if (!check || !state.player) return; if (!scene?.rollsUnlocked && hasMiniSkillRolled(sceneIndex, scene, state.player, check)) return; const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; const targetBreakdown = targetBreakdownForPlayerCheck(state.player, check, { difficulty: state.skillDifficultyModifier, situations: state.skillSituationModifiers, custom: state.skillCustomModifier, }); const target = targetBreakdown.target; const roll = d100(); const result = degrees(target, roll); const rollLocks = { ...(scene.rollLocks || {}), skills: { ...(scene.rollLocks?.skills || {}), [check.name]: { ...(scene.rollLocks?.skills?.[check.name] || {}), [state.player]: true, }, }, }; await patchSceneAt(sceneIndex, { rollLocks }); await recordMissionRollForScene(feedSceneIndex, state.player, 'check', check.name, { sceneTitle: titleForScene(scene, sceneIndex), mini: true, check, targetBreakdown, target, roll, ...result, }); } async function rollMiniPlayerAttack(sceneIndex, scene) { const state = miniState(sceneIndex, scene); const tab = playerSheet(state.player); const chars = tab.characteristics || {}; const enemies = sceneEnemyObjects(scene); const enemy = enemies.find(e => e.name === state.enemyName) || enemies[0] || {}; if (!state.player) return; const currentWounds = Math.max(0, Number(enemy.wounds ?? enemy.woundsTotal ?? enemy.w ?? 0) || 0); if (enemy.dead || enemy.defeated || currentWounds <= 0) return; const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; const attackSkill = state.attackSkill === 'WS' ? 'WS' : 'BS'; const target = clampTarget(characteristicValue(chars, attackSkill, 45) + (Number(state.modifier) || 0)); const roll = d100(); const result = degrees(target, roll); const weapon = firstWeaponDamage(tab, attackSkill); const damageRoll = result.success ? rollDamageExpression(weapon.damage) : null; const targetTB = Number(enemy.tb ?? combatForScene(scene).targetTB ?? 4) || 0; const targetArmour = Number(enemy.armour ?? enemy.ap ?? combatForScene(scene).targetArmour ?? 0) || 0; const effectiveArmour = Math.max(0, targetArmour - weapon.pen); const appliedDamage = damageRoll ? Math.max(0, damageRoll.total - targetTB - effectiveArmour) : 0; const remainingWounds = Math.max(0, currentWounds - appliedDamage); const defeated = result.success && remainingWounds <= 0; if (result.success) { const originalEnemies = Array.isArray(scene.enemies) ? scene.enemies : []; const nextEnemies = originalEnemies.map(original => { const originalName = typeof original === 'string' ? original : (original.name || original.type); if (originalName !== enemy.name) return original; const nextEnemy = typeof original === 'string' ? { name: original } : { ...original }; nextEnemy.wounds = remainingWounds; nextEnemy.currentWounds = remainingWounds; if (defeated) { nextEnemy.dead = true; nextEnemy.defeated = true; nextEnemy.status = 'dead'; } return nextEnemy; }); await patchSceneAt(sceneIndex, { enemies: nextEnemies }); } await recordMissionRollForScene(feedSceneIndex, state.player, 'attack', `${state.player} vs ${enemy.name || 'target'}`, { sceneTitle: titleForScene(scene, sceneIndex), mini: true, attacker: state.player, enemy: enemy.name || 'target', weapon: weapon.name, using: attackSkill, attackRoll: roll, target, success: result.success, dos: result.dos, dof: result.dof, hits: result.success ? 1 : 0, damage: weapon.damage, damageRoll: damageRoll ? damageRoll.rolls : [], rawDamage: damageRoll ? damageRoll.total : 0, appliedDamage, targetTB, targetArmour, effectiveArmour, woundsBefore: currentWounds, woundsAfter: remainingWounds, defeated, }); } async function addMiniInitiative(sceneIndex, scene) { const state = miniState(sceneIndex, scene); const name = state.initPlayer || state.player || authedPlayer; if (!name) return; const tab = playerSheet(name); const ag = characteristicValue(tab.characteristics || {}, 'Ag', 40); const ab = state.initAgBonus === '' ? Math.floor(ag / 10) : (parseInt(state.initAgBonus, 10) || 0); const dieRoll = Math.floor(Math.random() * 10) + 1; const total = dieRoll + ab; const entry = { player: name, agBonus: ab, dieRoll, total, type: playerSheets[name] ? 'player' : 'npc', ts: Date.now() }; const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; const current = scene?.combatState || {}; const existing = Array.isArray(current.initiatives) ? current.initiatives : []; const next = existing.filter(e => e.player !== name).concat(entry); await updateSceneCombatState(sceneIndex, { initiatives: next }); await recordMissionRollForScene(feedSceneIndex, name, 'initiative', 'Initiative', { mini: true, agBonus: ab, dieRoll, total }); } // #7 — fear test (Finding #2: penalty auto-applied) const fearPenaltyValue = FEAR_RATINGS[combatState.fearRating]?.penaltyValue ?? 0; const effectiveWP = Math.max(1, (parseInt(wpTarget) || 60) + fearPenaltyValue); async function rollFearTest() { const target = clampTarget(effectiveWP); const roll = d100(); const result = degrees(target, roll); const entry = { id: `${Date.now()}-${Math.random()}`, name: 'Fear Test', skill: 'Willpower', target, roll, rolledBy: authedPlayer, ...result, }; setRolledChecks(prev => new Set([...prev, '__fear__'])); setRollResults(prev => [entry, ...prev].slice(0, 5)); await recordMissionRoll('fear', 'Fear Test', { target, roll, ...result }); } // #8 — decision declare async function rollDecision() { const text = declarationText.trim(); if (!text) return; setRolledChecks(prev => new Set([...prev, playPrompt?.check?.name || '__decision__'])); await recordMissionRoll('decision', playPrompt?.title || 'Decision', { declaration: text }); setDeclarationText(''); } // #9 — fate point re-roll function spendFate(checkName) { setFateUsedChecks(prev => new Set([...prev, checkName])); setRolledChecks(prev => { const next = new Set(prev); next.delete(checkName); return next; }); } function openSceneInRoller() { const context = { sceneTitle: titleForScene(currentScene, activeSceneIndex), enemy: sceneCombat.enemy || 'Custom/None', weapon: sceneCombat.weapon || '', bs: Number(sceneCombat.bs || 45), ws: Number(sceneCombat.ws || 45), modifier: Number(sceneCombat.modifier || 0), damage: sceneCombat.damage || '1d10+5', targetTB: Number(sceneCombat.targetTB || 4), targetArmour: Number(sceneCombat.targetArmour || 5), mode: sceneCombat.mode || 'single', rof: Number(sceneCombat.rof || 1), tearing: Boolean(sceneCombat.tearing), pen: Number(sceneCombat.pen || 0), reliable: Boolean(sceneCombat.reliable), }; window.localStorage.setItem(MISSION_ROLL_CONTEXT_KEY, JSON.stringify(context)); window.dispatchEvent(new CustomEvent('dw:mission-roll-context')); window.dispatchEvent(new CustomEvent('dw:open-roller')); } function renderMiniSceneRoller(scene, sceneIndex, opts = {}) { if (!scene) return null; const checks = listForScene(scene, 'checks').length ? listForScene(scene, 'checks') : defaultChecksForScene(scene); const enemies = sceneEnemyObjects(scene); const state = miniState(sceneIndex, scene); const combatStateForScene = scene.combatState || {}; const initiatives = Array.isArray(combatStateForScene.initiatives) ? combatStateForScene.initiatives : []; const selectedCheck = checks.find(check => check.name === state.checkName) || checks[0]; const selectedPlayer = state.player || (isGM ? playerList[0] : authedPlayer); const skillBreakdown = selectedCheck && selectedPlayer ? targetBreakdownForPlayerCheck(selectedPlayer, selectedCheck, { difficulty: state.skillDifficultyModifier, situations: state.skillSituationModifiers, custom: state.skillCustomModifier, }) : null; const skillTarget = skillBreakdown?.target ?? null; const skillAlreadyRolled = Boolean(selectedCheck && selectedPlayer && hasMiniSkillRolled(sceneIndex, scene, selectedPlayer, selectedCheck)); const skillLocked = skillAlreadyRolled && !scene?.rollsUnlocked; const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; const selectedEnemy = enemies.find(enemy => enemy.name === state.enemyName) || enemies[0] || null; const targetDead = Boolean(selectedEnemy?.dead || selectedEnemy?.defeated || Number(selectedEnemy?.wounds ?? selectedEnemy?.woundsTotal ?? selectedEnemy?.w ?? 1) <= 0); const loggedInWounds = woundsLabel(playerSheet(authedPlayer).wounds); const selectedPlayerWounds = woundsLabel(playerSheet(selectedPlayer).wounds); const targetWounds = selectedEnemy ? woundsLabel(selectedEnemy.wounds ?? selectedEnemy.woundsTotal ?? selectedEnemy.w) : 'W ?'; const latestSkillRoll = rollFeed.find(roll => roll.scene_index === feedSceneIndex && roll.roll_type === 'check' && roll.payload?.mini && (!selectedCheck || roll.label === selectedCheck.name) ); const latestCombatRoll = rollFeed.find(roll => roll.scene_index === feedSceneIndex && roll.roll_type === 'attack' && roll.payload?.mini && (!selectedEnemy || roll.payload?.enemy === selectedEnemy.name) ); return (

Mini Roller

Scene skill, combat and initiative. You: {authedPlayer} · {loggedInWounds}

{initiatives.length} initiative{initiatives.length === 1 ? '' : 's'} {isGM && ( )}
Skill
{SKILL_SITUATION_MODIFIERS.map(mod => { const active = state.skillSituationModifiers.includes(mod.id); return ( ); })}
{selectedCheck && (
{selectedCheck.skill || 'Skill'} {' '}({selectedCheck.characteristic || 'Characteristic'}) {selectedCheck.modifier ? ` · ${selectedCheck.modifier > 0 ? '+' : ''}${selectedCheck.modifier}` : ''}
{skillBreakdown && (
Target Number {skillBreakdown.target}
Base {skillBreakdown.characteristic}: {skillBreakdown.base}
Training: {skillBreakdown.trainingLabel} ({skillBreakdown.training >= 0 ? '+' : ''}{skillBreakdown.training})
Scene modifier: {skillBreakdown.checkModifier >= 0 ? '+' : ''}{skillBreakdown.checkModifier}
Difficulty: {skillBreakdown.difficultyModifier >= 0 ? '+' : ''}{skillBreakdown.difficultyModifier}
Skill conditions: {skillBreakdown.situationTotal >= 0 ? '+' : ''}{skillBreakdown.situationTotal}
Custom: {skillBreakdown.customModifier >= 0 ? '+' : ''}{skillBreakdown.customModifier}
{skillBreakdown.situationMods.length > 0 ? (
{skillBreakdown.situationMods.map(mod => ( {mod.label} {mod.value ? `${mod.value > 0 ? '+' : ''}${mod.value}` : '+0'} ))}
) : (
No active skill condition modifiers.
)}
)} {selectedCheck.rulesNote && (
{selectedCheck.rulesNote}
)} {selectedCheck.reward && (
Success: {selectedCheck.reward}
)}
)} {skillAlreadyRolled && (
{scene?.rollsUnlocked ? 'GM unlocked extra rolls for this scene.' : 'Locked: 1 skill roll per player for this check.'}
)} {latestSkillRoll ? (
{rollSummary(latestSkillRoll)}
) : (
No skill roll yet.
)}
Combat
Player Wounds: {selectedPlayerWounds}
Target Wounds: {targetWounds}
setMiniState(sceneIndex, { modifier: parseInt(e.target.value || '0', 10) })} className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm" title="Attack modifier" />
{latestCombatRoll ? (
{rollSummary(latestCombatRoll)}
) : (
No combat roll yet.
)}
Initiative
setMiniState(sceneIndex, { initAgBonus: e.target.value })} className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm" />
{initiatives.length > 0 && (
{[...initiatives].sort((a, b) => b.total - a.total).map((entry, i) => (
{i + 1} {entry.player} {entry.total} d10:{entry.dieRoll} + Ab:{entry.agBonus} {isGM && ( )}
))}
)}
); } async function setMissionActive(id = selectedMissionId) { if (!id) return; setSaving(true); setError(''); try { const res = await axios.post(`/api/missions/${id}/active`); const mission = normalizeMission(res.data?.mission); setActiveMission(mission); setSelectedMissionId(String(id)); setMessage('Mission set for the table.'); // Finding #1: initialise revealed:false on all scenes that haven't been set yet, // so the GM must explicitly reveal each scene before players see it. if (mission?.scenes?.some(s => s.revealed === undefined)) { const initScenes = mission.scenes.map(s => ({ ...s, revealed: s.revealed !== undefined ? s.revealed : false, })); await axios.put(`/api/missions/${mission.id}/progress`, { currentScene: mission.current_scene, scenes: initScenes, }); } await loadMissions(); } catch (e) { setError(`Failed to set mission: ${e.response?.data?.error || e.message}`); } finally { setSaving(false); } } function setCurrentScene(index) { saveProgress(index); } function toggleSceneComplete(index) { if (!activeMission?.scenes) return; const nextScenes = activeMission.scenes.map((scene, i) => i === index ? { ...scene, completed: !scene.completed } : scene ); saveProgress(activeMission.current_scene, nextScenes); } async function toggleExtraChallenge(challengeIndex) { if (!isGM || !activeMission?.scenes?.length || !currentScene) return; const nextChallenges = sceneExtraChallenges.map((challenge, index) => index === challengeIndex ? { ...challenge, completed: !challenge.completed } : challenge ); await patchScene({ extraChallenges: nextChallenges }); } // #11 — save outcome notes to last scene async function saveOutcomeNotes() { if (!isGM || !activeMission?.scenes?.length) return; const lastIdx = activeMission.scenes.length - 1; const nextScenes = activeMission.scenes.map((scene, i) => i === lastIdx ? { ...scene, outcome: outcomeNotes } : scene ); await saveProgress(activeMission.current_scene, nextScenes); } // ─── Scene editor helpers ──────────────────────────────────────── function openSceneEdit() { if (!currentScene) return; setSceneEdit({ title: currentScene.title || currentScene.name || '', type: currentScene.type || 'combat', mood: currentScene.mood || '', description: currentScene.description || currentScene.briefing || currentScene.summary || '', endingText: endingTextForScene(currentScene), checks: (Array.isArray(currentScene.checks) ? currentScene.checks : []).map(c => ({ ...c })), enemies: (Array.isArray(currentScene.enemies) ? currentScene.enemies : []).map(e => ({ ...e })), objectives: (Array.isArray(currentScene.objectives) ? currentScene.objectives : []).join('\n'), complications: (Array.isArray(currentScene.complications) ? currentScene.complications : []).join('\n'), }); setEditingScene(true); } async function saveSceneEdit() { if (!sceneEdit) return; const toArr = (str) => str.split('\n').map(s => s.trim()).filter(Boolean); await patchScene({ title: sceneEdit.title.trim() || undefined, name: sceneEdit.title.trim() || undefined, type: sceneEdit.type, mood: sceneEdit.mood, description: sceneEdit.description, briefing: sceneEdit.description, endingText: sceneEdit.endingText, checks: sceneEdit.checks, enemies: sceneEdit.enemies, objectives: toArr(sceneEdit.objectives), complications: toArr(sceneEdit.complications), }); setEditingScene(false); setSceneEdit(null); } function seField(field, value) { setSceneEdit(prev => ({ ...prev, [field]: value })); } function seCheck(i, field, value) { setSceneEdit(prev => { const checks = prev.checks.map((c, idx) => idx === i ? { ...c, [field]: value } : c); return { ...prev, checks }; }); } function addCheck() { setSceneEdit(prev => ({ ...prev, checks: [...prev.checks, { name: 'New Check', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: '' }], })); } function removeCheck(i) { setSceneEdit(prev => ({ ...prev, checks: prev.checks.filter((_, idx) => idx !== i) })); } function seEnemy(i, field, value) { setSceneEdit(prev => { const enemies = prev.enemies.map((e, idx) => idx === i ? { ...e, [field]: value } : e); return { ...prev, enemies }; }); } function addEnemy() { setSceneEdit(prev => ({ ...prev, enemies: [...prev.enemies, { name: 'Enemy', wounds: 10, bs: 45, ws: 45, damage: '1d10+4', ap: 0 }], })); } function removeEnemy(i) { setSceneEdit(prev => ({ ...prev, enemies: prev.enemies.filter((_, idx) => idx !== i) })); } // ─── Render ────────────────────────────────────────────────────── if (!authedPlayer) { return (

Mission

Log in to see the active Deathwatch mission.

); } return (
{/* Header */}

Deathwatch Mission Table

Active Mission

{/* Next game date banner */}
⚔️
Næste spildag

Lørdag 28. juni 2026 — Deathwatch session

{error &&
{error}
} {message &&
{message}
} {/* #11 — Mission complete banner */} {missionComplete && (

Mission Complete

{activeMission.name} — all {activeMission.scenes.length} scenes concluded.

{showMissionReport && (
{activeMission.scenes.length}
Scenes
{rollStats.total}
Checks Rolled
{rollStats.total > 0 ? Math.round((rollStats.successes / rollStats.total) * 100) : 0}%
Success Rate
{missionXpEstimate.total}
Estimated XP
XP breakdown: {missionXpEstimate.checkSuccesses} successful checks x 50 = {missionXpEstimate.checkXp}; scene bonuses = {missionXpEstimate.sceneXp}; combat = {missionXpEstimate.combatXp}; extra challenge XP = {missionXpEstimate.bonusXp}.
{(isGM || activeMission.scenes[activeMission.scenes.length - 1]?.outcome) && (
Outcome Notes
{isGM ? (