fix: combat reveal toggle + eliminate poll flickering

Add 👁/🔒 toggle to combat row in Scene Secrets — players see
combat panel only when GM reveals it (scene.reveal.combat).

Stop flickering: background polls now run silent (no setLoading),
and state updates are skipped when JSON content is unchanged,
preventing unnecessary re-renders every 4s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:06:37 +02:00
parent b3818bd727
commit e1bcf61498

View File

@@ -177,9 +177,8 @@ export default function MissionTab({ authedPlayer }) {
// ─── Data loading ───────────────────────────────────────────────
const loadMissions = useCallback(async function loadMissions() {
setLoading(true);
setError('');
const loadMissions = useCallback(async function loadMissions({ silent = false } = {}) {
if (!silent) { setLoading(true); setError(''); }
try {
if (isGM) {
const [allRes, activeRes] = await Promise.all([
@@ -188,19 +187,18 @@ export default function MissionTab({ authedPlayer }) {
]);
const all = Array.isArray(allRes.data) ? allRes.data.map(normalizeMission) : [];
const active = normalizeMission(activeRes.data);
setMissions(all);
setActiveMission(active);
setSelectedMissionId(active?.id ? String(active.id) : (all[0]?.id ? String(all[0].id) : ''));
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');
setMissions([]);
setActiveMission(normalizeMission(activeRes.data));
setSelectedMissionId('');
const active = normalizeMission(activeRes.data);
setActiveMission(prev => JSON.stringify(prev) !== JSON.stringify(active) ? active : prev);
}
} catch (e) {
setError(`Failed to load missions: ${e.response?.data?.error || e.message}`);
if (!silent) setError(`Failed to load missions: ${e.response?.data?.error || e.message}`);
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
}, [isGM]);
@@ -208,7 +206,8 @@ export default function MissionTab({ authedPlayer }) {
const loadRollFeed = useCallback(async function loadRollFeed() {
try {
const res = await axios.get('/api/missions/active/rolls/feed?limit=50');
setRollFeed(Array.isArray(res.data) ? res.data : []);
const next = Array.isArray(res.data) ? res.data : [];
setRollFeed(prev => JSON.stringify(prev) !== JSON.stringify(next) ? next : prev);
} catch (e) {
// non-blocking
}
@@ -236,7 +235,7 @@ export default function MissionTab({ authedPlayer }) {
useEffect(() => {
if (isGM) return undefined;
const timer = setInterval(() => {
loadMissions();
loadMissions({ silent: true });
loadRollFeed();
}, 4000);
return () => clearInterval(timer);
@@ -291,6 +290,7 @@ export default function MissionTab({ authedPlayer }) {
objectives: r.objectives ?? legacy,
complications: r.complications ?? false, // never auto-revealed
checks: r.checks || {},
combat: r.combat ?? false,
};
}, [currentScene]);
@@ -407,7 +407,7 @@ export default function MissionTab({ authedPlayer }) {
i === index ? { ...scene, ...updates } : scene
);
await saveProgress(index, nextScenes);
await loadMissions();
await loadMissions({ silent: true });
}
async function updateCombatState(updates) {
@@ -1208,10 +1208,10 @@ export default function MissionTab({ authedPlayer }) {
{/* 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 && !combatActive ? (
{!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 is classified.<br />Awaiting GM to initiate combat.</p>
<p className="text-sm text-slate-400">Combat briefing er klassificeret.<br />Afventer GM-ordre.</p>
</div>
) : (
<>
@@ -1255,7 +1255,7 @@ export default function MissionTab({ authedPlayer }) {
</div>
{/* ── Combat state panels (initiative, conditions, action ref, fear) ── */}
{combatActive && (
{(combatActive || (!isGM && sceneReveal.combat)) && (
<div className="mt-4 space-y-4">
{/* Round counter + fear rating */}
@@ -1786,6 +1786,14 @@ export default function MissionTab({ authedPlayer }) {
<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'}`}