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 && (
onDelete(roll.id)} className="rounded bg-slate-800 px-2 py-1 text-xs text-slate-300 hover:bg-slate-700">
Delete
)}
{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 && (
patchSceneAt(sceneIndex, { rollsUnlocked: !scene?.rollsUnlocked })}
className={`rounded px-2 py-1 text-xs ${scene?.rollsUnlocked ? 'bg-amber-700 text-white hover:bg-amber-600' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'}`}
>
{scene?.rollsUnlocked ? 'Extra Rolls On' : 'Unlock Rolls'}
)}
Skill
setMiniState(sceneIndex, { player: e.target.value, initPlayer: e.target.value })}
disabled={!isGM}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{(isGM ? playerList : [authedPlayer]).map(name => {name} )}
setMiniState(sceneIndex, { checkName: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{checks.map(check => {check.name} )}
{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}
)}
)}
rollMiniSceneCheck(sceneIndex, scene)}
disabled={!selectedCheck || !state.player || skillLocked}
title={skillLocked ? 'This player already rolled this skill check. GM can unlock more rolls.' : undefined}
className="rounded bg-emerald-700 px-3 py-2 text-sm hover:bg-emerald-600 disabled:opacity-40"
>
{skillLocked ? 'Already Rolled' : `Roll Skill ${skillTarget ? `(target ${skillTarget})` : ''}`}
{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
setMiniState(sceneIndex, { enemyName: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{enemies.length === 0 && No scene enemies }
{enemies.map(enemy => {
const dead = enemy.dead || enemy.defeated || Number(enemy.wounds ?? enemy.woundsTotal ?? enemy.w ?? 1) <= 0;
return {enemy.name} ({woundsLabel(enemy.wounds ?? enemy.woundsTotal ?? enemy.w)}){dead ? ' DEAD' : ''} ;
})}
Player Wounds: {selectedPlayerWounds}
Target Wounds: {targetWounds}
setMiniState(sceneIndex, { attackSkill: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
Ranged BS
Melee WS
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"
/>
rollMiniPlayerAttack(sceneIndex, scene)}
disabled={!state.player || enemies.length === 0 || targetDead}
className="rounded bg-red-800 px-3 py-2 text-sm hover:bg-red-700 disabled:opacity-40"
>
{targetDead ? 'Target Dead' : 'Roll Attack'}
{latestCombatRoll ? (
{rollSummary(latestCombatRoll)}
) : (
No combat roll yet.
)}
{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 && (
updateSceneCombatState(sceneIndex, { initiatives: initiatives.filter(e => !(e.player === entry.player && e.ts === entry.ts)) })}
className="rounded bg-slate-800 px-1.5 py-0.5 text-slate-400 hover:bg-slate-700"
>
x
)}
))}
)}
);
}
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
Refresh
{/* 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.
setShowMissionReport(v => !v)} className="rounded bg-emerald-800 px-3 py-2 text-sm hover:bg-emerald-700">
{showMissionReport ? 'Hide Report' : 'View Report'}
{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 ? (
) : (
{activeMission.scenes[activeMission.scenes.length - 1]?.outcome || ''}
)}
)}
)}
)}
{/* GM mission selector */}
{isGM && (
GM Mission Control
Choose a story from the database and make it the mission everyone plays from this tab.
{missions.length} saved
setSelectedMissionId(e.target.value)}
>
Select mission from database
{missions.map(mission => (
{mission.name} ({missionTheme(mission)}, {mission.threat_level || 'Medium'})
))}
setMissionActive()}
disabled={!selectedMissionId || saving}
className="rounded bg-blue-600 px-4 py-2 font-medium hover:bg-blue-500 disabled:opacity-50"
>
Set Active Mission
)}
{loading ? (
Loading mission data...
) : !activeMission ? (
No Active Mission
{isGM ? 'Select a saved mission above to begin play.' : 'The GM has not selected a mission yet.'}
) : (
<>
{/* ── Left: scene content ── */}
{activeMission.name}
{isGM && (
{missionTheme(activeMission)} threat · {activeMission.threat_level || 'Medium'} · {activeMission.player_count || '?'} battle-brothers
)}
{isGM && (
Scene {activeMission.current_scene + 1} / {activeMission.scenes.length || 1}
)}
{currentScene ? (
{/* ── Scene briefing ── */}
{isGM ? 'Current Scene' : 'Mission Briefing'}
{/* Title gated for players */}
{isGM || sceneReveal.title ? (
{titleForScene(currentScene, activeMission.current_scene)}
) : (
Scene {activeMission.current_scene + 1}
)}
{isGM && (
{currentScene.type || 'Scene'}
)}
{/* Per-element reveal gates for players */}
{isGM ? (
/* GM always sees everything */
{currentScene.mood && (
)}
{textForScene(currentScene) || 'No scene text stored.'}
{sceneObjectives.length > 0 && (
Objectives
{sceneObjectives.map((obj, i) => (
{obj}
))}
)}
{sceneComplications.length > 0 && (
Pressure
{sceneComplications.map((comp, i) => (
{comp}
))}
)}
) : (
/* Players see only what GM has unlocked */
(() => {
const anyVisible = sceneReveal.mood || sceneReveal.description || sceneReveal.objectives || sceneReveal.complications;
if (!anyVisible) {
return (
🔒
Awaiting GM scene briefing. Stand by, Kampbroder.
);
}
return (
{sceneReveal.mood && currentScene.mood && (
)}
{sceneReveal.description && (
{textForScene(currentScene)}
)}
{sceneReveal.objectives && sceneObjectives.length > 0 && (
Objectives
{sceneObjectives.map((obj, i) => (
{obj}
))}
)}
{sceneReveal.complications && sceneComplications.length > 0 && (
Pressure
{sceneComplications.map((comp, i) => (
{comp}
))}
)}
);
})()
)}
{isGM && currentScene.completed && (
Scene completed
{endingTextForScene(currentScene) && (
{endingTextForScene(currentScene)}
)}
)}
{(isGM || sceneReveal.checks || sceneReveal.combat || currentScene?.rollsUnlocked) && (
renderMiniSceneRoller(currentScene, activeMission.current_scene)
)}
{sceneExtraChallenges.length > 0 && (isGM || sceneReveal.complications || sceneExtraChallenges.some(c => c.completed)) && (
Extra Challenges
Earned here becomes mission reward. Heroic work grants Renown; Reserve Req is only a mission resource.
{sceneExtraChallenges.filter(c => c.completed).length} / {sceneExtraChallenges.length} solved
{sceneExtraChallenges.map((challenge, index) => (
{challenge.text}
Reward: {rewardLabel(challenge)}
{challenge.completed ? 'Earned' : 'Available'}
{isGM && (
toggleExtraChallenge(index)}
className={`rounded px-2 py-1 text-xs ${
challenge.completed
? 'bg-slate-700 text-slate-200 hover:bg-slate-600'
: 'bg-amber-700 text-white hover:bg-amber-600'
}`}
>
{challenge.completed ? 'Undo' : 'Mark Solved'}
)}
))}
)}
{/* ── Checks + Combat grid ── */}
{/* Scene Checks */}
Scene Checks
{/* Player: active check banner */}
{!isGM && playPrompt?.type === 'check' && playPrompt.check && (() => {
const check = playPrompt.check;
const assignedTo = playPrompt.assignedTo;
const isDecision = playPrompt.isDecision;
const isAssignedToMe = !assignedTo || assignedTo === authedPlayer;
const hasRolled = rolledChecks.has(check.name);
const canFate = hasRolled && !fateUsedChecks.has(check.name) && !isDecision;
return (
{playPrompt.title}
{assignedTo && (
Assigned to {assignedTo}
)}
{isDecision ? 'Declare your intent for this moment.' : 'Roll this check when your Battle-Brother acts.'}
{isAssignedToMe && (
isDecision ? (
) : (
rollSceneCheck(check)}
disabled={hasRolled && !currentScene?.rollsUnlocked}
title={hasRolled && !currentScene?.rollsUnlocked ? 'Already rolled this scene' : undefined}
className="rounded bg-emerald-700 px-3 py-2 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{hasRolled ? 'Already Rolled' : 'Roll Check'}
{/* #9 — fate point re-roll */}
{canFate && (
spendFate(check.name)}
className="rounded border border-amber-600/50 bg-amber-950/30 px-3 py-2 text-xs text-amber-300 hover:bg-amber-900/30"
title="Spend a Fate point to re-roll this check"
>
Spend Fate ✦
)}
)
)}
{!isAssignedToMe && (
Not assigned to you.
)}
{/* #5 — reward only shown after rolling */}
{hasRolled && check.reward && (
Reward: {check.reward}
)}
);
})()}
{/* Player: visible scene checks (only GM-revealed ones) */}
{!isGM && (() => {
const revealedChecks = sceneChecks.filter(c => sceneReveal.checks[c.name]);
if (revealedChecks.length === 0) {
return (
📋
No check open yet. Awaiting GM orders.
);
}
return (
{revealedChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
{check.name}
{check.skill || 'Skill'} ({check.characteristic || 'Characteristic'}) target {target}
{check.modifier ? ` (${check.modifier > 0 ? '+' : ''}${check.modifier})` : ''}
{check.rulesNote && (
{check.rulesNote}
)}
{check.reward && (
Success: {check.reward}
)}
Open in Dice Roller
);
})}
);
})()}
{/* GM: full check list */}
{isGM && (
{sceneChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
{check.name}
{check.skill || 'Skill'} ({check.characteristic || 'Characteristic'}) target {target}
{check.modifier ? ` (${check.modifier > 0 ? '+' : ''}${check.modifier})` : ''}
rollSceneCheck(check)}
className="rounded bg-blue-600 px-3 py-2 text-sm hover:bg-blue-500"
>
Roll Check
{check.reward &&
{check.reward}
}
);
})}
)}
{/* Recent rolls (all players) */}
{rollResults.length > 0 && (
Recent Scene Rolls
{rollResults.map(result => (
{result.rolledBy || 'Unknown'}
Roll {result.roll} vs {result.target} ·{' '}
{result.success
? Success, {result.dos} DoS
: Fail, {result.dof} DoF }
{result.name} · {result.skill}
))}
)}
{/* Combat Setup */}
Combat Setup
{!isGM && !sceneReveal.combat ? (
⚔️
Combat briefing er klassificeret. Afventer GM-ordre.
) : (
<>
Enemy
{sceneCombat.enemy || 'Custom/None'}
Attack target
BS {sceneCombat.bs || 45} / WS {sceneCombat.ws || 45}
Modifier
{Number(sceneCombat.modifier || 0) >= 0 ? '+' : ''}{sceneCombat.modifier || 0}
Damage
{sceneCombat.damage || '1d10+5'}
Target soak
TB {sceneCombat.targetTB || 4} / AR {sceneCombat.targetArmour || 5}
Open in Dice Roller
>
)}
{isGM && (
Open Combat
Clear Open
)}
{/* ── Combat state panels (initiative, conditions, action ref, fear) ── */}
{(combatActive || (!isGM && sceneReveal.combat)) && (
{/* Round counter + fear rating */}
Round
{isGM && shiftRound(-1)} disabled={combatState.round <= 1} className="rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600 disabled:opacity-40">− }
{combatState.round}
{isGM && shiftRound(1)} className="rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600">+ }
{isGM ? (
Fear:
setFearRating(Number(e.target.value))}
className="rounded border border-slate-600 bg-slate-900 px-2 py-1 text-sm"
>
{FEAR_RATINGS.map(f => {f.label} )}
) : combatState.fearRating > 0 ? (
{FEAR_RATINGS[combatState.fearRating]?.label}
{FEAR_RATINGS[combatState.fearRating]?.penalty && (
{FEAR_RATINGS[combatState.fearRating].penalty}
)}
) : null}
{isGM && (
Reset Combat
)}
{/* #7 — Fear test quick-roll (Finding #2: penalty auto-applied) */}
{combatState.fearRating > 0 && (
Fear Test Required
Willpower{fearPenaltyValue !== 0 && (
({fearPenaltyValue} penalty → effective {effectiveWP})
)}
setWpTarget(e.target.value)}
className="w-20 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
/>
{rolledChecks.has('__fear__') ? 'Fear Test Rolled' : `Roll Fear Test (WP ${effectiveWP})`}
{rolledChecks.has('__fear__') && !fateUsedChecks.has('__fear__') && (
spendFate('__fear__')}
className="rounded border border-amber-600/50 bg-amber-950/30 px-3 py-2 text-xs text-amber-300 hover:bg-amber-900/30"
title="Spend a Fate point to re-roll"
>
Spend Fate ✦
)}
)}
{/* Initiative tracker */}
Initiative Order
{/* Player roll form */}
{!myInitiative && (
)}
{myInitiative && !isGM && (
Your initiative: {myInitiative.total} (d10:{myInitiative.dieRoll} + Ab:{myInitiative.agBonus})
{ setAgBonus(String(myInitiative.agBonus)); updateCombatState({ initiatives: combatState.initiatives.filter(e => e.player !== authedPlayer) }); }}
className="ml-3 text-xs text-slate-500 hover:text-slate-300"
>
Re-roll
)}
{/* #3 — GM NPC form */}
{isGM && (
)}
{/* Initiative list */}
{combatState.initiatives.length === 0 ? (
No initiatives submitted yet.
) : (
{[...combatState.initiatives].sort((a, b) => b.total - a.total).map((entry, i) => (
{i + 1}
{entry.type === 'npc' && NPC }
{entry.player}
{entry.total}
{entry.type !== 'npc' && d10:{entry.dieRoll} + Ab:{entry.agBonus} }
{isGM && (
updateCombatState({ initiatives: combatState.initiatives.filter(e => !(e.player === entry.player && e.ts === entry.ts)) })}
className="rounded bg-slate-800 px-2 py-0.5 text-xs text-slate-400 hover:bg-slate-700"
>×
)}
))}
)}
{/* Status conditions */}
{(isGM || combatEntities.some(e => (combatState.conditions[e.name] || []).length > 0)) && (
Conditions
{combatEntities.length === 0 ? (
Roll initiative to populate combatants.
) : (
{combatEntities.map(entity => {
const active = combatState.conditions[entity.name] || [];
if (!isGM && active.length === 0) return null;
return (
{entity.type === 'player' ? 'Battle-Brother' : entity.type === 'npc' ? 'NPC' : 'Enemy'}
{entity.name}
{CONDITIONS.map(cond => {
const on = active.includes(cond);
return isGM ? (
toggleCondition(entity.name, cond)}
className={`rounded px-2 py-1 text-xs transition-colors ${on ? 'bg-rose-700 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>
{cond}
) : on ? (
{cond}
) : null;
})}
);
})}
)}
)}
{/* Action economy reference */}
setShowActionRef(v => !v)}
className="flex w-full items-center justify-between text-sm font-semibold uppercase tracking-wide text-slate-300"
>
Action Economy Reference
{showActionRef ? '▲' : '▼'}
{showActionRef && (
{ACTION_ECONOMY.map(row => (
{row.type}
{row.examples}
))}
Each Turn
2 Half Actions or 1 Full Action · 1 Reaction · Unlimited Free Actions
)}
)}
{/* #1 — player roll feed (current scene) */}
{!isGM && playerRollFeed.length > 0 && (
Scene Roll Feed
{playerRollFeed.map(roll => (
))}
)}
) : (
This mission has no scenes stored.
)}
{/* GM scene navigation */}
{isGM && currentScene && (
saveProgress(activeMission.current_scene - 1)}
disabled={saving || activeMission.current_scene <= 0}
className="rounded bg-slate-700 px-3 py-2 text-sm hover:bg-slate-600 disabled:opacity-50"
>
Previous Scene
toggleSceneComplete(activeMission.current_scene)}
disabled={saving}
className="rounded bg-emerald-700 px-3 py-2 text-sm hover:bg-emerald-600 disabled:opacity-50"
>
{currentScene.completed ? 'Reopen Scene' : 'Mark Scene Complete'}
saveProgress(activeMission.current_scene + 1)}
disabled={saving || activeMission.current_scene >= activeMission.scenes.length - 1}
className="rounded bg-blue-600 px-3 py-2 text-sm hover:bg-blue-500 disabled:opacity-50"
>
Advance Scene
)}
{/* ── Right: GM Scene Secrets + Table State ── */}
{isGM && (
{/* Scene Secrets */}
Scene Secrets
editingScene ? setEditingScene(false) : openSceneEdit()}
className={`rounded px-2 py-1 text-xs ${editingScene ? 'bg-amber-700 text-amber-100 hover:bg-amber-600' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}
>
{editingScene ? 'Close Editor' : '✏ Edit Scene'}
{playPrompt && (
Close All
)}
{/* #2 — per-element reveal controls */}
Reveal to Players
All
Lock
{[
{ key: 'title', label: 'Title' },
{ key: 'mood', label: 'Mood' },
{ key: 'description', label: 'Description' },
{ key: 'objectives', label: 'Objectives' },
{ key: 'complications', label: 'Complications' },
].map(({ key, label }) => (
toggleReveal(key)}
className={`flex items-center justify-between gap-1 rounded px-2 py-1 text-left ${sceneReveal[key] ? 'bg-emerald-900/50 text-emerald-300' : 'bg-slate-800 text-slate-400'}`}
>
{label}
{sceneReveal[key] ? '👁' : '🔒'}
))}
{/* Currently revealed */}
{playPrompt ? (
Revealed to players
{playPrompt.title || playPrompt.type}
{playPrompt.type === 'check' && playPrompt.check && (
{playPrompt.check.skill} ({playPrompt.check.characteristic}) · {playPrompt.check.target}
{playPrompt.assignedTo && ` · Assigned: ${playPrompt.assignedTo}`}
{playPrompt.isDecision && ' · Decision'}
)}
{playPrompt.type === 'combat' && (
Initiative order · Combat stats
)}
Close
) : (
Nothing revealed to players yet.
)}
{/* Checks — #6 assignment + #8 decision options */}
Checks
{sceneChecks.map((check) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
const isOpen = playPrompt?.type === 'check' && playPrompt.title === check.name;
const isExpanded = checkOpenOptions?.name === check.name;
const isCheckRevealed = !!sceneReveal.checks[check.name];
const isManualEntry = gmManualRoll?.checkName === check.name;
return (
{check.name}
{check.skill} ({check.characteristic}) · {target}
{/* Reveal toggle */}
toggleRevealCheck(check.name)}
title={isCheckRevealed ? 'Hide from players' : 'Reveal to players'}
className={`shrink-0 rounded px-2 py-1 text-xs ${isCheckRevealed ? 'bg-emerald-900/50 text-emerald-300 hover:bg-emerald-800/50' : 'bg-slate-800 text-slate-500 hover:bg-slate-700'}`}
>
{isCheckRevealed ? '👁' : '🔒'}
{/* Manual roll entry toggle */}
setGmManualRoll(isManualEntry ? null : { checkName: check.name, playerName: playerList[0] || '', value: '' })}
title="Enter a physical dice result"
className={`shrink-0 rounded px-2 py-1 text-xs ${isManualEntry ? 'bg-blue-900/60 text-blue-300' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>
🎲
{isOpen ? (
Opened ✓
) : (
<>
openCheckForPlayers(check, checkOpenOptions?.name === check.name ? { assignedTo: checkOpenOptions.assignedTo, isDecision: checkOpenOptions.isDecision } : {})}
className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-600"
>
Open
setCheckOpenOptions(isExpanded ? null : { name: check.name, assignedTo: '', isDecision: false })}
className="shrink-0 rounded bg-slate-800 px-2 py-1 text-xs text-slate-400 hover:bg-slate-700"
title="Options"
>
⚙
>
)}
{/* Manual roll entry form */}
{isManualEntry && (
Enter physical dice result
Player:
setGmManualRoll(prev => ({ ...prev, playerName: e.target.value }))}
className="flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs"
>
— select —
{playerList.map(p => {p} )}
Roll (1–100):
setGmManualRoll(prev => ({ ...prev, value: e.target.value }))}
placeholder="e.g. 42"
className="w-24 rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs"
/>
{gmManualRoll.value && !isNaN(parseInt(gmManualRoll.value)) && (
{parseInt(gmManualRoll.value) <= target ? 'Success' : 'Fail'}
)}
Submit Roll
setGmManualRoll(null)}
className="rounded bg-slate-700 px-3 py-1 text-xs text-slate-300 hover:bg-slate-600"
>
Cancel
)}
{/* #6 + #8 — expanded options */}
{isExpanded && !isOpen && (
)}
);
})}
{/* Combat */}
Combat
{sceneCombat.enemy || 'Custom/None'}
BS {sceneCombat.bs || 45} / WS {sceneCombat.ws || 45} · {sceneCombat.damage || '1d10+5'}
{/* Reveal toggle */}
toggleReveal('combat')}
title={sceneReveal.combat ? 'Hide combat from players' : 'Reveal combat to players'}
className={`shrink-0 rounded px-2 py-1 text-xs ${sceneReveal.combat ? 'bg-emerald-900/50 text-emerald-300 hover:bg-emerald-800/50' : 'bg-slate-800 text-slate-500 hover:bg-slate-700'}`}
>
{sceneReveal.combat ? '👁' : '🔒'}
{combatActive ? 'Close' : 'Open Combat'}
{/* Roll limit */}
Player roll limit
{currentScene?.rollsUnlocked ? 'Unlimited (lock)' : '1 per scene (unlock)'}
{/* Table State */}
Table State
GM
{activeMission.gm_player || 'Unassigned'}
Scenes complete
{activeMission.scenes.filter(s => s.completed).length} / {activeMission.scenes.length}
Enemy pressure
{activeMission.enemy_count || '?'} per scene
Extra rewards
{earnedMissionRewards.completed} / {earnedMissionRewards.total} solved
{earnedMissionRewards.renown ? ` · +${earnedMissionRewards.renown} Renown` : ''}
{earnedMissionRewards.reserveReq ? ` · +${earnedMissionRewards.reserveReq} Reserve Req` : ''}
{earnedMissionRewards.xp ? ` · +${earnedMissionRewards.xp} XP` : ''}
Estimated XP
{missionXpEstimate.total}
({missionXpEstimate.checkXp} checks, {missionXpEstimate.sceneXp} scenes, {missionXpEstimate.combatXp} combat)
Last update
{activeMission.updated_at ? new Date(activeMission.updated_at).toLocaleString('da-DK') : 'Unknown'}
)}
{/* Scene Editor — full width, below the two-column grid */}
{isGM && editingScene && sceneEdit && (
Edit Scene — {sceneEdit.title || 'Untitled'}
{saving ? 'Saving…' : 'Save Changes'}
{ setEditingScene(false); setSceneEdit(null); }}
className="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
>
Cancel
{/* Title + Type + Description — 3-col on wide screens */}
Scene Title
seField('title', e.target.value)}
className="w-full min-w-0 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
Type
seField('type', e.target.value)}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
>
{['intro','investigation','combat','social','puzzle','boss'].map(t => (
{t}
))}
Mood / Read-aloud (vist i kursiv til spillerne)
Scene Description / Briefing
Ending Text / Scene Complete Read-aloud
{/* Checks + Enemies — side by side on wide screens */}
{/* Checks */}
Checks
+ Add
{sceneEdit.checks.map((check, i) => (
))}
{sceneEdit.checks.length === 0 && (
No checks — defaults generated from scene type.
)}
{/* Enemies */}
Enemies
+ Add
{sceneEdit.enemies.map((enemy, i) => (
seEnemy(i, 'name', e.target.value)}
placeholder="Enemy name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
removeEnemy(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×
Wounds
seEnemy(i, 'wounds', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
BS
seEnemy(i, 'bs', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
WS
seEnemy(i, 'ws', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
Damage
seEnemy(i, 'damage', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
AP
seEnemy(i, 'ap', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
))}
{sceneEdit.enemies.length === 0 && (
No enemies — defaults generated from scene index.
)}
{/* Objectives + Complications — side by side */}
Objectives (one per line)
Complications / Pressure (one per line)
{saving ? 'Saving…' : 'Save Changes'}
)}
{/* #1 — GM Roll Feed (full, with delete) */}
{isGM && (
Roll Feed
Refresh Rolls
{rollFeed.length === 0 ? (
No mission rolls recorded yet.
) : (
{rollFeed.map(roll => )}
)}
)}
{/* GM Mission Scenes overview */}
{isGM && (
Mission Scenes
{activeMission.scenes.map((scene, index) => {
const active = index === activeMission.current_scene;
const enemies = sceneEnemies(scene);
return (
Scene {index + 1}
{active && Current }
{scene.completed && Complete }
{scene.revealed === false && Hidden }
{titleForScene(scene, index)}
{textForScene(scene) || 'No story text stored.'}
{enemies.length > 0 && (
Enemies: {enemies.join(', ')}
)}
setCurrentScene(index)}
disabled={saving || active}
className="rounded bg-slate-700 px-3 py-2 text-xs hover:bg-slate-600 disabled:opacity-50"
>
Make Current
toggleSceneComplete(index)}
disabled={saving}
className="rounded bg-emerald-700 px-3 py-2 text-xs hover:bg-emerald-600 disabled:opacity-50"
>
{scene.completed ? 'Reopen' : 'Complete'}
{renderMiniSceneRoller(scene, index, { compact: true })}
);
})}
)}
{/* GM-only full story for read-aloud prep */}
{isGM && (
GM Only
Hele historien til oplæsning
{activeMission.scenes.length} scenes
{activeMission.scenes.map((scene, index) => {
const objectives = listForScene(scene, 'objectives');
const complications = listForScene(scene, 'complications');
const challenges = extraChallengesForScene(scene);
return (
Scene {index + 1}
{scene.type || 'scene'}
{index === activeMission.current_scene && Current }
{scene.completed && Complete }
{titleForScene(scene, index)}
{scene.mood && (
)}
{textForScene(scene) || 'No scene text stored.'}
{endingTextForScene(scene) && (
Ending Read-aloud
{endingTextForScene(scene)}
)}
{(objectives.length > 0 || complications.length > 0 || challenges.length > 0) && (
{objectives.length > 0 && (
Objectives
{objectives.map((objective, objectiveIndex) => {objective} )}
)}
{complications.length > 0 && (
Pressure
{complications.map((complication, complicationIndex) => {complication} )}
)}
{challenges.length > 0 && (
Extra Rewards
{challenges.map((challenge, challengeIndex) => (
{challenge.text} ({rewardLabel(challenge)})
))}
)}
)}
);
})}
)}
>
)}
);
}