Files
dwroller/src/components/MissionTab.jsx
T
2026-06-30 08:53:44 +02:00

3109 lines
164 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="rounded border border-slate-700 bg-slate-950/40 p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold capitalize">
{roll.player_name || 'unknown'} · {roll.roll_type} · {roll.label || payload.name || 'Roll'}
</span>
<div className="flex items-center gap-2">
<span className="text-slate-400 text-xs">{roll.created_at ? new Date(roll.created_at).toLocaleTimeString('da-DK') : ''}</span>
{showDelete && (
<button onClick={() => onDelete(roll.id)} className="rounded bg-slate-800 px-2 py-1 text-xs text-slate-300 hover:bg-slate-700">
Delete
</button>
)}
</div>
</div>
<div className="mt-1 text-slate-300">
{roll.roll_type === 'decision' && payload.declaration && (
<span className="italic text-blue-200">"{payload.declaration}"</span>
)}
{typeof payload.roll !== 'undefined' && (
<>
Roll <strong>{payload.roll}</strong> vs {payload.target}{' '}
{success
? <span className="text-emerald-300">Success {payload.dos ? `(${payload.dos} DoS)` : ''}</span>
: <span className="text-rose-300">Fail {payload.dof ? `(${payload.dof} DoF)` : ''}</span>}
</>
)}
{typeof payload.attackRoll !== 'undefined' && (
<>Attack <strong>{payload.attackRoll}</strong> vs {payload.target} · Hits {payload.hits || 0}{payload.where ? ` · ${payload.where}` : ''}</>
)}
{typeof payload.total !== 'undefined' && roll.roll_type === 'initiative' && (
<span>{`Initiative ${payload.total} (d10:${payload.dieRoll} + Ab:${payload.agBonus})`}</span>
)}
</div>
</div>
);
}
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 (
<div className={`rounded-lg border border-cyan-700/40 bg-cyan-950/10 p-4 ${opts.compact ? 'mt-3' : ''}`}>
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-sm font-semibold uppercase tracking-wide text-cyan-200">Mini Roller</h4>
<p className="text-xs text-slate-400">Scene skill, combat and initiative. You: {authedPlayer} · {loggedInWounds}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-slate-950 px-2 py-1 text-xs text-slate-400">
{initiatives.length} initiative{initiatives.length === 1 ? '' : 's'}
</span>
{isGM && (
<button
onClick={() => 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'}
</button>
)}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-3">
<div className="rounded border border-slate-700 bg-slate-950/40 p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Skill</div>
<div className="grid gap-2">
<select
value={state.player}
onChange={e => 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 => <option key={name} value={name}>{name}</option>)}
</select>
<select
value={state.checkName}
onChange={e => setMiniState(sceneIndex, { checkName: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{checks.map(check => <option key={check.name} value={check.name}>{check.name}</option>)}
</select>
<div className="grid gap-2 rounded border border-slate-800 bg-slate-900/50 p-2">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Skill difficulty</label>
<select
aria-label="Skill difficulty"
value={state.skillDifficultyModifier}
onChange={e => setMiniState(sceneIndex, { skillDifficultyModifier: Number(e.target.value) })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{SKILL_DIFFICULTIES.map(diff => (
<option key={diff.label} value={diff.value}>
{diff.label} ({diff.value >= 0 ? '+' : ''}{diff.value})
</option>
))}
</select>
<div className="flex flex-wrap gap-1">
{SKILL_SITUATION_MODIFIERS.map(mod => {
const active = state.skillSituationModifiers.includes(mod.id);
return (
<button
key={mod.id}
type="button"
onClick={() => setMiniState(sceneIndex, {
skillSituationModifiers: active
? state.skillSituationModifiers.filter(id => id !== mod.id)
: [...state.skillSituationModifiers, mod.id],
})}
className={`rounded px-2 py-1 text-[11px] ${active ? 'bg-cyan-700 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'}`}
>
{mod.label} {mod.value >= 0 ? '+' : ''}{mod.value}
</button>
);
})}
</div>
<label className="grid gap-1 text-xs text-slate-400">
Custom modifier
<input
aria-label="Skill custom modifier"
type="number"
value={state.skillCustomModifier}
onChange={e => setMiniState(sceneIndex, { skillCustomModifier: Number(e.target.value || 0) })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
</label>
</div>
{selectedCheck && (
<div className="rounded border border-slate-800 bg-slate-900/70 px-2 py-2 text-xs text-slate-300">
<div>
<span className="font-semibold text-cyan-200">{selectedCheck.skill || 'Skill'}</span>
{' '}({selectedCheck.characteristic || 'Characteristic'})
{selectedCheck.modifier ? ` · ${selectedCheck.modifier > 0 ? '+' : ''}${selectedCheck.modifier}` : ''}
</div>
{skillBreakdown && (
<div className="mt-2 rounded border border-cyan-900/50 bg-cyan-950/20 p-2">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold uppercase tracking-wide text-cyan-200">Target Number</span>
<span data-testid="skill-target-number" className="text-lg font-bold tabular-nums text-cyan-100">{skillBreakdown.target}</span>
</div>
<div className="mt-1 grid gap-1 text-slate-400">
<div>Base {skillBreakdown.characteristic}: <span className="text-slate-200">{skillBreakdown.base}</span></div>
<div>Training: <span className="text-slate-200">{skillBreakdown.trainingLabel} ({skillBreakdown.training >= 0 ? '+' : ''}{skillBreakdown.training})</span></div>
<div>Scene modifier: <span className="text-slate-200">{skillBreakdown.checkModifier >= 0 ? '+' : ''}{skillBreakdown.checkModifier}</span></div>
<div>Difficulty: <span className="text-slate-200">{skillBreakdown.difficultyModifier >= 0 ? '+' : ''}{skillBreakdown.difficultyModifier}</span></div>
<div>Skill conditions: <span className="text-slate-200">{skillBreakdown.situationTotal >= 0 ? '+' : ''}{skillBreakdown.situationTotal}</span></div>
<div>Custom: <span className="text-slate-200">{skillBreakdown.customModifier >= 0 ? '+' : ''}{skillBreakdown.customModifier}</span></div>
</div>
{skillBreakdown.situationMods.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{skillBreakdown.situationMods.map(mod => (
<span key={`${mod.label}-${mod.value}`} className="rounded bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-300">
{mod.label} {mod.value ? `${mod.value > 0 ? '+' : ''}${mod.value}` : '+0'}
</span>
))}
</div>
) : (
<div className="mt-2 text-[11px] text-slate-500">No active skill condition modifiers.</div>
)}
</div>
)}
{selectedCheck.rulesNote && (
<div className="mt-1 text-slate-400">{selectedCheck.rulesNote}</div>
)}
{selectedCheck.reward && (
<div className="mt-1 text-amber-200">Success: {selectedCheck.reward}</div>
)}
</div>
)}
<button
onClick={() => 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})` : ''}`}
</button>
{skillAlreadyRolled && (
<div className={`rounded px-2 py-1 text-xs ${scene?.rollsUnlocked ? 'bg-amber-950/40 text-amber-200' : 'bg-slate-900/60 text-slate-400'}`}>
{scene?.rollsUnlocked ? 'GM unlocked extra rolls for this scene.' : 'Locked: 1 skill roll per player for this check.'}
</div>
)}
{latestSkillRoll ? (
<div className={`rounded px-2 py-1 text-xs ${latestSkillRoll.payload?.success ? 'bg-emerald-950/40 text-emerald-200' : 'bg-rose-950/40 text-rose-200'}`}>
{rollSummary(latestSkillRoll)}
</div>
) : (
<div className="rounded bg-slate-900/40 px-2 py-1 text-xs text-slate-500">No skill roll yet.</div>
)}
</div>
</div>
<div className="rounded border border-slate-700 bg-slate-950/40 p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Combat</div>
<div className="grid gap-2">
<select
value={state.enemyName}
onChange={e => setMiniState(sceneIndex, { enemyName: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{enemies.length === 0 && <option value="">No scene enemies</option>}
{enemies.map(enemy => {
const dead = enemy.dead || enemy.defeated || Number(enemy.wounds ?? enemy.woundsTotal ?? enemy.w ?? 1) <= 0;
return <option key={enemy.name} value={enemy.name}>{enemy.name} ({woundsLabel(enemy.wounds ?? enemy.woundsTotal ?? enemy.w)}){dead ? ' DEAD' : ''}</option>;
})}
</select>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded border border-slate-800 bg-slate-900/60 px-2 py-1 text-slate-400">
Player Wounds: <span className="text-slate-200">{selectedPlayerWounds}</span>
</div>
<div className="rounded border border-slate-800 bg-slate-900/60 px-2 py-1 text-slate-400">
Target Wounds: <span className="text-slate-200">{targetWounds}</span>
</div>
</div>
<div className="grid grid-cols-[1fr_90px] gap-2">
<select
value={state.attackSkill}
onChange={e => setMiniState(sceneIndex, { attackSkill: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
<option value="BS">Ranged BS</option>
<option value="WS">Melee WS</option>
</select>
<input
type="number"
value={state.modifier}
onChange={e => 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"
/>
</div>
<button
onClick={() => 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'}
</button>
{latestCombatRoll ? (
<div className={`rounded px-2 py-1 text-xs ${latestCombatRoll.payload?.success ? 'bg-emerald-950/40 text-emerald-200' : 'bg-rose-950/40 text-rose-200'}`}>
{rollSummary(latestCombatRoll)}
</div>
) : (
<div className="rounded bg-slate-900/40 px-2 py-1 text-xs text-slate-500">No combat roll yet.</div>
)}
</div>
</div>
<div className="rounded border border-slate-700 bg-slate-950/40 p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Initiative</div>
<div className="grid gap-2">
<select
value={state.initPlayer}
onChange={e => setMiniState(sceneIndex, { 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 => <option key={name} value={name}>{name}</option>)}
</select>
<input
type="number"
placeholder="Ag bonus auto"
value={state.initAgBonus}
onChange={e => setMiniState(sceneIndex, { initAgBonus: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
/>
<button
onClick={() => addMiniInitiative(sceneIndex, scene)}
disabled={!state.initPlayer}
className="rounded bg-blue-700 px-3 py-2 text-sm hover:bg-blue-600 disabled:opacity-40"
>
Roll Initiative
</button>
</div>
</div>
</div>
{initiatives.length > 0 && (
<div className="mt-3 grid gap-1">
{[...initiatives].sort((a, b) => b.total - a.total).map((entry, i) => (
<div key={`${entry.player}-${entry.ts || i}`} className="flex items-center gap-2 rounded bg-slate-950/60 px-2 py-1 text-xs">
<span className="w-5 text-center text-slate-500">{i + 1}</span>
<span className="flex-1 capitalize">{entry.player}</span>
<span className="font-semibold text-cyan-200">{entry.total}</span>
<span className="text-slate-500">d10:{entry.dieRoll} + Ab:{entry.agBonus}</span>
{isGM && (
<button
onClick={() => 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
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
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 (
<section className="min-h-screen bg-slate-900 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-6xl">
<h1 className="text-3xl font-bold">Mission</h1>
<p className="mt-3 text-slate-300">Log in to see the active Deathwatch mission.</p>
</div>
</section>
);
}
return (
<section className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-800 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-6xl space-y-5">
{/* Header */}
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-blue-300">Deathwatch Mission Table</p>
<h1 className="text-3xl font-bold">Active Mission</h1>
</div>
<button onClick={loadMissions} className="self-start rounded bg-slate-700 px-3 py-2 text-sm hover:bg-slate-600">
Refresh
</button>
</div>
{/* Next game date banner */}
<div className="flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-950/20 px-4 py-3">
<span className="text-lg">⚔️</span>
<div>
<span className="text-xs uppercase tracking-wide text-amber-400">Næste spildag</span>
<p className="font-semibold text-amber-100">Lørdag 28. juni 2026 — Deathwatch session</p>
</div>
</div>
{error && <div className="rounded border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{error}</div>}
{message && <div className="rounded border border-emerald-500/30 bg-emerald-950/30 p-3 text-sm text-emerald-200">{message}</div>}
{/* #11 — Mission complete banner */}
{missionComplete && (
<div className="rounded-lg border border-emerald-500/40 bg-emerald-950/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-emerald-200">Mission Complete</h2>
<p className="text-sm text-emerald-100/70">{activeMission.name} — all {activeMission.scenes.length} scenes concluded.</p>
</div>
<button onClick={() => setShowMissionReport(v => !v)} className="rounded bg-emerald-800 px-3 py-2 text-sm hover:bg-emerald-700">
{showMissionReport ? 'Hide Report' : 'View Report'}
</button>
</div>
{showMissionReport && (
<div className="mt-4 space-y-4">
<div className="grid gap-3 sm:grid-cols-4 text-sm">
<div className="rounded border border-slate-700 bg-slate-900/60 p-3 text-center">
<div className="text-2xl font-bold text-emerald-300">{activeMission.scenes.length}</div>
<div className="text-xs uppercase tracking-wide text-slate-400 mt-1">Scenes</div>
</div>
<div className="rounded border border-slate-700 bg-slate-900/60 p-3 text-center">
<div className="text-2xl font-bold text-blue-300">{rollStats.total}</div>
<div className="text-xs uppercase tracking-wide text-slate-400 mt-1">Checks Rolled</div>
</div>
<div className="rounded border border-slate-700 bg-slate-900/60 p-3 text-center">
<div className="text-2xl font-bold text-amber-300">
{rollStats.total > 0 ? Math.round((rollStats.successes / rollStats.total) * 100) : 0}%
</div>
<div className="text-xs uppercase tracking-wide text-slate-400 mt-1">Success Rate</div>
</div>
<div className="rounded border border-slate-700 bg-slate-900/60 p-3 text-center">
<div className="text-2xl font-bold text-cyan-300">{missionXpEstimate.total}</div>
<div className="text-xs uppercase tracking-wide text-slate-400 mt-1">Estimated XP</div>
</div>
</div>
<div className="rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
XP breakdown: {missionXpEstimate.checkSuccesses} successful checks x 50 = {missionXpEstimate.checkXp};
scene bonuses = {missionXpEstimate.sceneXp}; combat = {missionXpEstimate.combatXp};
extra challenge XP = {missionXpEstimate.bonusXp}.
</div>
{(isGM || activeMission.scenes[activeMission.scenes.length - 1]?.outcome) && (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Outcome Notes</div>
{isGM ? (
<div className="flex gap-2">
<textarea
value={outcomeNotes}
onChange={e => setOutcomeNotes(e.target.value)}
placeholder="Record what the Kill-team decided, what was achieved, what was lost..."
rows={3}
className="flex-1 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500"
/>
<button onClick={saveOutcomeNotes} disabled={saving} className="self-end rounded bg-emerald-700 px-3 py-2 text-sm hover:bg-emerald-600 disabled:opacity-50">
Save
</button>
</div>
) : (
<div className="rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm italic text-slate-200">
{activeMission.scenes[activeMission.scenes.length - 1]?.outcome || ''}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* GM mission selector */}
{isGM && (
<div className="rounded-lg border border-blue-500/30 bg-blue-950/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">GM Mission Control</h2>
<p className="text-sm text-blue-100/70">Choose a story from the database and make it the mission everyone plays from this tab.</p>
</div>
<span className="rounded bg-slate-900/70 px-2 py-1 text-xs text-blue-200">{missions.length} saved</span>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<select
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-3 py-2"
value={selectedMissionId}
onChange={e => setSelectedMissionId(e.target.value)}
>
<option value="">Select mission from database</option>
{missions.map(mission => (
<option key={mission.id} value={mission.id}>
{mission.name} ({missionTheme(mission)}, {mission.threat_level || 'Medium'})
</option>
))}
</select>
<button
onClick={() => 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
</button>
</div>
</div>
)}
{loading ? (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-6 text-slate-300">Loading mission data...</div>
) : !activeMission ? (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-6">
<h2 className="text-xl font-semibold">No Active Mission</h2>
<p className="mt-2 text-slate-300">
{isGM ? 'Select a saved mission above to begin play.' : 'The GM has not selected a mission yet.'}
</p>
</div>
) : (
<>
<div className={isGM ? 'grid gap-4 lg:grid-cols-[1.5fr_1fr]' : 'grid gap-4'}>
{/* ── Left: scene content ── */}
<div className="rounded-lg border border-slate-700 bg-slate-900 p-5">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-2xl font-bold">{activeMission.name}</h2>
{isGM && (
<p className="mt-1 text-sm capitalize text-slate-300">
{missionTheme(activeMission)} threat · {activeMission.threat_level || 'Medium'} · {activeMission.player_count || '?'} battle-brothers
</p>
)}
</div>
{isGM && (
<div className="rounded bg-blue-600/20 px-3 py-2 text-sm text-blue-100">
Scene {activeMission.current_scene + 1} / {activeMission.scenes.length || 1}
</div>
)}
</div>
{currentScene ? (
<div className="space-y-4">
{/* ── Scene briefing ── */}
<div className="rounded-lg border border-blue-500/30 bg-blue-950/20 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-blue-300">
{isGM ? 'Current Scene' : 'Mission Briefing'}
</p>
{/* Title gated for players */}
{isGM || sceneReveal.title ? (
<h3 className="text-xl font-semibold">{titleForScene(currentScene, activeMission.current_scene)}</h3>
) : (
<h3 className="text-xl font-semibold text-slate-500">Scene {activeMission.current_scene + 1}</h3>
)}
</div>
{isGM && (
<span className="rounded bg-slate-950/80 px-2 py-1 text-xs text-slate-300">{currentScene.type || 'Scene'}</span>
)}
</div>
{/* Per-element reveal gates for players */}
{isGM ? (
/* GM always sees everything */
<div className="space-y-3">
{currentScene.mood && (
<div className="rounded border border-amber-500/20 bg-amber-950/20 px-4 py-3">
<p className="text-sm italic leading-relaxed text-amber-100">{currentScene.mood}</p>
</div>
)}
<p className="whitespace-pre-wrap text-slate-200">{textForScene(currentScene) || 'No scene text stored.'}</p>
{sceneObjectives.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-blue-200">Objectives</h4>
<div className="grid gap-2">
{sceneObjectives.map((obj, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-950/40 px-3 py-2 text-sm text-slate-200">{obj}</div>
))}
</div>
</div>
)}
{sceneComplications.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-200">Pressure</h4>
<div className="grid gap-2">
{sceneComplications.map((comp, i) => (
<div key={i} className="rounded border border-amber-500/30 bg-amber-950/20 px-3 py-2 text-sm text-amber-100">{comp}</div>
))}
</div>
</div>
)}
</div>
) : (
/* Players see only what GM has unlocked */
(() => {
const anyVisible = sceneReveal.mood || sceneReveal.description || sceneReveal.objectives || sceneReveal.complications;
if (!anyVisible) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">🔒</div>
<p className="text-sm text-slate-400">Awaiting GM scene briefing.<br />Stand by, Kampbroder.</p>
</div>
);
}
return (
<div className="space-y-3">
{sceneReveal.mood && currentScene.mood && (
<div className="rounded border border-amber-500/20 bg-amber-950/20 px-4 py-3">
<p className="text-sm italic leading-relaxed text-amber-100">{currentScene.mood}</p>
</div>
)}
{sceneReveal.description && (
<p className="whitespace-pre-wrap text-slate-200">{textForScene(currentScene)}</p>
)}
{sceneReveal.objectives && sceneObjectives.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-blue-200">Objectives</h4>
<div className="grid gap-2">
{sceneObjectives.map((obj, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-950/40 px-3 py-2 text-sm text-slate-200">{obj}</div>
))}
</div>
</div>
)}
{sceneReveal.complications && sceneComplications.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-200">Pressure</h4>
<div className="grid gap-2">
{sceneComplications.map((comp, i) => (
<div key={i} className="rounded border border-amber-500/30 bg-amber-950/20 px-3 py-2 text-sm text-amber-100">{comp}</div>
))}
</div>
</div>
)}
</div>
);
})()
)}
{isGM && currentScene.completed && (
<div className="mt-3 rounded border border-emerald-500/30 bg-emerald-950/30 px-3 py-2 text-sm text-emerald-200">
<div className="font-semibold">Scene completed</div>
{endingTextForScene(currentScene) && (
<p className="mt-2 whitespace-pre-wrap text-emerald-100">{endingTextForScene(currentScene)}</p>
)}
</div>
)}
</div>
{(isGM || sceneReveal.checks || sceneReveal.combat || currentScene?.rollsUnlocked) && (
renderMiniSceneRoller(currentScene, activeMission.current_scene)
)}
{sceneExtraChallenges.length > 0 && (isGM || sceneReveal.complications || sceneExtraChallenges.some(c => c.completed)) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-950/10 p-4">
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-sm font-semibold uppercase tracking-wide text-amber-200">Extra Challenges</h4>
<p className="text-xs text-slate-400">
Earned here becomes mission reward. Heroic work grants Renown; Reserve Req is only a mission resource.
</p>
</div>
<div className="text-xs text-amber-100">
{sceneExtraChallenges.filter(c => c.completed).length} / {sceneExtraChallenges.length} solved
</div>
</div>
<div className="grid gap-2">
{sceneExtraChallenges.map((challenge, index) => (
<div
key={`${challenge.id}-${index}`}
className={`rounded border px-3 py-2 text-sm ${
challenge.completed
? 'border-emerald-500/40 bg-emerald-950/30 text-emerald-100'
: 'border-slate-700 bg-slate-950/40 text-slate-200'
}`}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="font-medium">{challenge.text}</div>
<div className="mt-0.5 text-xs text-slate-400">Reward: {rewardLabel(challenge)}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`rounded px-2 py-1 text-xs ${challenge.completed ? 'bg-emerald-800/70 text-emerald-100' : 'bg-slate-800 text-slate-400'}`}>
{challenge.completed ? 'Earned' : 'Available'}
</span>
{isGM && (
<button
onClick={() => 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'}
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Checks + Combat grid ── */}
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
{/* Scene Checks */}
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-300">Scene Checks</h4>
</div>
{/* 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 (
<div className="mb-4 rounded border border-emerald-500/30 bg-emerald-950/30 p-3 text-sm">
<div className="font-semibold text-emerald-200">{playPrompt.title}</div>
{assignedTo && (
<div className="mt-0.5 text-xs text-emerald-100/60">
Assigned to <span className="capitalize font-medium text-emerald-200">{assignedTo}</span>
</div>
)}
<div className="mt-1 text-emerald-100/80">
{isDecision ? 'Declare your intent for this moment.' : 'Roll this check when your Battle-Brother acts.'}
</div>
{isAssignedToMe && (
isDecision ? (
<div className="mt-3 space-y-2">
<textarea
value={declarationText}
onChange={e => setDeclarationText(e.target.value)}
placeholder="State your Battle-Brother's decision..."
rows={2}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500"
/>
<button
onClick={rollDecision}
disabled={!declarationText.trim() || hasRolled}
className="rounded bg-emerald-700 px-3 py-2 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{hasRolled ? 'Declared' : 'Declare'}
</button>
</div>
) : (
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={() => 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'}
</button>
{/* #9 — fate point re-roll */}
{canFate && (
<button
onClick={() => 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 ✦
</button>
)}
</div>
)
)}
{!isAssignedToMe && (
<div className="mt-2 text-xs text-slate-400 italic">Not assigned to you.</div>
)}
{/* #5 — reward only shown after rolling */}
{hasRolled && check.reward && (
<div className="mt-3 rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
<span className="text-amber-300 font-semibold">Reward: </span>{check.reward}
</div>
)}
</div>
);
})()}
{/* Player: visible scene checks (only GM-revealed ones) */}
{!isGM && (() => {
const revealedChecks = sceneChecks.filter(c => sceneReveal.checks[c.name]);
if (revealedChecks.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">📋</div>
<p className="text-sm text-slate-400">No check open yet.<br />Awaiting GM orders.</p>
</div>
);
}
return (
<div className="grid gap-3">
{revealedChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
<div key={`${check.name}-${index}`} className="rounded border border-slate-700 bg-slate-900 p-3">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="font-semibold text-slate-100">{check.name}</div>
<div className="text-xs text-slate-400">
{check.skill || 'Skill'} ({check.characteristic || 'Characteristic'}) target {target}
{check.modifier ? ` (${check.modifier > 0 ? '+' : ''}${check.modifier})` : ''}
</div>
{check.rulesNote && (
<div className="mt-1 text-xs text-slate-500">{check.rulesNote}</div>
)}
{check.reward && (
<div className="mt-1 text-xs text-amber-200">Success: {check.reward}</div>
)}
</div>
<button
onClick={openSceneInRoller}
className="rounded bg-emerald-700 px-3 py-2 text-sm font-medium hover:bg-emerald-600"
>
Open in Dice Roller
</button>
</div>
</div>
);
})}
</div>
);
})()}
{/* GM: full check list */}
{isGM && (
<div className="grid gap-3">
{sceneChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
<div key={`${check.name}-${index}`} className="rounded border border-slate-700 bg-slate-900 p-3">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-semibold text-slate-100">{check.name}</div>
<div className="text-xs text-slate-400">
{check.skill || 'Skill'} ({check.characteristic || 'Characteristic'}) target {target}
{check.modifier ? ` (${check.modifier > 0 ? '+' : ''}${check.modifier})` : ''}
</div>
</div>
<button
onClick={() => rollSceneCheck(check)}
className="rounded bg-blue-600 px-3 py-2 text-sm hover:bg-blue-500"
>
Roll Check
</button>
</div>
{check.reward && <p className="mt-2 text-sm text-slate-300">{check.reward}</p>}
</div>
);
})}
</div>
)}
{/* Recent rolls (all players) */}
{rollResults.length > 0 && (
<div className="mt-4 rounded border border-slate-700 bg-slate-900 p-3">
<h5 className="mb-2 text-sm font-semibold text-slate-200">Recent Scene Rolls</h5>
<div className="space-y-2 text-sm">
{rollResults.map(result => (
<div key={result.id} className="border-b border-slate-800 pb-2 last:border-b-0 last:pb-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-blue-200">{result.rolledBy || 'Unknown'}</span>
<span>
Roll <strong>{result.roll}</strong> vs {result.target} ·{' '}
{result.success
? <span className="text-emerald-300">Success, {result.dos} DoS</span>
: <span className="text-rose-300">Fail, {result.dof} DoF</span>}
</span>
</div>
<div className="text-xs text-slate-400">{result.name} · {result.skill}</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Combat Setup */}
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300">Combat Setup</h4>
{!isGM && !sceneReveal.combat ? (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">⚔️</div>
<p className="text-sm text-slate-400">Combat briefing er klassificeret.<br />Afventer GM-ordre.</p>
</div>
) : (
<>
<div className="space-y-2 text-sm">
<div className="flex justify-between gap-3 border-b border-slate-800 pb-2">
<span className="text-slate-400">Enemy</span>
<span className="text-right">{sceneCombat.enemy || 'Custom/None'}</span>
</div>
<div className="flex justify-between gap-3 border-b border-slate-800 pb-2">
<span className="text-slate-400">Attack target</span>
<span>BS {sceneCombat.bs || 45} / WS {sceneCombat.ws || 45}</span>
</div>
<div className="flex justify-between gap-3 border-b border-slate-800 pb-2">
<span className="text-slate-400">Modifier</span>
<span>{Number(sceneCombat.modifier || 0) >= 0 ? '+' : ''}{sceneCombat.modifier || 0}</span>
</div>
<div className="flex justify-between gap-3 border-b border-slate-800 pb-2">
<span className="text-slate-400">Damage</span>
<span>{sceneCombat.damage || '1d10+5'}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-400">Target soak</span>
<span>TB {sceneCombat.targetTB || 4} / AR {sceneCombat.targetArmour || 5}</span>
</div>
</div>
<button
onClick={openSceneInRoller}
className="mt-4 w-full rounded bg-emerald-700 px-3 py-2 text-sm font-medium hover:bg-emerald-600"
>
Open in Dice Roller
</button>
</>
)}
{isGM && (
<div className="mt-2 grid grid-cols-2 gap-2">
<button onClick={openCombatForPlayers} className="rounded bg-blue-700 px-3 py-2 text-sm hover:bg-blue-600">Open Combat</button>
<button onClick={clearPlayPrompt} className="rounded bg-slate-700 px-3 py-2 text-sm hover:bg-slate-600">Clear Open</button>
</div>
)}
</div>
</div>
{/* ── Combat state panels (initiative, conditions, action ref, fear) ── */}
{(combatActive || (!isGM && sceneReveal.combat)) && (
<div className="mt-4 space-y-4">
{/* Round counter + fear rating */}
<div className="rounded-lg border border-red-900/50 bg-red-950/20 p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold uppercase tracking-wide text-red-300">Round</span>
{isGM && <button onClick={() => shiftRound(-1)} disabled={combatState.round <= 1} className="rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600 disabled:opacity-40">−</button>}
<span className="text-2xl font-bold tabular-nums">{combatState.round}</span>
{isGM && <button onClick={() => shiftRound(1)} className="rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600">+</button>}
</div>
{isGM ? (
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">Fear:</span>
<select
value={combatState.fearRating}
onChange={e => setFearRating(Number(e.target.value))}
className="rounded border border-slate-600 bg-slate-900 px-2 py-1 text-sm"
>
{FEAR_RATINGS.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
</select>
</div>
) : combatState.fearRating > 0 ? (
<div className="rounded border border-rose-700/40 bg-rose-950/30 px-3 py-1 text-sm">
<span className="font-semibold text-rose-300">{FEAR_RATINGS[combatState.fearRating]?.label}</span>
{FEAR_RATINGS[combatState.fearRating]?.penalty && (
<span className="ml-2 text-rose-200/70">{FEAR_RATINGS[combatState.fearRating].penalty}</span>
)}
</div>
) : null}
{isGM && (
<button onClick={clearCombatState} className="ml-auto rounded bg-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-600">
Reset Combat
</button>
)}
</div>
</div>
{/* #7 — Fear test quick-roll (Finding #2: penalty auto-applied) */}
{combatState.fearRating > 0 && (
<div className="rounded-lg border border-rose-900/40 bg-rose-950/10 p-4">
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wide text-rose-300">Fear Test Required</h4>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-slate-400">
Willpower{fearPenaltyValue !== 0 && (
<span className="ml-1 text-rose-300">({fearPenaltyValue} penalty → effective {effectiveWP})</span>
)}
</label>
<input
type="number"
min="1" max="100"
value={wpTarget}
onChange={e => setWpTarget(e.target.value)}
className="w-20 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
/>
</div>
<button
onClick={rollFearTest}
disabled={rolledChecks.has('__fear__')}
className="rounded bg-rose-700 px-4 py-2 text-sm font-medium hover:bg-rose-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{rolledChecks.has('__fear__') ? 'Fear Test Rolled' : `Roll Fear Test (WP ${effectiveWP})`}
</button>
{rolledChecks.has('__fear__') && !fateUsedChecks.has('__fear__') && (
<button
onClick={() => 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 ✦
</button>
)}
</div>
</div>
)}
{/* Initiative tracker */}
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300">Initiative Order</h4>
{/* Player roll form */}
{!myInitiative && (
<div className="mb-4 flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-slate-400">Agility Bonus</label>
<input
type="number" min="0" max="20"
value={agBonus}
onChange={e => setAgBonus(e.target.value)}
placeholder="e.g. 4"
className="w-24 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
/>
</div>
<button
onClick={rollInitiative}
disabled={saving}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium hover:bg-blue-500 disabled:opacity-50"
>
Roll Initiative (1d10 + {parseInt(agBonus) || 0})
</button>
</div>
)}
{myInitiative && !isGM && (
<div className="mb-3 text-sm text-slate-400">
Your initiative: <strong className="text-slate-100">{myInitiative.total}</strong> (d10:{myInitiative.dieRoll} + Ab:{myInitiative.agBonus})
<button
onClick={() => { 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
</button>
</div>
)}
{/* #3 — GM NPC form */}
{isGM && (
<div className="mb-4 flex flex-wrap items-end gap-2 rounded border border-slate-700 bg-slate-900/40 p-3">
<div>
<label className="mb-1 block text-xs text-slate-400">NPC / Enemy Name</label>
<input
type="text"
value={npcName}
onChange={e => setNpcName(e.target.value)}
placeholder="e.g. Hormagaunt"
className="rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
onKeyDown={e => e.key === 'Enter' && addNpcInitiative()}
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Roll (blank = auto d10)</label>
<input
type="number" min="1" max="20"
value={npcRoll}
onChange={e => setNpcRoll(e.target.value)}
placeholder="1–20"
className="w-20 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm"
/>
</div>
<button
onClick={addNpcInitiative}
disabled={!npcName.trim()}
className="rounded bg-red-800 px-3 py-2 text-sm hover:bg-red-700 disabled:opacity-40"
>
Add NPC
</button>
</div>
)}
{/* Initiative list */}
{combatState.initiatives.length === 0 ? (
<p className="text-sm text-slate-500">No initiatives submitted yet.</p>
) : (
<div className="space-y-1">
{[...combatState.initiatives].sort((a, b) => b.total - a.total).map((entry, i) => (
<div
key={entry.player + entry.ts}
className={`flex items-center gap-3 rounded px-3 py-2 text-sm ${
entry.type === 'npc'
? 'bg-red-950/30 border border-red-900/40'
: entry.player === authedPlayer
? 'bg-blue-950/40 border border-blue-800/40'
: 'bg-slate-900/60'
}`}
>
<span className="w-5 text-center font-bold text-slate-400">{i + 1}</span>
{entry.type === 'npc' && <span className="rounded bg-red-900/60 px-1.5 py-0.5 text-xs text-red-300">NPC</span>}
<span className="flex-1 font-medium capitalize">{entry.player}</span>
<span className="text-lg font-bold tabular-nums">{entry.total}</span>
{entry.type !== 'npc' && <span className="text-xs text-slate-500">d10:{entry.dieRoll} + Ab:{entry.agBonus}</span>}
{isGM && (
<button
onClick={() => 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"
>×</button>
)}
</div>
))}
</div>
)}
</div>
{/* Status conditions */}
{(isGM || combatEntities.some(e => (combatState.conditions[e.name] || []).length > 0)) && (
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300">Conditions</h4>
{combatEntities.length === 0 ? (
<p className="text-sm text-slate-500">Roll initiative to populate combatants.</p>
) : (
<div className="space-y-3">
{combatEntities.map(entity => {
const active = combatState.conditions[entity.name] || [];
if (!isGM && active.length === 0) return null;
return (
<div key={entity.name} className="rounded border border-slate-800 bg-slate-900/60 p-3">
<div className="mb-2 flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${entity.type === 'player' ? 'bg-blue-900/50 text-blue-300' : 'bg-red-900/50 text-red-300'}`}>
{entity.type === 'player' ? 'Battle-Brother' : entity.type === 'npc' ? 'NPC' : 'Enemy'}
</span>
<span className="font-medium capitalize">{entity.name}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{CONDITIONS.map(cond => {
const on = active.includes(cond);
return isGM ? (
<button
key={cond}
onClick={() => 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}
</button>
) : on ? (
<span key={cond} className="rounded bg-rose-700/80 px-2 py-1 text-xs text-white">{cond}</span>
) : null;
})}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Action economy reference */}
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<button
onClick={() => setShowActionRef(v => !v)}
className="flex w-full items-center justify-between text-sm font-semibold uppercase tracking-wide text-slate-300"
>
Action Economy Reference
<span className="text-slate-500">{showActionRef ? '▲' : '▼'}</span>
</button>
{showActionRef && (
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{ACTION_ECONOMY.map(row => (
<div key={row.type} className={`rounded border ${row.border} ${row.bg} px-3 py-2`}>
<div className={`mb-1 text-xs font-bold uppercase tracking-wide ${row.color}`}>{row.type}</div>
<div className="text-xs text-slate-300">{row.examples}</div>
</div>
))}
<div className="rounded border border-slate-700 bg-slate-900/30 px-3 py-2 sm:col-span-2">
<div className="mb-1 text-xs font-bold uppercase tracking-wide text-slate-400">Each Turn</div>
<div className="text-xs text-slate-300">2 Half Actions <em>or</em> 1 Full Action · 1 Reaction · Unlimited Free Actions</div>
</div>
</div>
)}
</div>
</div>
)}
{/* #1 — player roll feed (current scene) */}
{!isGM && playerRollFeed.length > 0 && (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-4">
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300">Scene Roll Feed</h4>
<div className="space-y-2">
{playerRollFeed.map(roll => (
<RollFeedRow key={roll.id} roll={roll} showDelete={false} />
))}
</div>
</div>
)}
</div>
) : (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-4 text-slate-300">This mission has no scenes stored.</div>
)}
{/* GM scene navigation */}
{isGM && currentScene && (
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={() => 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
</button>
<button
onClick={() => 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'}
</button>
<button
onClick={() => 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
</button>
</div>
)}
</div>
{/* ── Right: GM Scene Secrets + Table State ── */}
{isGM && (
<div className="space-y-4">
{/* Scene Secrets */}
<div className="rounded-lg border border-amber-500/30 bg-amber-950/10 p-5">
<div className="mb-4 flex items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-200">Scene Secrets</h3>
<div className="flex gap-1.5">
<button
onClick={() => 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'}
</button>
{playPrompt && (
<button onClick={clearPlayPrompt} className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-600">
Close All
</button>
)}
</div>
</div>
{/* #2 — per-element reveal controls */}
<div className="mb-4 rounded border border-slate-700 bg-slate-900/60 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">Reveal to Players</div>
<div className="flex gap-1">
<button onClick={revealAll} className="rounded bg-emerald-900/60 px-2 py-0.5 text-xs text-emerald-300 hover:bg-emerald-800/60">All</button>
<button onClick={lockAll} className="rounded bg-slate-800 px-2 py-0.5 text-xs text-slate-400 hover:bg-slate-700">Lock</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-xs">
{[
{ key: 'title', label: 'Title' },
{ key: 'mood', label: 'Mood' },
{ key: 'description', label: 'Description' },
{ key: 'objectives', label: 'Objectives' },
{ key: 'complications', label: 'Complications' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => 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'}`}
>
<span>{label}</span>
<span>{sceneReveal[key] ? '👁' : '🔒'}</span>
</button>
))}
</div>
</div>
{/* Currently revealed */}
<div className="mb-4 rounded border border-slate-700 bg-slate-950/50 p-3 text-sm">
{playPrompt ? (
<div className="flex items-start justify-between gap-2">
<div>
<div className="mb-0.5 text-xs uppercase tracking-wide text-slate-400">Revealed to players</div>
<div className="font-semibold text-emerald-200">{playPrompt.title || playPrompt.type}</div>
{playPrompt.type === 'check' && playPrompt.check && (
<div className="mt-0.5 text-xs text-slate-400">
{playPrompt.check.skill} ({playPrompt.check.characteristic}) · {playPrompt.check.target}
{playPrompt.assignedTo && ` · Assigned: ${playPrompt.assignedTo}`}
{playPrompt.isDecision && ' · Decision'}
</div>
)}
{playPrompt.type === 'combat' && (
<div className="mt-0.5 text-xs text-slate-400">Initiative order · Combat stats</div>
)}
</div>
<button onClick={clearPlayPrompt} className="shrink-0 rounded bg-rose-900/60 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">
Close
</button>
</div>
) : (
<div className="text-slate-500">Nothing revealed to players yet.</div>
)}
</div>
{/* Checks — #6 assignment + #8 decision options */}
<div className="mb-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Checks</div>
<div className="space-y-1.5">
{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 (
<div key={check.name} className={`rounded border ${isOpen ? 'border-emerald-700/60 bg-emerald-950/30' : 'border-slate-700 bg-slate-900/60'}`}>
<div className="flex items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{check.name}</div>
<div className="text-xs text-slate-400">{check.skill} ({check.characteristic}) · {target}</div>
</div>
{/* Reveal toggle */}
<button
onClick={() => 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 ? '👁' : '🔒'}
</button>
{/* Manual roll entry toggle */}
<button
onClick={() => 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'}`}
>
🎲
</button>
{isOpen ? (
<button
onClick={clearPlayPrompt}
className="shrink-0 rounded bg-emerald-700 px-2 py-1 text-xs text-white hover:bg-emerald-600"
>
Opened ✓
</button>
) : (
<>
<button
onClick={() => 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
</button>
<button
onClick={() => 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"
>
⚙
</button>
</>
)}
</div>
{/* Manual roll entry form */}
{isManualEntry && (
<div className="border-t border-blue-900/40 bg-blue-950/20 px-3 py-2 space-y-2">
<div className="text-xs font-semibold text-blue-300">Enter physical dice result</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-16 shrink-0">Player:</label>
<select
value={gmManualRoll.playerName}
onChange={e => setGmManualRoll(prev => ({ ...prev, playerName: e.target.value }))}
className="flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs"
>
<option value="">— select —</option>
{playerList.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-16 shrink-0">Roll (1–100):</label>
<input
type="number"
min="1"
max="100"
value={gmManualRoll.value}
onChange={e => 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)) && (
<span className={`text-xs font-semibold ${parseInt(gmManualRoll.value) <= target ? 'text-emerald-300' : 'text-rose-300'}`}>
{parseInt(gmManualRoll.value) <= target ? 'Success' : 'Fail'}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={submitGmManualRoll}
disabled={!gmManualRoll.playerName || !gmManualRoll.value}
className="rounded bg-blue-700 px-3 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-40"
>
Submit Roll
</button>
<button
onClick={() => setGmManualRoll(null)}
className="rounded bg-slate-700 px-3 py-1 text-xs text-slate-300 hover:bg-slate-600"
>
Cancel
</button>
</div>
</div>
)}
{/* #6 + #8 — expanded options */}
{isExpanded && !isOpen && (
<div className="border-t border-slate-700 px-3 py-2 space-y-2 bg-slate-950/30">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-20">Assign to:</label>
<select
value={checkOpenOptions.assignedTo}
onChange={e => setCheckOpenOptions(prev => ({ ...prev, assignedTo: e.target.value }))}
className="flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs"
>
<option value="">Anyone</option>
{playerList.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={checkOpenOptions.isDecision}
onChange={e => setCheckOpenOptions(prev => ({ ...prev, isDecision: e.target.checked }))}
className="rounded"
/>
Decision (no dice — declare intent)
</label>
<button
onClick={() => openCheckForPlayers(check, { assignedTo: checkOpenOptions.assignedTo, isDecision: checkOpenOptions.isDecision })}
className="w-full rounded bg-emerald-800 px-2 py-1 text-xs text-emerald-200 hover:bg-emerald-700"
>
Open with these options
</button>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Combat */}
<div className="mb-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Combat</div>
<div className={`flex items-center gap-2 rounded border px-3 py-2 ${combatActive ? 'border-red-700/60 bg-red-950/20' : 'border-slate-700 bg-slate-900/60'}`}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{sceneCombat.enemy || 'Custom/None'}</div>
<div className="text-xs text-slate-400">BS {sceneCombat.bs || 45} / WS {sceneCombat.ws || 45} · {sceneCombat.damage || '1d10+5'}</div>
</div>
{/* Reveal toggle */}
<button
onClick={() => 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 ? '👁' : '🔒'}
</button>
<button
onClick={combatActive ? clearPlayPrompt : openCombatForPlayers}
className={`shrink-0 rounded px-2 py-1 text-xs ${combatActive ? 'bg-rose-800 text-rose-200 hover:bg-rose-700' : 'bg-blue-700 text-white hover:bg-blue-600'}`}
>
{combatActive ? 'Close' : 'Open Combat'}
</button>
</div>
</div>
{/* Roll limit */}
<div className="flex items-center justify-between rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm">
<span className="text-slate-300">Player roll limit</span>
<button
onClick={toggleSceneRollsUnlocked}
className={`rounded px-2 py-1 text-xs ${currentScene?.rollsUnlocked ? 'bg-amber-700 hover:bg-amber-600' : 'bg-slate-700 hover:bg-slate-600'}`}
>
{currentScene?.rollsUnlocked ? 'Unlimited (lock)' : '1 per scene (unlock)'}
</button>
</div>
</div>
{/* Table State */}
<div className="rounded-lg border border-slate-700 bg-slate-900 p-5">
<h3 className="mb-3 text-lg font-semibold">Table State</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b border-slate-700 pb-2">
<span className="text-slate-400">GM</span>
<span>{activeMission.gm_player || 'Unassigned'}</span>
</div>
<div className="flex justify-between border-b border-slate-700 pb-2">
<span className="text-slate-400">Scenes complete</span>
<span>{activeMission.scenes.filter(s => s.completed).length} / {activeMission.scenes.length}</span>
</div>
<div className="flex justify-between border-b border-slate-700 pb-2">
<span className="text-slate-400">Enemy pressure</span>
<span>{activeMission.enemy_count || '?'} per scene</span>
</div>
<div className="flex justify-between border-b border-slate-700 pb-2">
<span className="text-slate-400">Extra rewards</span>
<span className="text-right">
{earnedMissionRewards.completed} / {earnedMissionRewards.total} solved
{earnedMissionRewards.renown ? ` · +${earnedMissionRewards.renown} Renown` : ''}
{earnedMissionRewards.reserveReq ? ` · +${earnedMissionRewards.reserveReq} Reserve Req` : ''}
{earnedMissionRewards.xp ? ` · +${earnedMissionRewards.xp} XP` : ''}
</span>
</div>
<div className="flex justify-between border-b border-slate-700 pb-2">
<span className="text-slate-400">Estimated XP</span>
<span className="text-right">
{missionXpEstimate.total}
<span className="ml-1 text-xs text-slate-500">
({missionXpEstimate.checkXp} checks, {missionXpEstimate.sceneXp} scenes, {missionXpEstimate.combatXp} combat)
</span>
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Last update</span>
<span>{activeMission.updated_at ? new Date(activeMission.updated_at).toLocaleString('da-DK') : 'Unknown'}</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Scene Editor — full width, below the two-column grid */}
{isGM && editingScene && sceneEdit && (
<div className="w-full min-w-0 rounded-lg border border-amber-600/40 bg-amber-950/10 p-4 sm:p-5 space-y-5 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-200">Edit Scene — {sceneEdit.title || 'Untitled'}</h3>
<div className="flex gap-2">
<button
onClick={saveSceneEdit}
disabled={saving}
className="rounded bg-amber-700 px-3 py-1.5 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
<button
onClick={() => { setEditingScene(false); setSceneEdit(null); }}
className="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
>
Cancel
</button>
</div>
</div>
{/* Title + Type + Description — 3-col on wide screens */}
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,2fr)]">
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Scene Title</label>
<input
type="text"
value={sceneEdit.title}
onChange={e => 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"
/>
</div>
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Type</label>
<select
value={sceneEdit.type}
onChange={e => 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 => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Mood / Read-aloud <span className="text-slate-500 normal-case">(vist i kursiv til spillerne)</span></label>
<textarea
value={sceneEdit.mood || ''}
onChange={e => seField('mood', e.target.value)}
rows={2}
placeholder="Kortfattet stemningssætter…"
className="w-full rounded border border-amber-800/40 bg-amber-950/10 px-3 py-2 text-sm italic text-amber-100 placeholder-slate-600"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Scene Description / Briefing</label>
<textarea
value={sceneEdit.description}
onChange={e => seField('description', e.target.value)}
rows={4}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Ending Text / Scene Complete Read-aloud</label>
<textarea
value={sceneEdit.endingText || ''}
onChange={e => seField('endingText', e.target.value)}
rows={3}
placeholder="Tekst GM læser op når scenen er færdig…"
className="w-full rounded border border-emerald-700/40 bg-emerald-950/10 px-3 py-2 text-sm text-emerald-100 placeholder-slate-600"
/>
</div>
{/* Checks + Enemies — side by side on wide screens */}
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
{/* Checks */}
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Checks</label>
<button onClick={addCheck} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add</button>
</div>
<div className="space-y-3">
{sceneEdit.checks.map((check, i) => (
<div key={i} className="min-w-0 rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={check.name}
onChange={e => seCheck(i, 'name', e.target.value)}
placeholder="Check 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"
/>
<button onClick={() => removeCheck(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Skill</label>
<input type="text" value={check.skill || ''} onChange={e => seCheck(i, 'skill', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Char</label>
<input type="text" value={check.characteristic || ''} onChange={e => seCheck(i, 'characteristic', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Target</label>
<input type="number" value={check.target || 45} onChange={e => seCheck(i, 'target', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Mod</label>
<input type="number" value={check.modifier || 0} onChange={e => seCheck(i, 'modifier', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Reward (shown after roll)</label>
<input type="text" value={check.reward || ''} onChange={e => seCheck(i, 'reward', e.target.value)} placeholder="What succeeding unlocks…" className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
))}
{sceneEdit.checks.length === 0 && (
<p className="text-xs text-slate-500">No checks — defaults generated from scene type.</p>
)}
</div>
</div>
{/* Enemies */}
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Enemies</label>
<button onClick={addEnemy} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add</button>
</div>
<div className="space-y-3">
{sceneEdit.enemies.map((enemy, i) => (
<div key={i} className="min-w-0 rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={enemy.name || ''}
onChange={e => 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"
/>
<button onClick={() => removeEnemy(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-5">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Wounds</label>
<input type="number" value={enemy.wounds || 10} onChange={e => 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" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">BS</label>
<input type="number" value={enemy.bs || 45} onChange={e => 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" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">WS</label>
<input type="number" value={enemy.ws || 45} onChange={e => 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" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Damage</label>
<input type="text" value={enemy.damage || '1d10+4'} onChange={e => 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" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">AP</label>
<input type="number" value={enemy.ap || 0} onChange={e => 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" />
</div>
</div>
</div>
))}
{sceneEdit.enemies.length === 0 && (
<p className="text-xs text-slate-500">No enemies — defaults generated from scene index.</p>
)}
</div>
</div>
</div>
{/* Objectives + Complications — side by side */}
<div className="grid min-w-0 gap-4 lg:grid-cols-2">
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Objectives (one per line)</label>
<textarea
value={sceneEdit.objectives}
onChange={e => seField('objectives', e.target.value)}
rows={3}
placeholder="Secure the perimeter&#10;Locate the gene-code terminal&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Complications / Pressure (one per line)</label>
<textarea
value={sceneEdit.complications}
onChange={e => seField('complications', e.target.value)}
rows={3}
placeholder="Spore mines trigger on failed stealth&#10;Reinforcements arrive round 3&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
</div>
<button
onClick={saveSceneEdit}
disabled={saving}
className="w-full rounded bg-amber-700 px-3 py-2 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
)}
{/* #1 — GM Roll Feed (full, with delete) */}
{isGM && (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-5">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Roll Feed</h3>
<button onClick={loadRollFeed} className="rounded bg-slate-700 px-3 py-2 text-sm hover:bg-slate-600">
Refresh Rolls
</button>
</div>
{rollFeed.length === 0 ? (
<div className="text-sm text-slate-400">No mission rolls recorded yet.</div>
) : (
<div className="grid gap-2">
{rollFeed.map(roll => <RollFeedRow key={roll.id} roll={roll} showDelete onDelete={deleteRoll} />)}
</div>
)}
</div>
)}
{/* GM Mission Scenes overview */}
{isGM && (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-5">
<h3 className="mb-3 text-lg font-semibold">Mission Scenes</h3>
<div className="grid gap-3">
{activeMission.scenes.map((scene, index) => {
const active = index === activeMission.current_scene;
const enemies = sceneEnemies(scene);
return (
<div
key={scene.id || index}
className={`rounded-lg border p-4 ${active ? 'border-blue-500 bg-blue-950/20' : 'border-slate-700 bg-slate-800/70'}`}
>
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-slate-950 px-2 py-1 text-xs text-slate-300">Scene {index + 1}</span>
{active && <span className="rounded bg-blue-600 px-2 py-1 text-xs">Current</span>}
{scene.completed && <span className="rounded bg-emerald-700 px-2 py-1 text-xs">Complete</span>}
{scene.revealed === false && <span className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-400">Hidden</span>}
</div>
<h4 className="mt-2 text-lg font-semibold">{titleForScene(scene, index)}</h4>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-300">{textForScene(scene) || 'No story text stored.'}</p>
{enemies.length > 0 && (
<p className="mt-2 text-xs text-amber-200">Enemies: {enemies.join(', ')}</p>
)}
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={() => 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
</button>
<button
onClick={() => 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'}
</button>
</div>
</div>
{renderMiniSceneRoller(scene, index, { compact: true })}
</div>
);
})}
</div>
</div>
)}
{/* GM-only full story for read-aloud prep */}
{isGM && (
<div className="rounded-lg border border-amber-600/30 bg-slate-900 p-5">
<div className="mb-4 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-amber-300">GM Only</p>
<h3 className="text-lg font-semibold text-amber-100">Hele historien til oplæsning</h3>
</div>
<span className="rounded bg-slate-950 px-2 py-1 text-xs text-slate-400">
{activeMission.scenes.length} scenes
</span>
</div>
<div className="grid gap-4">
{activeMission.scenes.map((scene, index) => {
const objectives = listForScene(scene, 'objectives');
const complications = listForScene(scene, 'complications');
const challenges = extraChallengesForScene(scene);
return (
<article key={scene.id || index} className="rounded border border-slate-700 bg-slate-950/40 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded bg-amber-900/40 px-2 py-1 text-xs text-amber-200">Scene {index + 1}</span>
<span className="rounded bg-slate-800 px-2 py-1 text-xs text-slate-300">{scene.type || 'scene'}</span>
{index === activeMission.current_scene && <span className="rounded bg-blue-700 px-2 py-1 text-xs text-blue-100">Current</span>}
{scene.completed && <span className="rounded bg-emerald-700 px-2 py-1 text-xs text-emerald-100">Complete</span>}
</div>
<h4 className="text-xl font-semibold text-slate-100">{titleForScene(scene, index)}</h4>
{scene.mood && (
<div className="mt-3 rounded border border-amber-500/20 bg-amber-950/20 px-4 py-3">
<p className="whitespace-pre-wrap text-sm italic leading-relaxed text-amber-100">{scene.mood}</p>
</div>
)}
<p className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-slate-200">
{textForScene(scene) || 'No scene text stored.'}
</p>
{endingTextForScene(scene) && (
<div className="mt-4 rounded border border-emerald-500/20 bg-emerald-950/20 px-4 py-3">
<h5 className="mb-2 text-xs font-semibold uppercase tracking-wide text-emerald-200">Ending Read-aloud</h5>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-emerald-100">{endingTextForScene(scene)}</p>
</div>
)}
{(objectives.length > 0 || complications.length > 0 || challenges.length > 0) && (
<div className="mt-4 grid gap-3 lg:grid-cols-3">
{objectives.length > 0 && (
<div>
<h5 className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-200">Objectives</h5>
<ul className="space-y-1 text-sm text-slate-300">
{objectives.map((objective, objectiveIndex) => <li key={objectiveIndex}>{objective}</li>)}
</ul>
</div>
)}
{complications.length > 0 && (
<div>
<h5 className="mb-2 text-xs font-semibold uppercase tracking-wide text-amber-200">Pressure</h5>
<ul className="space-y-1 text-sm text-slate-300">
{complications.map((complication, complicationIndex) => <li key={complicationIndex}>{complication}</li>)}
</ul>
</div>
)}
{challenges.length > 0 && (
<div>
<h5 className="mb-2 text-xs font-semibold uppercase tracking-wide text-emerald-200">Extra Rewards</h5>
<ul className="space-y-1 text-sm text-slate-300">
{challenges.map((challenge, challengeIndex) => (
<li key={`${challenge.id}-${challengeIndex}`}>
{challenge.text} <span className="text-slate-500">({rewardLabel(challenge)})</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
</article>
);
})}
</div>
</div>
)}
</>
)}
</div>
</section>
);
}