/* ========================================================================= CISCO ARENA v2 — App root Two operation modes: • Connected — Session.getGameId() returns a valid ID → real API + polling • Demo — No game ID set → local mock data, local timer, no API calls ========================================================================= */ const { useState: aS, useEffect: aE, useMemo: aM, useRef: aR, useCallback: aC } = React; // --------------------------------------------------------------------------- // Demo-mode player roster (replaces window.PLAYERS which was removed in v2) // --------------------------------------------------------------------------- const DEMO_PLAYERS = [ {tid:"t1", id:"p1", name:"Mira Osei", role:"Captain", isMe:false}, {tid:"t1", id:"p2", name:"Theo Park", role:"Strategist", isMe:true}, {tid:"t1", id:"p3", name:"Ines Calder", role:"Analyst", isMe:false}, {tid:"t1", id:"p4", name:"Dev Ramos", role:"Specialist", isMe:false}, {tid:"t1", id:"p5", name:"Sana Li", role:"Specialist", isMe:false}, {tid:"t1", id:"p6", name:"Omar Fields", role:"Rookie", isMe:false}, {tid:"t2", id:"p7", name:"Kai Brenner", role:"Captain", isMe:false}, {tid:"t2", id:"p8", name:"Alba Rinaldi", role:"Strategist", isMe:false}, {tid:"t2", id:"p9", name:"Noor Jameel", role:"Analyst", isMe:false}, {tid:"t2", id:"p10", name:"Leo Vance", role:"Specialist", isMe:false}, {tid:"t3", id:"p11", name:"Yuki Okafor", role:"Captain", isMe:false}, {tid:"t3", id:"p12", name:"Mathias Roan", role:"Strategist", isMe:false}, {tid:"t3", id:"p13", name:"Zara Quinn", role:"Analyst", isMe:false}, {tid:"t3", id:"p14", name:"Bast Okonkwo", role:"Specialist", isMe:false}, {tid:"t4", id:"p15", name:"Priya Shah", role:"Captain", isMe:false}, {tid:"t4", id:"p16", name:"Rafa Ibáñez", role:"Strategist", isMe:false}, {tid:"t4", id:"p17", name:"Juno Keller", role:"Analyst", isMe:false}, {tid:"t4", id:"p18", name:"Hiro Tanaka", role:"Specialist", isMe:false}, ]; // --------------------------------------------------------------------------- // Tweak defaults pulled from ARENA_CONFIG set in data.js // --------------------------------------------------------------------------- // Ticket normalizer — TriageScreen expects a stable shape, but the three // ticket sources in this repo each use different fields: // - v2 demo TICKET → already has `.log` (array) + desc/customer/priority // - modules/ise.js → `cliOutput` (string) + `symptoms` (array) + // scenario / audiences / phase / audienceFraming / // symptoms / licensingAngle / solution // - legacy data-tickets.js → `symptoms` (string) // Normalize so any source schema renders correctly, and surface the rich // ISE fields under the legacy keys TriageScreen already reads. // --------------------------------------------------------------------------- function normalizeTicket(t) { if (!t) return t; // 1. Build the syslog `.log` array let log; if (Array.isArray(t.log)) { log = t.log; } else if (typeof t.cliOutput === 'string' && t.cliOutput.trim()) { log = t.cliOutput.split('\n'); } else if (Array.isArray(t.symptoms)) { log = t.symptoms; } else if (typeof t.symptoms === 'string' && t.symptoms.trim()) { log = t.symptoms.split('\n'); } else { log = []; } // 2. Pick the first available audienceFraming question (sales / presales / // deployment / ops / etc.) — this is the actual prompt for the player. let framedQuestion = null; if (t.audienceFraming && typeof t.audienceFraming === 'object') { const order = ['sales', 'presales', 'deployment', 'ops', 'operations', 'security']; for (const k of order) { const v = t.audienceFraming[k]; if (v && (v.question || typeof v === 'string')) { framedQuestion = v.question || v; break; } } if (!framedQuestion) { const firstKey = Object.keys(t.audienceFraming)[0]; const v = firstKey ? t.audienceFraming[firstKey] : null; if (v) framedQuestion = v.question || (typeof v === 'string' ? v : null); } } // 3. Symptoms list (preserved separately from `.log` for richer UI). const symptomsList = Array.isArray(t.symptoms) ? t.symptoms : null; return { ...t, log, desc: t.desc || t.scenario || '', customer: t.customer || (Array.isArray(t.audiences) ? t.audiences.join(' · ') : (t.audiences || '')), priority: t.priority || (t.phase ? String(t.phase).toUpperCase() : ''), question: t.question || framedQuestion || '', symptomsList, licensingAngle: t.licensingAngle || '', }; } const cfg = window.ARENA_CONFIG; const TWEAK_DEFAULTS = { theme: cfg.defaultTheme, mode: cfg.defaultMode, density: cfg.defaultDensity, layout: cfg.defaultLayout, celeb: cfg.defaultCeleb, }; const PHASE_MAP = { registration: "join", draft: "lobby", // "playing" means game is underway — don't override V2's internal screen navigation }; // --------------------------------------------------------------------------- // Toast helpers // --------------------------------------------------------------------------- let _toastSeq = 0; function makeToast(head, msg, kind = "") { return { id: ++_toastSeq, head, msg, kind }; } // --------------------------------------------------------------------------- // TickerBar — top strip shown when layout === "ticker" // --------------------------------------------------------------------------- function TickerBar({ theme, leaderboard, timerSec, urgent }) { const activePlayers = leaderboard.filter(p => !p.is_proctor && p.role !== 'proctor'); const teamTotals = theme.teams.map(t => ({ ...t, total: activePlayers.filter(p => p.tid === t.id).reduce((s, p) => s + (p.score || 0), 0), })).sort((a, b) => b.total - a.total); const mm = String(Math.floor(timerSec/60)).padStart(2,"0"); const ss = String(timerSec%60).padStart(2,"0"); return ( ); } // --------------------------------------------------------------------------- // URL param → session bridge: ?game=XXXX sets game, ?reset clears everything // --------------------------------------------------------------------------- (function _initFromURL() { const p = new URLSearchParams(window.location.search); // ?reset — wipe all player state and go back to join screen if (p.has("reset")) { Session.clear(); localStorage.removeItem("arena.screen"); localStorage.removeItem("arena.tweaks"); sessionStorage.removeItem("arena.pendingScreen"); window.history.replaceState({}, "", window.location.pathname); return; // clean slate — app will render join screen } const g = p.get("game"); if (g) { const prior = Session.getGameId(); if (prior && prior !== g) { // Different game in QR than previously stored in this tab — this is a // fresh join (e.g. user scanned a new event QR). Wipe stale identity so // the join form appears instead of silently dropping into the old game. Session.clear(); localStorage.removeItem("arena.screen"); sessionStorage.removeItem("arena.pendingScreen"); } if (!Session.getGameId()) { Session.setGameId(g); } // Observer mode: capture ?email, ?name, ?observer params before URL is cleaned. // Force-clear any stale session identity (prior visits to same game) so the // screen always initialises to "join" and the auto-join effect can fire. const _obsFlag = p.get("observer"); const _obsEmail = p.get("email"); const _obsName = p.get("name"); if (_obsFlag === "1" && _obsEmail) { window._arenaObserverEmail = _obsEmail; window._arenaObserverName = _obsName || "Observer"; // Wipe stale identity — ensures screen === "join" on mount regardless of // what a prior session stored in localStorage / sessionStorage. Session.clear(); localStorage.removeItem("arena.screen"); sessionStorage.removeItem("arena.pendingScreen"); Session.setGameId(g); // re-establish game link after clear } window.history.replaceState({}, "", window.location.pathname); // clean URL } })(); // --------------------------------------------------------------------------- // Language init: read event language from game config and set i18n // --------------------------------------------------------------------------- (function _initLanguage() { const gameId = Session.getGameId(); if (!gameId) return; fetch("/api/games/" + encodeURIComponent(gameId)) .then(function(res) { return res.ok ? res.json() : null; }) .then(function(game) { if (!game) return; const lang = game.language || "en"; const mods = game.modules || []; // Load module-specific translations for modules with English source strings var loadPromises = []; if (lang !== "en") { mods.forEach(function(modId) { if (typeof loadModuleTranslations === "function") { loadPromises.push(loadModuleTranslations(modId, lang)); } }); } Promise.all(loadPromises).then(function() { if (typeof setLanguage === "function" && lang !== getLanguage()) { return setLanguage(lang); } }); }) .catch(function(err) { console.warn("[lang-init] Could not load game language:", err); }); })(); // --------------------------------------------------------------------------- // App root // --------------------------------------------------------------------------- function BreachIncidentScreenHoisted({ gameId, game, me, isCaptain }) { const scenario = window.BREACH_SCENARIO; const gid = gameId || 'demo'; const initialBeat = (() => { try { const manual = parseInt(window.localStorage.getItem('breach_beat_' + gid), 10); if (!isNaN(manual) && scenario?.beats?.length) return Math.max(0, Math.min(scenario.beats.length - 1, manual)); } catch (_) {} return 0; })(); const [beatIndex, setBeatIndex] = aS(initialBeat); const [now, setNow] = aS(Date.now()); const [submitted, setSubmitted] = aS(false); const beatId = scenario?.beats?.[beatIndex]?.id || 'b'; const notesKeyInit = 'breach_notes_' + gid + '_' + (me?.email || 'anon') + '_' + beatId; const [notes, setNotes] = aS(() => { try { return window.localStorage.getItem(notesKeyInit) || ''; } catch (_) { return ''; } }); aE(() => { if (!scenario || !scenario.beats || !scenario.beats.length) return; const startKey = 'breach_start_' + gid; const beatKey = 'breach_beat_' + gid; if (!window.localStorage.getItem(startKey)) window.localStorage.setItem(startKey, String(Date.now())); const sync = () => { const manual = parseInt(window.localStorage.getItem(beatKey), 10); if (!isNaN(manual)) { setBeatIndex(prev => { const next = Math.max(0, Math.min(scenario.beats.length - 1, manual)); return next === prev ? prev : next; }); return; } const start = parseInt(window.localStorage.getItem(startKey), 10) || Date.now(); const elapsed = Math.max(0, Math.floor((Date.now() - start) / 1000)); let acc = 0; let idx = 0; for (let i = 0; i < scenario.beats.length; i += 1) { acc += Number(scenario.beats[i].durationSec || 900); if (elapsed < acc) { idx = i; break; } idx = i; } setBeatIndex(prev => idx === prev ? prev : idx); }; sync(); const iv = setInterval(() => { setNow(Date.now()); sync(); }, 1000); return () => clearInterval(iv); }, [gid, scenario]); // Beat changed → load that beat's saved notes aE(() => { setSubmitted(false); const notesKey = 'breach_notes_' + gid + '_' + (me?.email || 'anon') + '_' + (scenario?.beats?.[beatIndex]?.id || 'b'); try { setNotes(window.localStorage.getItem(notesKey) || ''); } catch (_) { setNotes(''); } }, [beatIndex]); // On mount: backfill any decisions/notes from localStorage that haven't been synced yet aE(() => { try { var keys = Object.keys(window.localStorage); var decisionPrefix = 'breach_decision_' + gid + '_'; var notesPrefix = 'breach_notes_' + gid + '_'; keys.forEach(function(k) { if (k.endsWith('__synced')) return; var syncFlag = k + '__synced'; if (window.localStorage.getItem(syncFlag)) return; if (k.startsWith(decisionPrefix)) { try { var d = JSON.parse(window.localStorage.getItem(k) || 'null'); if (d && d.decision) { fetch('/api/g/' + gid + '/breach-decision', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ team: d.team || '', beat: d.beat || '', captain_email: me?.email || '', captain_name: d.captain || '', decision: d.decision }) }).then(function() { try { window.localStorage.setItem(syncFlag, '1'); } catch (_) {} }).catch(function() {}); } } catch (_) {} } else if (k.startsWith(notesPrefix)) { try { var text = window.localStorage.getItem(k) || ''; // Key pattern: breach_notes___ var rest = k.slice(notesPrefix.length); var lastUnderscore = rest.lastIndexOf('_'); var emailPart = lastUnderscore >= 0 ? rest.slice(0, lastUnderscore) : rest; var beatPart = lastUnderscore >= 0 ? rest.slice(lastUnderscore + 1) : ''; if (text && beatPart) { fetch('/api/g/' + gid + '/breach-notes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: emailPart || 'anon', beat: beatPart, notes: text }) }).then(function() { try { window.localStorage.setItem(syncFlag, '1'); } catch (_) {} }).catch(function() {}); } } catch (_) {} } }); } catch (_) {} }, []); if (!scenario || !scenario.beats || !scenario.beats.length) { return Cargando escenario…; } const beat = scenario.beats[Math.max(0, Math.min(scenario.beats.length - 1, beatIndex))]; const start = parseInt(window.localStorage.getItem('breach_start_' + gid), 10) || now; const elapsedTotal = Math.max(0, Math.floor((now - start) / 1000)); let before = 0; for (let i = 0; i < beatIndex; i += 1) before += Number(scenario.beats[i].durationSec || 900); const beatElapsed = Math.max(0, elapsedTotal - before); const remaining = Math.max(0, Number(beat.durationSec || 900) - beatElapsed); const mm = String(Math.floor(remaining / 60)).padStart(2, '0'); const ss = String(remaining % 60).padStart(2, '0'); const decisionKey = 'breach_decision_' + gid + '_' + (me?.tid || 'team') + '_' + beat.id; const notesKey = 'breach_notes_' + gid + '_' + (me?.email || 'anon') + '_' + beat.id; const submitDecision = (e) => { e.preventDefault(); if (!isCaptain || !notes.trim()) return; window.localStorage.setItem(decisionKey, JSON.stringify({ beat: beat.id, team: me?.tid || '', captain: me?.name || '', decision: notes.trim(), at: new Date().toISOString() })); setSubmitted(true); // Sync to server (fire-and-forget) try { fetch('/api/g/' + gid + '/breach-decision', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ team: me?.tid || '', beat: beat.id, captain_email: me?.email || '', captain_name: me?.name || '', decision: notes.trim() }) }).catch(function() {}); } catch (_) {} }; const _notesDebounceRef = React.useRef(null); const saveNotes = (val) => { setNotes(val); try { window.localStorage.setItem(notesKey, val); } catch (_) {} // Debounce-sync notes to server (1500ms) if (_notesDebounceRef.current) clearTimeout(_notesDebounceRef.current); _notesDebounceRef.current = setTimeout(function() { try { fetch('/api/g/' + gid + '/breach-notes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: me?.email || 'anon', beat: beat.id, notes: val }) }).catch(function() {}); } catch (_) {} }, 1500); }; const advanceBeat = (delta) => { const next = Math.max(0, Math.min(scenario.beats.length - 1, beatIndex + delta)); window.localStorage.setItem('breach_beat_' + gid, String(next)); setBeatIndex(next); }; return ( {scenario.victim.logo} {scenario.victim.name} {mm}:{ss} {scenario.beats.map((item, idx) => )} {beat.kicker} · Beat {beatIndex + 1}/{scenario.beats.length} {beat.headline} {beat.narrative} Discutan en equipo: {beat.teamPrompt || 'Analicen el escenario y decidan qué harían como partner.'} {tc('Opciones sobre la mesa · inspiración para el debate')} {(beat.decisions || beat.choices || []).map((raw, i) => { // Back-compat: support either string choices or {label, hint} decision objects const label = (raw && typeof raw === 'object') ? (raw.label || raw.text || '') : String(raw || ''); const hint = (raw && typeof raw === 'object') ? (raw.hint || '') : ''; const onPick = () => { if (!submitted) saveNotes((notes ? notes + '\n' : '') + '• ' + label + (hint ? ' — ' + hint : '')); }; return ( {String.fromCharCode(65 + i)} {tc(label)} {hint ? {tc(hint)} : null} ); })} {isCaptain ? 'Decisión del equipo · escribe aquí lo que envías' : 'Notas del taller · ayuda a tu capitán a decidir'} saveNotes(e.target.value)} disabled={submitted} /> {isCaptain ? ( {submitted ? '✓ Decisión enviada' : 'Enviar decisión del equipo'} ) : null} {isCaptain ? null : El capitán envía la decisión final del equipo.} advanceBeat(-1)} disabled={beatIndex === 0}>◀ Beat anterior advanceBeat(1)} disabled={beatIndex >= scenario.beats.length - 1}>Siguiente beat ▶ ); } function App() { // ── Mode detection ──────────────────────────────────────────────────────── const connected = !!Session.getGameId(); // ── Persistent UI state ─────────────────────────────────────────────────── const [screen, setScreen] = aS(() => { // Check for a pending screen redirect (set before reload during game-code join) const pending = sessionStorage.getItem("arena.pendingScreen"); if (pending) { sessionStorage.removeItem("arena.pendingScreen"); localStorage.setItem("arena.screen", pending); return pending; } // Only restore last screen if connected (has a game ID in this tab). // Without this guard, a new tab inherits the stale localStorage screen // and lands in demo mode with timers already running. if (connected) { // If we have a game but no registered email yet (e.g. a QR scan with // no prior check-in in this tab), force the join screen so the player // can enter their name + email instead of silently landing in lobby // with `arena.screen` carried over from a different prior visit. if (!Session.getEmail()) { return "join"; } return localStorage.getItem("arena.screen") || "join"; } return "join"; }); const [tweaks, setTweaks] = aS(() => { try { const saved = JSON.parse(localStorage.getItem("arena.tweaks") || "null"); return { ...TWEAK_DEFAULTS, ...(saved || {}) }; } catch { return TWEAK_DEFAULTS; } }); const [tweakOpen, setTweakOpen] = aS(false); const [editMode, setEditMode] = aS(false); // ── Observer mode ───────────────────────────────────────────────────────── const [isObserver, setIsObserver] = aS(false); // ── Leaderboard ─────────────────────────────────────────────────────────── const [leaderboard, setLeaderboard] = aS(() => connected ? [] : DEMO_PLAYERS.map(p => ({ ...p, score: Math.floor(380 + Math.random() * 640) })) ); // ── Player / score ──────────────────────────────────────────────────────── const [connectedPlayer, setConnectedPlayer] = aS(null); const [prevScore, setPrevScore] = aS(connected ? 0 : 900); const [myScore, setMyScore] = aS(connected ? 0 : 900); const [streak, setStreak] = aS(connected ? 0 : 2); // ── Quiz state ──────────────────────────────────────────────────────────── const [questions, setQuestions] = aS(() => window.QUESTIONS); // ── Tech-Day Q&A state ──────────────────────────────────────────────────── // Polled separately from game phase. When questions_open + active_agenda_id, // the player UI is overridden with TechDayQuizScreen regardless of game phase. const [tdState, setTdState] = aS(null); const [qIdx, setQIdx] = aS(0); const serverQIdxRef = aR(0); // server-derived quiz progress length — authoritative across sessions const [selected, setSelected] = aS(null); const [locked, setLocked] = aS(false); const [gradedOutcome, setGradedOutcome] = aS(null); // 'correct' | 'wrong' | null — authoritative answer outcome // ── Power-ups ───────────────────────────────────────────────────────────── const [powerupsSpent, setPowerupsSpent] = aS([]); // ── Celebration / toasts ────────────────────────────────────────────────── const [celebration, setCelebration] = aS(null); const [toasts, setToasts] = aS([]); // ── Language reactivity ──────────────────────────────────────────────────── // Track i18n language in React state so tc() re-evaluates on language change const [_lang, _setLang] = aS(() => typeof getLanguage === "function" ? getLanguage() : "es"); aE(() => { if (typeof onLanguageChange === "function") { return onLanguageChange(function(newLang) { _setLang(newLang); }); } }, []); // ── API health ──────────────────────────────────────────────────────────── const [apiOk, setApiOk] = aS(true); // ── Triage (multi-ticket) ──────────────────────────────────────────────── const [tickets, setTickets] = aS([]); // assigned tickets array const [ticketIdx, setTicketIdx] = aS(0); // current ticket index const [graded, setGraded] = aS(false); const [gradeMsg, setGradeMsg] = aS(null); const [grading, setGrading] = aS(false); // API call in progress const ticket = tickets[ticketIdx] || window.TICKET; // current ticket (fallback) // ── Phase transition target ────────────────────────────────────────────── const [transitionTarget, setTransitionTarget] = aS("triage"); // ── Active game module (ISE, SDWAN, etc.) ──────────────────────────────── // gameModuleIds is populated from the server's game config (game.modules) by // the game-init effect below. The memo resolves it against window.GAME_MODULES // so it stays reactive: as soon as either side updates we re-evaluate. This // avoids the bug where gameModule stayed null on stale sessions and the // GamePlan screen rendered an empty "no briefs" state ("black screen"). const [gameModuleIds, setGameModuleIds] = aS([]); const gameModule = aM(() => { if (!connected) return null; const REG = window.GAME_MODULES; if (!REG) return null; // Prefer modules declared by the game config for (const id of gameModuleIds) { if (REG[id]) return REG[id]; } // Fallback: if registry has exactly one module, use it const allIds = Object.keys(REG); if (allIds.length === 1) return REG[allIds[0]]; // Last resort: 'ise' if present, else first available return REG['ise'] || (allIds.length > 0 ? REG[allIds[0]] : null); }, [connected, gameModuleIds]); // ── Draft state (connected mode) ────────────────────────────────────────── const [draftStatus, setDraftStatus] = aS(null); const [draftTeams, setDraftTeams] = aS({}); const [draftUndrafted, setDraftUndrafted] = aS([]); const [serverPhase, setServerPhase] = aS(null); const [gameConfig, setGameConfig] = aS(null); // ── Timer ───────────────────────────────────────────────────────────────── const totalSec = cfg.timerQuizSec; // 180 const [timerSec, setTimerSec] = aS(totalSec); const timerRef = aR(null); // ── Load triage tickets when entering triage screen ──────────────────── aE(() => { if (screen !== "triage" && screen !== "phaseTransition") return; if (tickets.length > 0) return; // already loaded if (!connected) { // Demo mode: use fallback ticket setTickets([window.TICKET]); return; } const email = Session.getEmail(); if (!email) return; // Get module from game config const gameId = Session.getGameId(); fetch("/api/games/" + encodeURIComponent(gameId)) .then(r => r.ok ? r.json() : null) .then(game => { if (!game) return; const modId = (game.modules || ["ise"])[0]; const mod = window.GAME_MODULES && window.GAME_MODULES[modId]; if (!mod || !mod.data || !mod.data.tickets) { setTickets([window.TICKET]); return; } const allTickets = mod.data.tickets; const ticketCount = game.ticket_count || 1; // Fetch deterministic assignment from server Api.getTicketsAssignment(email, modId, allTickets.length, ticketCount) .then(result => { const indices = result.indices || []; // Normalize each ticket so every render path sees a consistent // shape (see normalizeTicket() at top of file). const assigned = indices.map(i => allTickets[i]).filter(Boolean).map(normalizeTicket); if (assigned.length > 0) { setTickets(assigned); setTicketIdx(0); } else { setTickets([window.TICKET]); } }) .catch(() => { setTickets([window.TICKET]); }); }) .catch(() => { setTickets([window.TICKET]); }); }, [screen, connected, tickets.length]); // ── Derived ─────────────────────────────────────────────────────────────── const theme = aM(() => window.THEMES.find(t => t.id === tweaks.theme) || window.THEMES[0], [tweaks.theme]); const me = aM(() => { if (connected && connectedPlayer) { return { id: connectedPlayer.email, tid: connectedPlayer.team, name: connectedPlayer.name, role: connectedPlayer.isCaptain ? "Captain" : "", isMe: true, }; } return DEMO_PLAYERS.find(p => p.isMe) || DEMO_PLAYERS[0]; }, [connected, connectedPlayer]); const team = aM(() => theme.teams.find(t => t.id === me.tid) || theme.teams[0], [theme, me]); // ── Draft computed ──────────────────────────────────────────────────────── const isCaptain = aM(() => { if (!connected || !connectedPlayer) return false; return !!connectedPlayer.isCaptain; }, [connected, connectedPlayer]); const isMyDraftTurn = aM(() => { if (!isCaptain || !draftStatus || !draftStatus.active) return false; const email = Session.getEmail(); const order = draftStatus.captain_order || []; const idx = draftStatus.current_pick_index != null ? draftStatus.current_pick_index : 0; const round = draftStatus.round || 1; const maxManual = draftStatus.max_manual_rounds || 2; if (round > maxManual) return false; const n = order.length; if (n === 0) return false; const captainIdx = round % 2 === 1 ? (idx % n) : ((n - 1) - (idx % n)); return order[captainIdx] === email; }, [isCaptain, draftStatus]); const handleDraftPick = aC((playerEmail) => { const captainEmail = Session.getEmail(); Api.draftPick(captainEmail, playerEmail) .then(() => { addToast(tc("Drafted!"), tc("Player added to your team"), "success"); }) .catch(err => { addToast(tc("Pick failed"), err.message || tc("Try again"), "error"); }); }, []); // ── Sync leaderboard with current me.score ──────────────────────────────── aE(() => { setLeaderboard(lb => lb.map(p => p.id === me.id ? { ...p, score: myScore } : p) ); }, [myScore]); // ── Hydrate connected player state on refresh ───────────────────────────── aE(() => { if (!connected) return; const email = Session.getEmail(); if (!email) return; Api.getLeaderboard().then(res => { const players = res?.data?.leaderboard || res?.data || []; const me = players.find(p => p.email === email); if (me) { setMyScore(me.score || 0); setStreak(me.streak || 0); setConnectedPlayer({ name: me.name, email: me.email, team: me.team, isCaptain: !!me.isCaptain }); // Seed serverQIdxRef so ScreenNav and game-init can both use it const sqIdx = (me.quizProgress || []).length; if (sqIdx > serverQIdxRef.current) serverQIdxRef.current = sqIdx; } }).catch(() => {}); }, []); // ── Apply game config (theme, modules, language) on connected mount ─────── aE(() => { if (!connected) return; const gameId = Session.getGameId(); if (!gameId) return; fetch("/api/games/" + encodeURIComponent(gameId)) .then(r => r.ok ? r.json() : null) .then(game => { if (!game) return; setGameConfig(game); console.log("[game-init] Game config:", game.theme_id, game.modules, game.language); // Stash phase content limits globally so GamePlanScreen / ZonaMixtaScreen // can slice briefs[] / curveballs[] without prop plumbing. 0 = no cap. window.__GAME_CONFIG__ = window.__GAME_CONFIG__ || {}; window.__GAME_CONFIG__.gameplan_count = game.gameplan_count || 0; window.__GAME_CONFIG__.zonamixta_count = game.zonamixta_count || 0; // Theme: map server theme_id to client theme id const themeMap = { 'f1': 'gp', 'world-cup': 'cup', 'nfl': 'nfl', 'realm': 'realm' }; const clientTheme = themeMap[game.theme_id] || game.theme_id || 'cisco'; setTweak('theme', clientTheme); const mods = game.modules || []; const lang = game.language || "en"; // Publish module IDs so the gameModule memo can resolve reactively. // Without this, GamePlanScreen renders an empty "no briefs" state when // organizer clicks "Advance to Game Plan" on a fresh session. setGameModuleIds(mods); // ── Step 1: Load translations + set language FIRST ────────────── let langReady; if (lang !== "en" && mods.length > 0) { const loadPromises = mods.map(modId => { if (typeof loadModuleTranslations === "function") { return loadModuleTranslations(modId, lang); } return Promise.resolve(); }); langReady = Promise.all(loadPromises).then(() => { if (typeof setLanguage === "function" && lang !== getLanguage()) { return setLanguage(lang).then(() => { console.log("[game-init] Language set to", lang, "currentLang:", getLanguage()); }); } }); } else { langReady = Promise.resolve(); } // ── Step 2: AFTER language is ready, load questions ───────────── // (module getters call tc() which now uses the correct language) langReady.then(async () => { if (mods.length > 0 && window.GAME_MODULES) { let allQuestions = []; mods.forEach(modId => { const mod = window.GAME_MODULES[modId]; if (mod && mod.data && mod.data.quiz) { allQuestions = allQuestions.concat(mod.data.quiz); } }); if (allQuestions.length > 0) { // Filter to question types that have renderers or are classic format const RENDERER_TYPES = window.RENDERERS ? new Set(Object.keys(window.RENDERERS)) : new Set(); const playable = allQuestions.filter(q => { // Only include typed questions with a matching renderer. // Bare-legacy questions (firewall/sdwan/secure-access old schema with // q.q + options:string[]) render blank in screens.jsx → exclude. if (!q.type) return false; return RENDERER_TYPES.has(q.type); }); console.log("[game-init] Loaded", playable.length, "playable questions from modules:", mods, "(raw:", allQuestions.length, ")"); if (playable.length > 0) { // Seeded PRNG (mulberry32) — same game_id = same questions for all players const seedHash = (function(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; } return h; })(gameId); function seededShuffle(arr, seed) { let t = seed; function rand() { t = (t + 0x6D2B79F5) | 0; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } const sh = arr.slice(); for (let i = sh.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); const tmp = sh[i]; sh[i] = sh[j]; sh[j] = tmp; } return sh; } // Separate tech vs fun questions, filter fun by game theme const techPool = playable.filter(q => q.source !== 'fun'); const funThemeMap = { gp: 'gp', cup: 'wc', nfl: 'nfl', realm: 'realm', cisco: 'cisco' }; const funPrefix = funThemeMap[clientTheme] || 'cisco'; const funPool = playable.filter(q => q.source === 'fun' && q.id && q.id.indexOf('-fun-' + funPrefix + '-') !== -1); // Config: quiz_count (tech) + fun_count from server, with defaults const quizCount = game.quiz_count || 10; const funCount = game.fun_count || 3; // Seeded-shuffle each pool, take requested count const techPick = seededShuffle(techPool, seedHash).slice(0, quizCount); const funPick = seededShuffle(funPool, seedHash + 1).slice(0, funCount); // Interleave: insert fun questions every ~4th position const final = []; let fi = 0; for (let i = 0; i < techPick.length; i++) { final.push(techPick[i]); // After every 3rd tech question, insert a fun question if ((i + 1) % 3 === 0 && fi < funPick.length) { final.push(funPick[fi++]); } } // Append any remaining fun questions at the end while (fi < funPick.length) { final.push(funPick[fi++]); } console.log("[game-init] Quiz sampled:", final.length, "questions (", techPick.length, "tech +", funPick.length, "fun ) from pool of", playable.length); setQuestions(final); // Restore progress: server is authoritative; sessionStorage acts as tie-breaker // for ultra-fresh local state that hasn't reached the server yet. const savedIdx = parseInt(sessionStorage.getItem('arena_qIdx_' + gameId), 10); const sessionIdx = (!isNaN(savedIdx) && savedIdx >= 0 && savedIdx < final.length) ? savedIdx : 0; let restoredIdx = sessionIdx; const playerEmail = Session.getEmail(); if (playerEmail) { try { const playerData = await Api.getPlayer(playerEmail); if (playerData) { const serverQIdx = (playerData.quizProgress || []).length; if (serverQIdx > serverQIdxRef.current) serverQIdxRef.current = serverQIdx; if (serverQIdx > 0) { restoredIdx = Math.min(Math.max(serverQIdx, sessionIdx), final.length - 1); // Reseed the duplicate-guard answered-index set from server progress so // the anti-cheat dedupe works correctly after an AFK / cross-browser rejoin const answeredKey = 'arena_ans_' + gameId; const qIdToIdx = {}; final.forEach((q, i) => { qIdToIdx[q.id || q.tag || `q${i}`] = i; }); const serverAnsweredIdxs = (playerData.quizProgress || []) .map(e => qIdToIdx[e.qId]) .filter(i => i !== undefined && i >= 0); const existingAns = JSON.parse(sessionStorage.getItem(answeredKey) || '[]'); const merged = Array.from(new Set([...existingAns, ...serverAnsweredIdxs])); sessionStorage.setItem(answeredKey, JSON.stringify(merged)); // Sync score/streak from server on reconnect setMyScore(playerData.score || 0); setStreak(playerData.streak || 0); } } } catch (err) { console.warn("[game-init] Could not fetch player progress:", err.message); } } setQIdx(restoredIdx); if (restoredIdx > 0) { console.log("[game-init] Restored quiz progress to Q" + (restoredIdx + 1) + " (server=" + serverQIdxRef.current + " entries, session=" + sessionIdx + ")"); } } } } }); }).catch(err => console.warn("[game-init] Failed:", err)); }, []); // Clinic breach fallback: some endpoints may omit event_type from /api/games. // Pull /api/g/:id once, cache in gameConfig, and keep triage optimistic while loading. aE(() => { if (!connected || !gameConfig || gameConfig.event_type) return; const gameId = Session.getGameId(); if (!gameId) return; fetch('/api/g/' + encodeURIComponent(gameId)) .then(r => r.ok ? r.json() : null) .then(game => { if (game && game.event_type) setGameConfig(prev => ({ ...(prev || {}), ...game })); }) .catch(() => {}); }, [connected, gameConfig]); // ── Persist screen & tweaks ─────────────────────────────────────────────── aE(() => { localStorage.setItem("arena.screen", screen); }, [screen]); // ── Move focus to stage on screen transition (a11y) ─────────────────────── aE(() => { const stage = document.getElementById('main-stage'); if (stage) { stage.focus({ preventScroll: false }); } }, [screen]); aE(() => { const { triggerCeleb: _tc, triggerToast: _tt, ...toSave } = tweaks; localStorage.setItem("arena.tweaks", JSON.stringify(toSave)); document.documentElement.setAttribute("data-theme", tweaks.theme); document.documentElement.setAttribute("data-mode", tweaks.mode); document.documentElement.setAttribute("data-density", tweaks.density); document.documentElement.setAttribute("data-layout", tweaks.layout); }, [tweaks]); // ── Load theme display fonts ────────────────────────────────────────────── aE(() => { const fonts = { cisco: "Inter+Tight:wght@600;700;800", cup: "Oswald:wght@500;700", gp: "Rajdhani:wght@500;600;700", nfl: "Anton", realm: "Cinzel:wght@600;700", }; const id = "theme-font"; let link = document.getElementById(id); if (!link) { link = document.createElement("link"); link.id = id; link.rel = "stylesheet"; document.head.appendChild(link); } link.href = `https://fonts.googleapis.com/css2?family=${fonts[tweaks.theme]}&family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap`; }, [tweaks.theme]); // ── Tweak demo triggers ─────────────────────────────────────────────────── aE(() => { if (tweaks.triggerCeleb) setCelebration(Date.now()); }, [tweaks.triggerCeleb]); aE(() => { if (tweaks.triggerToast) { addToast(tc("Score update"), "+120 pts for Q03", "success"); } }, [tweaks.triggerToast]); // ── Timer logic ─────────────────────────────────────────────────────────── const startTimer = (initialSec) => { stopTimer(); setTimerSec(initialSec ?? totalSec); timerRef.current = setInterval(() => { setTimerSec(s => { if (s <= 1) { stopTimer(); return 0; } return s - 1; }); }, 1000); }; const stopTimer = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; aE(() => () => stopTimer(), []); // ── Screen ref for stale-closure-safe comparisons ──────────────────────── const screenRef = aR(screen); aE(() => { screenRef.current = screen; }, [screen]); // ── Connected mode: leaderboard polling ─────────────────────────────────── aE(() => { if (!connected) return; GameStore.startPolling("leaderboard", () => Api.getLeaderboard().then(r => { setApiOk(true); return r; }).catch(e => { setApiOk(false); throw e; }), cfg.pollIntervalMs); const unsub = GameStore.subscribe("leaderboard", (data) => { if (data && Array.isArray(data)) { const myEmail = Session.getEmail(); const normalized = data.map((p, i) => ({ id: p.email, tid: p.team, name: p.name, role: p.isCaptain ? "Captain" : "", score: p.score, climbing: false, isMe: p.email === myEmail, })); setLeaderboard(normalized); const meLive = data.find(p => p.email === myEmail); if (meLive) { setConnectedPlayer(prev => ({ ...(prev || {}), name: meLive.name, email: meLive.email, team: meLive.team, isCaptain: !!meLive.isCaptain, })); } } }); return () => { GameStore.stopPolling("leaderboard"); unsub && unsub(); }; }, [connected]); // ── Connected mode: phase polling ───────────────────────────────────────── // Only react to server phase CHANGES — don't continuously override local nav. const lastServerPhase = aR(null); aE(() => { if (!connected) return; GameStore.startPolling("phase", () => Api.getGamePhase().then(r => { setApiOk(true); return r; }).catch(e => { setApiOk(false); throw e; }), cfg.pollIntervalMs); const unsub = GameStore.subscribe("phase", (data) => { if (data && data.phase) { setServerPhase(data.phase); // Skip if server phase hasn't changed since last poll if (data.phase === lastServerPhase.current) return; const isFirstPoll = lastServerPhase.current === null; lastServerPhase.current = data.phase; const mapped = PHASE_MAP[data.phase]; // "playing" = game underway → advance to quiz from any pre-game screen. // Do NOT override screens that are already inside the playing phase // (gameplan, zonamixta, phaseTransition, wrap, triage, quiz) — those are // managed by the sub-phase poller and handleJoin routing. if (mapped === undefined && data.phase === "playing") { const cur = screenRef.current; const IN_GAME = new Set(["quiz","triage","gameplan","zonamixta","phaseTransition","wrap"]); if (!IN_GAME.has(cur)) setScreen("quiz"); return; } const target = mapped || data.phase; // First poll: only transition FORWARD (e.g. draft→lobby), never backward to "join" if (isFirstPoll && target === "join") return; if (target !== screenRef.current) setScreen(target); } }); return () => { GameStore.stopPolling("phase"); unsub && unsub(); }; }, [connected]); // ── Sub-phase polling — proctor can force-navigate all players ──────────── // Admin is authoritative: when sub_phase changes, all players navigate to // that screen regardless of where they currently are (forward OR backward). // This lets the proctor pull players back from gameplan/zonamixta to quiz. aE(() => { if (!connected) return; const gameId = Session.getGameId(); if (!gameId) return; const SUB_PHASE_SCREEN = { quiz: "quiz", triage: "triage", gameplan: "gameplan", zonamixta: "zonamixta", wrap: "wrap" }; const lastSubPhase = { current: null }; const pollId = setInterval(() => { fetch(`/api/g/${gameId}/sub-phase`) .then(r => r.ok ? r.json() : null) .then(data => { if (!data || !data.sub_phase) return; const target = SUB_PHASE_SCREEN[data.sub_phase]; if (!target) return; // Only act when sub_phase CHANGES — avoid yanking the player every 3s if (data.sub_phase === lastSubPhase.current) return; lastSubPhase.current = data.sub_phase; if (target === screenRef.current) return; if (target === "triage") { setGraded(false); setGradeMsg(null); setGrading(false); } setScreen(target); }) .catch(() => {}); }, 3000); return () => clearInterval(pollId); }, [connected]); // ── Tech-Day Q&A state polling ──────────────────────────────────────────── // Polls /tech-day/state every 3s. When questions_open && active_agenda_id, // the player UI swaps to TechDayQuizScreen. aE(() => { if (!connected) return; let cancelled = false; const tick = () => { Api.getTechDayState() .then(s => { if (!cancelled) setTdState(s); }) .catch(() => {}); }; tick(); const iv = setInterval(tick, 3000); return () => { cancelled = true; clearInterval(iv); }; }, [connected]); // ── Connected mode: draft polling ───────────────────────────────────────── aE(() => { if (!connected) return; const phase = serverPhase || lastServerPhase.current; if (phase !== "draft") { setDraftStatus(null); return; } const fetchDraft = () => { Promise.all([ Api.getDraftStatus().catch(() => null), Api.getTeams().catch(() => ({})), Api.getUndrafted().catch(() => []), ]).then(([status, teams, undrafted]) => { setDraftStatus(status); setDraftTeams(teams || {}); setDraftUndrafted(Array.isArray(undrafted) ? undrafted : []); }); }; fetchDraft(); const iv = setInterval(fetchDraft, 1500); return () => clearInterval(iv); }, [connected, serverPhase, lastServerPhase.current]); // ── Connected mode: timer polling ───────────────────────────────────────── aE(() => { if (!connected) return; GameStore.startPolling("timer", () => Api.getTimerStatus().then(r => { setApiOk(true); return r; }).catch(e => { setApiOk(false); throw e; }), cfg.pollIntervalMs); const unsub = GameStore.subscribe("timer", (data) => { if (data && typeof data === "object") { // Prefer "quiz" timer, fall back to first available const phase = data.quiz || data[Object.keys(data)[0]]; if (phase && typeof phase.remaining === "number") { setTimerSec(phase.remaining); } } }); return () => { GameStore.stopPolling("timer"); unsub && unsub(); }; }, [connected]); // ── Demo mode: leaderboard wobble ───────────────────────────────────────── aE(() => { if (connected) return; if (screen !== "quiz" && screen !== "triage") return; const t = setInterval(() => { setLeaderboard(lb => lb.map(p => p.id === me.id ? p : { ...p, score: Math.max(0, p.score + Math.floor((Math.random() - 0.4) * 60)), climbing: Math.random() > 0.6, }) ); }, 4000); return () => clearInterval(t); }, [connected, screen]); // ── Demo mode: timer tick (starts when quiz or triage begins) ───────────── aE(() => { if (connected) return; // server drives timer in connected mode if (screen === "quiz" || screen === "triage") { startTimer(screen === "triage" ? cfg.timerTriageSec : totalSec); } else { stopTimer(); } }, [connected, screen]); // ── Toast helper ────────────────────────────────────────────────────────── const addToast = (head, msg, kind = "") => { const t = makeToast(head, msg, kind); setToasts(prev => [...prev.slice(-3), t]); setTimeout(() => setToasts(prev => prev.filter(x => x.id !== t.id)), 3500); }; // ── Edit-mode postMessage wiring (Claude Design integration) ────────────── aE(() => { const handler = (e) => { if (!e.data || typeof e.data !== "object") return; if (e.data.type === "ARENA_EDIT_MODE") setEditMode(!!e.data.value); if (e.data.type === "ARENA_SET_SCREEN" && e.data.screen) setScreen(e.data.screen); if (e.data.type === "ARENA_SET_TWEAK" && e.data.key) { setTweaks(t => ({ ...t, [e.data.key]: e.data.value })); } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, []); // ── Tweak setter ────────────────────────────────────────────────────────── const setTweak = (key, val) => setTweaks(t => ({ ...t, [key]: val })); // ── Join handler ────────────────────────────────────────────────────────── const handleJoin = async (name, email, teamId, code) => { if (!name.trim()) { addToast(tc("Name required"), tc("Enter your display name"), "danger"); return; } if (!email.trim()) { addToast(tc("Email required"), tc("Enter your email"), "danger"); return; } // ── Path A: Already in connected mode — register directly ── if (connected) { try { await Api.register(name, email, teamId, false, false); Session.setEmail(email); try { const player = await Api.getPlayer(email); setConnectedPlayer(player); } catch (_) { /* Player fetch optional */ } addToast(tc("Checked in"), `Welcome, ${name}!`, "success"); } catch (err) { addToast(tc("Check-in failed"), err.message || "API error", "danger"); return; } // Clear stale quiz progress for this new player session const gameId = Session.getGameId(); if (gameId) { sessionStorage.removeItem('arena_qIdx_' + gameId); sessionStorage.removeItem('arena_ans_' + gameId); } setQIdx(0); setMyScore(0); setStreak(0); // Route to correct screen based on current game phase + sub-phase. // sub_phase wins over the macro phase: if the proctor has advanced the // game to gameplan/triage/zonamixta/wrap, the new player must land // there directly instead of starting at quiz and waiting 3s for the // sub-phase poll to push them forward. try { const gameId2 = Session.getGameId(); const [phaseData, subData] = await Promise.all([ Api.getGamePhase().catch(() => null), gameId2 ? fetch(`/api/g/${gameId2}/sub-phase`).then(r => r.ok ? r.json() : null).catch(() => null) : Promise.resolve(null), ]); const SUB_TO_SCREEN = { quiz: "quiz", triage: "triage", gameplan: "gameplan", zonamixta: "zonamixta", wrap: "wrap" }; const subTarget = subData && subData.sub_phase && SUB_TO_SCREEN[subData.sub_phase]; if (subTarget) { setScreen(subTarget); } else { const p = phaseData && phaseData.phase; if (p === "playing") { setScreen("quiz"); } else { setScreen("lobby"); } } } catch (_) { setScreen("lobby"); } return; } // ── Path B: Code entered — resolve join code, register, reload ── if (code && code.trim()) { const gameCode = code.trim().toUpperCase(); try { let gameId = null; // Try resolving as join_code first try { const joinRes = await fetch("/api/events/join", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: gameCode }), }); if (joinRes.ok) { const joinData = await joinRes.json(); gameId = joinData.game_id; console.log("[join] Resolved join code", gameCode, "→ game_id", gameId); } } catch (_) { /* join endpoint failed, try direct */ } // Fall back to treating code as game_id directly if (!gameId) { const res = await fetch("/api/games/" + encodeURIComponent(gameCode)); if (!res.ok) throw new Error("not found"); gameId = gameCode; console.log("[join] Using code as direct game_id:", gameId); } // Valid game — register + switch to connected mode Session.setGameId(gameId); Session.setEmail(email); await fetch("/api/g/" + encodeURIComponent(gameId) + "/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, email, team: teamId }), }); sessionStorage.setItem("arena.pendingScreen", "lobby"); window.location.reload(); return; } catch (err) { addToast(tc("Invalid code"), "Session not found — check your code or leave blank for demo.", "danger"); return; } } // ── Path C: No code — demo mode ── setScreen("lobby"); }; // ── Observer auto-join ──────────────────────────────────────────────────── // If URL had ?observer=1&email=...&game=..., _initFromURL stored params on // window._arenaObserverEmail. Fire once when screen==="join" and connected. // Registers the observer, then reads the LIVE sub_phase from the server and // routes directly to that screen — never to lobby/draft. aE(function() { const obsEmail = window._arenaObserverEmail; if (!obsEmail || screen !== "join" || !connected) return; const obsName = window._arenaObserverName || "Observer"; delete window._arenaObserverEmail; delete window._arenaObserverName; setIsObserver(true); const obsGameId = Session.getGameId(); // Observer-specific join: explicit register + sub-phase fetch, // fallback to "phaseTransition" (waiting screen) — NEVER lobby/draft. (async function _observerJoin() { try { await Api.register(obsName, obsEmail, "", false, "internal"); Session.setEmail(obsEmail); addToast(tc("Checked in"), "Observer mode — no scoring", "success"); } catch (err) { addToast(tc("Check-in failed"), err.message || "API error", "danger"); return; } // Route to the screen matching the CURRENT live sub_phase. // "phaseTransition" is the safe waiting screen — it shows a neutral // scoreboard view and the sub_phase poller will advance us automatically. const OBS_SUB_SCREEN = { quiz: "quiz", triage: "triage", breach: "triage", gameplan: "gameplan", zonamixta: "zonamixta", wrap: "wrap", }; let target = "phaseTransition"; // safe fallback — never lobby try { if (obsGameId) { const subData = await fetch(`/api/g/${encodeURIComponent(obsGameId)}/sub-phase`) .then(function(r) { return r.ok ? r.json() : null; }) .catch(function() { return null; }); if (subData && subData.sub_phase) { target = OBS_SUB_SCREEN[subData.sub_phase] || "phaseTransition"; } } } catch (_) { /* keep phaseTransition */ } console.log("[observer] sub_phase →", target); setScreen(target); })(); }, [screen, connected]); // eslint-disable-line react-hooks/exhaustive-deps // ── Quiz: select & answer ───────────────────────────────────────────────── const handleSelect = (key) => { if (!locked) setSelected(key); }; const handleAnswer = async (key) => { if (locked) return; setLocked(true); const q = questions[qIdx]; const gameId = Session.getGameId(); // Anti-cheat: skip scoring if this question was already answered (refresh exploit) const answeredKey = gameId ? 'arena_ans_' + gameId : null; const answered = answeredKey ? JSON.parse(sessionStorage.getItem(answeredKey) || '[]') : []; if (answered.includes(qIdx)) { console.log("[Arena] Q" + (qIdx + 1) + " already scored — skipping duplicate"); addToast(tc("Already answered"), tc("Score unchanged"), ""); return; } let correct, points; let partialMatch = false; // true for drag_match with 0 < pct < 1 (no streak multiplier) if (q.type && q.spec && window.RENDERERS && window.RENDERERS[q.type]) { if (q.type === 'drag_match' && q.spec.pairs) { // Partial-credit grading for drag-match. // Scoring: all correct → full q.points × streak mult; // partial (0 < pct < 1) → Math.round(q.points * pct * 0.5), no streak mult; // zero correct → 0 pts, streak broken. // "correct" for streak/leaderboard: pct >= 0.5 (50% threshold). const pairs = q.spec.pairs; const total = pairs.length; const matched = pairs.filter(p => key?.[p.leftId] === p.rightId).length; const pct = total > 0 ? matched / total : 0; correct = pct >= 0.5; // 50% threshold counts as correct for streak if (pct === 1) { points = q.points || 200; // full credit; streak multiplier applied below } else if (pct > 0) { points = Math.round((q.points || 200) * pct * 0.5); // partial, no streak mult partialMatch = true; } else { points = 0; } } else { // New format — use checkCorrect helper correct = window.checkCorrect(q, key); if (correct === null) { // Ungraded type (free_text, hotspot, etc.) — award participation points, keep streak points = q.points || 50; correct = true; // treat as "ok" for streak purposes } else { points = correct ? (q.points || 120) : 0; } } } else { // Legacy format correct = key === q.correct; points = correct ? 120 : 0; } setGradedOutcome(partialMatch ? 'partial' : correct ? 'correct' : 'wrong'); const newStreak = correct ? streak + 1 : 0; // Streak multiplier: 3+ correct = 1.5×, 5+ = 2× (not applied for partial drag_match) let streakMultiplier = 1; if (!partialMatch && correct && newStreak >= 5) streakMultiplier = 2; else if (!partialMatch && correct && newStreak >= 3) streakMultiplier = 1.5; const finalPoints = Math.round(points * streakMultiplier); const newScore = myScore + finalPoints; setPrevScore(myScore); setMyScore(newScore); setStreak(newStreak); // Mark as answered + save progress position answered.push(qIdx); if (answeredKey) sessionStorage.setItem(answeredKey, JSON.stringify(answered)); if (correct && newStreak >= 3) { setCelebration(Date.now()); } if (connected) { const email = Session.getEmail(); if (email) { try { const serverResult = await Api.updateScore(email, newScore, newStreak); // Sync with server truth — catches silent rejections (timer expired, etc.) if (serverResult && typeof serverResult.score === 'number' && serverResult.score !== newScore) { console.warn("[Arena] Server score differs:", serverResult.score, "vs local:", newScore); setMyScore(serverResult.score); } const qId = q.id || q.tag || `q${qIdx}`; const progressEntry = { qId, answered: key, correct, pts: finalPoints, streak: newStreak, multiplier: streakMultiplier }; const saveResult = await Api.saveQuizProgress(email, [progressEntry], newScore, newStreak); // Reconcile local score with server truth — server recomputes from progress entries // and may differ (e.g. timer expired, duplicate rejected, non-quiz score offset) if (saveResult && typeof saveResult.score === 'number') { if (saveResult.score !== newScore) { console.warn("[Arena] saveQuizProgress score differs — server:", saveResult.score, "local:", newScore); } setMyScore(saveResult.score); if (saveResult.streak != null) setStreak(saveResult.streak); } } catch (err) { console.warn("[Arena] score sync failed:", err.message); } } } addToast( correct ? tc("Correct!") : tc("Not quite"), correct ? `+${finalPoints} pts${streakMultiplier > 1 ? ` (${streakMultiplier}× streak!)` : ''} · streak × ${newStreak}` : tc("No points this round"), correct ? "success" : "danger" ); }; // ── Quiz: skip question ─────────────────────────────────────────────────── // Awards 0 pts, resets streak, marks question as answered (no replay). // Auto-advances after 800 ms. API call is fire-and-forget to avoid blocking. const handleSkip = () => { const q = questions[qIdx]; const gameId = Session.getGameId(); // Anti-cheat: already answered (e.g. race from double-tap timing) const answeredKey = gameId ? 'arena_ans_' + gameId : null; const answered = answeredKey ? JSON.parse(sessionStorage.getItem(answeredKey) || '[]') : []; if (answered.includes(qIdx)) { handleNext(); return; } // 0 pts, streak broken setStreak(0); setGradedOutcome('skipped'); // Mark qIdx as answered so it can't be replayed answered.push(qIdx); if (answeredKey) sessionStorage.setItem(answeredKey, JSON.stringify(answered)); addToast(tc("Skipped"), `+0 pts · ${tc("streak broken")}`, ""); // Auto-advance after short pause regardless of API outcome setTimeout(handleNext, 800); // Fire-and-forget progress entry if (connected) { const email = Session.getEmail(); if (email) { const qId = q.id || q.tag || `q${qIdx}`; const progressEntry = { qId, answered: true, correct: false, skipped: true, pts: 0, streak: 0 }; Api.saveQuizProgress(email, [progressEntry], myScore, 0) .then(result => { if (result && typeof result.score === 'number') setMyScore(result.score); if (result && result.streak != null) setStreak(result.streak); }) .catch(err => console.warn("[Arena] skip sync failed:", err.message)); } } }; const handleNext = () => { const gameId = Session.getGameId(); if (qIdx < questions.length - 1) { const next = qIdx + 1; setQIdx(next); if (gameId) sessionStorage.setItem('arena_qIdx_' + gameId, String(next)); setSelected(null); setLocked(false); setGradedOutcome(null); } else { if (gameId) { sessionStorage.removeItem('arena_qIdx_' + gameId); sessionStorage.removeItem('arena_ans_' + gameId); } setTransitionTarget("triage"); setScreen("phaseTransition"); setSelected(null); setLocked(false); setGradedOutcome(null); } }; // ── Power-up handler ───────────────────────────────────────────────────── const handlePowerup = async (puId) => { const newSpent = [...powerupsSpent, puId]; setPowerupsSpent(newSpent); const newScore = Math.max(0, myScore - 50); setPrevScore(myScore); setMyScore(newScore); if (connected) { const email = Session.getEmail(); if (email) { try { // Convert spent array to dict format expected by server // Server expects { key: boolean } where false = spent const puDict = {}; (theme.powerups || []).forEach(p => { puDict[p.id] = !newSpent.includes(p.id); }); await Api.savePowerUps(email, puDict); } catch (err) { console.warn("[Arena] power-up sync failed:", err.message); } } } addToast(tc("Power-up used"), "-50 pts", ""); }; // ── Triage submit ───────────────────────────────────────────────────────── const handleTriageSubmit = async (ans, powerUps = {}) => { // Handle skip if (powerUps.skipUsed) { const penalty = Math.round((powerUps.penaltyPct || 50) / 100 * 100); // 50% of max 100 pts setPrevScore(myScore); setMyScore(Math.max(0, myScore - penalty)); setGraded(true); setGradeMsg({ score: 0, note: tc("Ticket skipped. Penalty applied: -") + penalty + tc(" pts") }); addToast(tc("Ticket skipped"), "-" + penalty + " pts", "danger"); setGrading(false); return; } if (connected) { const email = Session.getEmail(); if (email) { setGrading(true); try { const gradeResult = await Api.gradeTicket({ email, ticket_id: ticket.id, answer: ans, ticket_question: ticket.question || ticket.title, ticket_scenario: ticket.desc || ticket.scenario || "", ticket_hint: powerUps.hintUsed ? (typeof ticket.hint === 'object' && ticket.hint ? (ticket.hint.text || "") : (ticket.hint || "")) : "", // Real answer key — `ticket.solution` is the multi-step playbook // for ISE tickets. Fall back to the syslog dump only when no // solution field exists (legacy v2 demo tickets). ticket_solution: ticket.solution || (ticket.log ? ticket.log.join("\n") : ""), ticket_licensing_angle: ticket.licensingAngle || "", ticket_symptoms: Array.isArray(ticket.symptomsList) ? ticket.symptomsList.join("\n") : "", max_points: 100, }); // Apply power-up modifiers to score let finalScore = gradeResult.score ?? 75; if (powerUps.hintUsed) finalScore = Math.round(finalScore * (1 - (powerUps.hintPenalty || 20) / 100)); if (powerUps.shuffleUsed) finalScore = Math.round(finalScore * (1 - (powerUps.shufflePenalty || 10) / 100)); if (powerUps.double) finalScore = finalScore >= 60 ? finalScore * 2 : 0; // Save the answer with ticket context so server can grade authoritatively (Fix 3) await Api.saveTicketAnswer({ email, ticket_id: ticket.id, module: "", answer: ans, ai_score: finalScore, // server ignores this; kept for compatibility ai_available: gradeResult.ai_available ?? false, ai_feedback: gradeResult.feedback ?? "", ticket_question: ticket.question || ticket.title, ticket_scenario: ticket.desc || ticket.scenario || "", ticket_hint: powerUps.hintUsed ? (typeof ticket.hint === 'object' && ticket.hint ? (ticket.hint.text || "") : (ticket.hint || "")) : "", ticket_solution: ticket.solution || (ticket.log ? ticket.log.join("\n") : ""), }); setGraded(true); setGrading(false); let note = gradeResult.feedback ?? "Graded by server."; if (powerUps.double && finalScore > 0) note += " ⚡ Double bonus!"; if (powerUps.double && finalScore === 0) note += " ⚡ Double risk — zero points."; setGradeMsg({ score: finalScore, note }); setPrevScore(myScore); setMyScore(myScore + finalScore); addToast(tc("Triage graded"), `Score: ${finalScore}/100`, finalScore > 0 ? "success" : "danger"); } catch (err) { console.warn("[Arena] triage grade failed:", err.message); setGrading(false); mockGradeTriage(ans, powerUps); } } } else { setGrading(false); mockGradeTriage(ans, powerUps); } }; const mockGradeTriage = (ans, powerUps = {}) => { const keywords = ["loop guard","loopguard","udld","gi1/0/24","bpdu","stp","spanning-tree"]; const hits = keywords.filter(k => (ans || "").toLowerCase().includes(k)).length; let score = Math.min(100, 40 + hits * 10 + ((ans || "").length > 200 ? 10 : 0)); if (powerUps.hintUsed) score = Math.round(score * 0.8); if (powerUps.shuffleUsed) score = Math.round(score * 0.9); if (powerUps.double) score = score >= 60 ? score * 2 : 0; const note = hits >= 3 ? "Strong diagnosis — loop-guard misconfig identified, rollback mentioned." : hits >= 1 ? "Partial credit — mention the STP loop-guard trigger and remediation." : "Review the syslog carefully — look for the repeating STP block event."; const bonus = Math.round(score * 1.2); setPrevScore(myScore); setMyScore(myScore + bonus); setGraded(true); setGradeMsg({ score, note }); addToast(tc("Triage graded"), `Score: ${score}/100 · +${bonus} pts`, "success"); }; // ── Next ticket (multi-ticket) ─────────────────────────────────────────── const handleNextTicket = () => { const nextIdx = ticketIdx + 1; if (nextIdx < tickets.length) { setTicketIdx(nextIdx); setGraded(false); setGradeMsg(null); setGrading(false); } }; // ── Shuffle power-up — swap current ticket for a random unused one ────── const handleShuffle = () => { if (!connected) return null; const email = Session.getEmail(); const gameId = Session.getGameId(); if (!email || !gameId) return null; // Get all tickets from module fetch("/api/games/" + encodeURIComponent(gameId)) .then(r => r.ok ? r.json() : null) .then(game => { if (!game) return; const modId = (game.modules || ["ise"])[0]; const mod = window.GAME_MODULES && window.GAME_MODULES[modId]; if (!mod || !mod.data || !mod.data.tickets) return; const allTickets = mod.data.tickets; const usedIds = new Set(tickets.map(t => t.id)); const available = allTickets.filter(t => !usedIds.has(t.id)); if (available.length === 0) return; // no tickets left to shuffle const randomIdx = Math.floor(Math.random() * available.length); // Normalize the new ticket so TriageScreen sees `.log` and the rich // ISE fields (scenario, audienceFraming, symptoms, etc.). Without // this, shuffle would drop the player back to "Loading ticket…". const newTicket = normalizeTicket(available[randomIdx]); // Replace current ticket in the array setTickets(prev => { const updated = [...prev]; updated[ticketIdx] = newTicket; return updated; }); }); }; // BreachIncidentScreen alias removed — was being redeclared inside App() body // every render, creating a new component identity and unmounting/remounting // BreachIncidentScreenHoisted on each re-render. That destroyed the textarea // local state and wiped what players typed. Use BreachIncidentScreenHoisted // directly in JSX below. // ── Computed ────────────────────────────────────────────────────────────── const urgent = timerSec <= 30; const phaseKey = screen === "phaseTransition" ? (transitionTarget || "triage") : screen; const phaseLabel = theme.phaseNames[phaseKey] || phaseKey; const layoutCompact = tweaks.layout === "compact"; // Tech-Day override: when an active talk has questions open, swap the // entire stage for the TechDayQuizScreen regardless of game phase. const tdActive = connected && tdState && tdState.questions_open && !!tdState.active_agenda_id && !!Session.getEmail() && screen !== "join"; // ── Render ──────────────────────────────────────────────────────────────── // Join screen renders full-viewport WITHOUT the game shell (no header/sidebar/footer) if (screen === "join") { return ( <> > ); } return ( <> {/* Tech-Day Q&A override: when an active talk has questions open, the player must answer here regardless of macro game phase. */} {tdActive && ( )} {!tdActive && screen === "lobby" && ( setScreen("quiz")} draftStatus={draftStatus} draftTeams={draftTeams} draftUndrafted={draftUndrafted} isCaptain={isCaptain} isMyTurn={isMyDraftTurn} onDraftPick={handleDraftPick} connected={connected} /> )} {!tdActive && screen === "quiz" && (() => { if (!questions.length || !questions[qIdx]) return null; return ( ); })()} {!tdActive && screen === "phaseTransition" && ( { if (transitionTarget === "triage") { setGraded(false); setGradeMsg(null); setGrading(false); setScreen("triage"); } else if (transitionTarget === "gameplan") { setScreen("gameplan"); } else if (transitionTarget === "zonamixta") { setScreen("zonamixta"); } else { setScreen("wrap"); } }} /> )} {!tdActive && screen === "triage" && ( (!gameConfig && connected) || gameConfig?.event_type === 'clinic' ? ( ) : ( { setTransitionTarget("gameplan"); setScreen("phaseTransition"); }} onNextTicket={handleNextTicket} onShuffle={handleShuffle} /> ) )} {!tdActive && screen === "gameplan" && ( { setTransitionTarget("zonamixta"); setScreen("phaseTransition"); }} /> )} {!tdActive && screen === "zonamixta" && ( setScreen("wrap")} /> )} {!tdActive && screen === "wrap" && ( )} {celebration && ( setCelebration(null)} /> )} {tweaks.layout === "ticker" ? ( ) : (connected && (screen === "lobby" || screen === "phaseTransition")) ? null : ( )} {!connected && { setScreen(s); if (s === "quiz") { setQIdx(serverQIdxRef.current); setSelected(null); setLocked(false); } if (s === "triage") { setGraded(false); setGradeMsg(null); } }} />} {!connected && ( setTweakOpen(true)} onClose={() => setTweakOpen(false)} state={tweaks} set={setTweak} /> )} {/* Connected-mode badge */} {connected && ( {apiOk ? "●" : "○"} {apiOk ? tc("LIVE") : tc("OFFLINE")} · {Session.getGameId()} )} {/* Observer mode badge */} {connected && isObserver && ( 👁 OBSERVER — no scoring )} > ); } ReactDOM.createRoot(document.getElementById("app")).render();
{beat.narrative}