import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import './App.css'; import { normalizeEpisode } from './utils/normalizeEpisode.js'; import { getConfig, getModes, getSnapshot, startRun, streamRun, } from './utils/api.js'; import RoundOverview from './components/RoundOverview.jsx'; import SectorCard from './components/SectorCard.jsx'; import PhasePanel from './components/PhasePanel.jsx'; import RewardCard from './components/RewardCard.jsx'; import TimeSeries from './components/TimeSeries.jsx'; import RunControls from './components/RunControls.jsx'; const SPEEDS = [0.5, 1, 2, 4, 8]; const EMPTY_EPISODE_BASE = { episode_id: '', policy: '', seed: null, rounds: [], summary: null, }; function termPillClass(reason) { if (!reason) return 'neutral'; if (reason.includes('PROSPERITY')) return 'success'; if (reason.includes('MAX_ROUNDS')) return 'neutral'; return 'failure'; } function makeEmptyEpisode(config) { return normalizeEpisode({ ...EMPTY_EPISODE_BASE, config: config || { sectors: [] } }); } export default function App() { const [serverConfig, setServerConfig] = useState(null); const [modes, setModes] = useState([]); const [bootstrapError, setBootstrapError] = useState(null); const [episode, setEpisode] = useState(null); const [runId, setRunId] = useState(null); const [runMeta, setRunMeta] = useState(null); // {policy, mode, seed, max_rounds} const [status, setStatus] = useState('idle'); // idle | connecting | running | done | error const [lastError, setLastError] = useState(null); const [roundIdx, setRoundIdx] = useState(0); const [follow, setFollow] = useState(true); // jump to newest round automatically const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); const [liveRound, setLiveRound] = useState(null); // partial round being built const fileInputRef = useRef(null); const streamRef = useRef(null); const pendingRoundsRef = useRef([]); const liveRoundRef = useRef(null); // mirror for callbacks // ── Bootstrap: backend config + mode list ───────────────────────────── useEffect(() => { let alive = true; Promise.all([getConfig(), getModes()]) .then(([cfg, modesResp]) => { if (!alive) return; setServerConfig(cfg); setModes(modesResp.modes || []); setEpisode(prev => prev || makeEmptyEpisode(cfg)); }) .catch(err => { if (!alive) return; setBootstrapError(err.message || String(err)); }); return () => { alive = false; }; }, []); // ── Episode helpers ─────────────────────────────────────────────────── const rounds = episode?.rounds || []; const hasRounds = rounds.length > 0; const currentSafeIdx = hasRounds ? Math.min(roundIdx, rounds.length - 1) : 0; const current = hasRounds ? rounds[currentSafeIdx] : null; const prev = currentSafeIdx > 0 ? rounds[currentSafeIdx - 1] : null; const sectorMap = useMemo(() => { const list = episode?.config?.sectors || []; return Object.fromEntries(list.map(s => [s.name, s])); }, [episode]); // When following live and viewing the latest position, show the in-progress // round's partial data (debate, proposals, votes streaming in). Once the // full round record arrives, `liveRound` is cleared and the completed // round takes over. const isViewingLatest = follow && hasRounds ? currentSafeIdx === rounds.length - 1 : !hasRounds; const showLiveRound = isViewingLatest && liveRound && status === 'running'; // The "display round" for the PhasePanel: use partial live data when // streaming and the user is at the latest position; otherwise the // completed round record. const phasePanelRound = showLiveRound ? liveRound : current; // ── Auto-follow newest round when streaming ────────────────────────── useEffect(() => { if (!follow || !hasRounds) return; setRoundIdx(rounds.length - 1); }, [rounds.length, follow, hasRounds]); // ── Manual playback (for completed runs) ────────────────────────────── useEffect(() => { if (!playing || !hasRounds) return undefined; if (status === 'running') { // Streaming auto-advances on its own. return undefined; } const interval = 1100 / speed; const id = setInterval(() => { setRoundIdx(idx => { if (idx >= rounds.length - 1) { setPlaying(false); return idx; } return idx + 1; }); }, interval); return () => clearInterval(id); }, [playing, speed, rounds.length, hasRounds, status]); // ── Apply newly-streamed rounds via normalization ───────────────────── const refreshFromRawRounds = useCallback( (rawRoundsList, summary, meta) => { if (!serverConfig) return; const next = normalizeEpisode({ episode_id: meta?.run_id || episode?.episode_id || '', seed: meta?.seed ?? episode?.seed, policy: meta?.policy || episode?.policy || '', config: serverConfig, rounds: rawRoundsList, summary: summary ?? null, }); setEpisode(next); }, [serverConfig, episode], ); // Flush any rounds that arrived while we were waiting for setState batches. const flushPending = useCallback(() => { if (pendingRoundsRef.current.length === 0) return; const all = pendingRoundsRef.current.slice(); refreshFromRawRounds(all, null, runMeta); }, [refreshFromRawRounds, runMeta]); // ── SSE integration ─────────────────────────────────────────────────── const teardownStream = useCallback(() => { if (streamRef.current) { streamRef.current.close(); streamRef.current = null; } }, []); const handleStart = useCallback( async opts => { try { setStatus('connecting'); setLastError(null); teardownStream(); pendingRoundsRef.current = []; const resp = await startRun(opts); setRunId(resp.run_id); setRunMeta({ run_id: resp.run_id, policy: resp.policy, mode: resp.mode, seed: resp.seed, max_rounds: resp.max_rounds, }); setEpisode( normalizeEpisode({ episode_id: resp.run_id, seed: resp.seed, policy: resp.policy, config: resp.config || serverConfig, rounds: [], summary: null, }), ); setRoundIdx(0); setFollow(true); setPlaying(false); const meta = { run_id: resp.run_id, seed: resp.seed, policy: resp.policy, mode: resp.mode, }; const stream = streamRun(resp.run_id, { onStart: () => setStatus('running'), onActivity: data => { if (data.kind === 'round_start') { const partial = { round_num: data.round_num, year: Math.floor((data.round_num - 1) / 4) + 1, quarter: ((data.round_num - 1) % 4) + 1, events: data.events || [], crisis_occurred: data.crisis_occurred || false, treasury_before: data.treasury_before ?? 0, debate: [], proposals: [], votes: [], vote_results: [], proposal_order: [], _phase: 'events', }; liveRoundRef.current = partial; setLiveRound({ ...partial }); } else if (data.kind === 'debate') { const lr = liveRoundRef.current; if (lr) { lr.debate.push({ agent_id: data.agent_id, department: data.department, message: data.message, }); lr._phase = 'debate'; setLiveRound({ ...lr, debate: [...lr.debate] }); } } else if (data.kind === 'proposal') { const lr = liveRoundRef.current; if (lr) { lr.proposals.push(data); lr._phase = 'proposals'; setLiveRound({ ...lr, proposals: [...lr.proposals] }); } } else if (data.kind === 'vote') { const lr = liveRoundRef.current; if (lr) { lr.votes.push(data); lr._phase = 'votes'; setLiveRound({ ...lr, votes: [...lr.votes] }); } } }, onRound: roundData => { liveRoundRef.current = null; setLiveRound(null); pendingRoundsRef.current.push(roundData); refreshFromRawRounds( pendingRoundsRef.current.slice(), null, meta, ); }, onSummary: summaryData => { refreshFromRawRounds( pendingRoundsRef.current.slice(), summaryData, meta, ); }, onError: errData => { setLastError(errData.message || 'Unknown error'); setStatus('error'); }, onDone: () => { liveRoundRef.current = null; setLiveRound(null); setStatus(s => (s === 'error' ? 'error' : 'done')); }, onConnectionError: () => { setStatus(s => s === 'done' || s === 'error' ? s : 'error', ); }, }); streamRef.current = stream; } catch (err) { setLastError(err.message || String(err)); setStatus('error'); } }, [refreshFromRawRounds, serverConfig, teardownStream], ); const handleStop = useCallback(() => { teardownStream(); setStatus(s => (s === 'running' || s === 'connecting' ? 'done' : s)); }, [teardownStream]); // ── On reload while a run was active, fetch its snapshot. ───────────── useEffect(() => { if (!runId || streamRef.current) return undefined; let alive = true; getSnapshot(runId) .then(snap => { if (!alive) return; setEpisode(normalizeEpisode(snap)); setRunMeta({ run_id: snap.episode_id || runId, seed: snap.seed, policy: snap.policy, mode: snap.mode, max_rounds: snap.max_rounds, }); }) .catch(() => {}); return () => { alive = false; }; }, [runId]); useEffect(() => { return () => teardownStream(); }, [teardownStream]); // ── File loading (offline episodes still supported) ─────────────────── function onLoadFile(file) { const reader = new FileReader(); reader.onload = e => { try { const parsed = JSON.parse(e.target.result); if (!parsed.rounds || !Array.isArray(parsed.rounds)) { alert('Invalid episode file: missing "rounds" array'); return; } if (!parsed.config?.sectors) { alert('Invalid episode file: missing "config.sectors"'); return; } teardownStream(); setStatus('idle'); setRunId(null); setRunMeta(null); setEpisode(normalizeEpisode(parsed)); setRoundIdx(0); setFollow(false); } catch (err) { alert(`Failed to parse episode JSON: ${err.message}`); } }; reader.readAsText(file); } function onDrop(e) { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) onLoadFile(file); } // ── Render ──────────────────────────────────────────────────────────── if (bootstrapError) { return (
Parliament RL · Episode Viewer
Backend unreachable.
{bootstrapError}
Start the streaming server with:
python -m scripts.run_visualizer_server
); } const showShell = !!episode; return (
e.preventDefault()} onDrop={onDrop}>
Parliament RL · Episode Viewer
policy {episode?.policy || '—'} mode {runMeta?.mode || '—'} seed {episode?.seed ?? '—'} id {episode?.episode_id || '—'} rounds {rounds.length}
e.target.files?.[0] && onLoadFile(e.target.files[0])} />
{hasRounds ? ( <> Round {current.round_num} /{' '} {rounds.length} Y{current.year} · Q{current.quarter} {showLiveRound && ( · next round in progress )} ) : liveRound ? ( <> Round {liveRound.round_num} in progress ) : ( No rounds yet )}
{ setRoundIdx(Number(e.target.value)); setFollow(false); }} disabled={!hasRounds} />
speed {streamPillLabel(status, current)}
{showShell && (
{hasRounds ? ( <> ) : (

Round summary awaiting first round

Pick a policy on the left and press Start run.
)}

Sectors allocation vs critical · demand · surplus · wastage

{hasRounds ? ( <> {current.is_critical_failure && (
Critical failure:{' '} {current.failing_sectors.length > 0 ? `${current.failing_sectors.join(', ')} dropped below 40% of demand. ` : 'a sector dropped below 40% of demand. '} Treasury was not debited (the round terminates before allocation in Phase 5), so revenues for every sector are zero.
)}
{(episode.config.sectors || []).map(s => ( ))}
) : (
Sectors render here once the first round arrives.
)}
{hasRounds && ( )}

{phasePanelRound ? <>Round {phasePanelRound.round_num} decisions{showLiveRound && LIVE} : 'Decisions'} events → debate → vote

{phasePanelRound ? ( ) : (
Live debate, proposals, and votes will stream here as the parliament deliberates.
)}
)}
); } function streamLabel(status) { switch (status) { case 'connecting': return 'Connecting…'; case 'running': return 'Streaming live'; case 'done': return 'Run complete'; case 'error': return 'Run failed'; default: return 'Idle'; } } function streamPillLabel(status, current) { if (current?.done && current?.termination_reason) { return current.termination_reason; } return streamLabel(status).toLowerCase(); }