/* ========================================================================= 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}
  • ); })}