3109 lines
164 KiB
React
3109 lines
164 KiB
React
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 Locate the gene-code terminal …"
|
||
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 Reinforcements arrive round 3 …"
|
||
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>
|
||
);
|
||
}
|