diff --git a/client/src/views/Leaderboard.vue b/client/src/views/Leaderboard.vue index b1a6ad1..f021f09 100644 --- a/client/src/views/Leaderboard.vue +++ b/client/src/views/Leaderboard.vue @@ -21,9 +21,66 @@
-
- +
+ +
+ +
+ +
+
+ + +
+
+ + + +
+ + + + +
+
+
+
+ Global + · + Ronda + · + Juego + · + Jugadores + · + Salas +
+ + +
- -
- - -
-
- Global - · - Jugadores - · - Salas -
@@ -72,31 +117,7 @@
- -
-
-
- Salas totales: {{ availableRooms.length }} - - Analizando {{ roomSliceIds.length }} ({{ sliceStartLabel }}–{{ sliceEndLabel }}) - -
- -
-
-
-
- - -
-
- - - - - -
-
+
@@ -121,6 +142,18 @@ totalPlayers: totalPlayersCount }" /> + + +
+
+
Datos del stream (players-actions-stream)
+
+ + +
+
+
{{ prettyRaw }}
+
@@ -130,7 +163,6 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { useRouter } from 'vue-router'; import EventChart from '../components/EventChart.vue'; import EventFilters from '../components/EventFilters.vue'; -import DataSourceSelector from '../components/DataSourceSelector.vue'; import GameLogo from '../components/GameLogo.vue'; import AppCredits from '../components/AppCredits.vue'; import { useEventFilters } from '../composables/useEventFilters'; @@ -141,6 +173,95 @@ const loading = ref(false); const eventFilters = useEventFilters(); const filtersCollapsed = ref(false); +// Time mode and range handling +type TimeMode = 'active' | 'range'; +const timeMode = ref('range'); +const rangeFromStr = ref(''); +const rangeToStr = ref(''); +const liveEnd = ref(true); +let liveTimer: any = null; +const fullAggregatedEvents = ref>([]); + +function fmtLocal(dt: Date) { + const pad = (n: number) => String(n).padStart(2, '0'); + const y = dt.getFullYear(); + const m = pad(dt.getMonth() + 1); + const d = pad(dt.getDate()); + const hh = pad(dt.getHours()); + const mm = pad(dt.getMinutes()); + return `${y}-${m}-${d}T${hh}:${mm}`; +} + +function initDefaultRange() { + const now = new Date(); + const from = new Date(now.getTime() - 60 * 60 * 1000); // 1h atrás + rangeFromStr.value = fmtLocal(from); + rangeToStr.value = fmtLocal(now); +} + +function setTimeMode(mode: TimeMode) { + timeMode.value = mode; + applyTimeMode(); +} + +function applyTimeMode() { + if (timeMode.value === 'active') { + eventFilters.dataSource.value = 'active-rooms'; + eventFilters.roomFilter.value = []; + selectedRoomIds.value = []; + eventFilters.applyFilters(EVENTS); + return; + } + eventFilters.dataSource.value = 'aggregated'; + // Parse range + const fromMs = Date.parse(rangeFromStr.value || ''); + const toMs = liveEnd.value ? Date.now() : Date.parse(rangeToStr.value || ''); + if (liveEnd.value) { + rangeToStr.value = fmtLocal(new Date(toMs)); + } + const valid = !Number.isNaN(fromMs) && !Number.isNaN(toMs) && toMs >= fromMs; + const ranged = valid + ? fullAggregatedEvents.value.filter(ev => typeof ev.timestamp === 'number' && (ev.timestamp as number) >= fromMs && (ev.timestamp as number) <= toMs) + : fullAggregatedEvents.value; + const counts: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any; + ranged.forEach(ev => { if (EVENTS.includes(ev.kind)) { counts[ev.kind] = (counts[ev.kind] || 0) + 1; } }); + eventFilters.updateAggregatedData(ranged as any, counts); + // Build rooms and sync roomFilter to current range + const lastSeenIndex: Record = {}; + const ids = new Set(); + ranged.forEach((ev, i) => { + const rid = String(ev?.roomId || '').trim(); + if (!rid) return; + ids.add(rid); + lastSeenIndex[rid] = i; + }); + availableRooms.value = Array.from(ids).map(rid => ({ + roomId: rid, + name: `Sala ${rid.slice(0, 8)}`, + playerCount: ranged.filter(e => e?.roomId === rid).length, + _lastSeen: lastSeenIndex[rid] ?? -1 + })).sort((a: any, b: any) => (a._lastSeen - b._lastSeen)).map(({ _lastSeen, ...rest }: any) => rest); + selectedRoomIds.value = Array.from(ids); + eventFilters.roomFilter.value = [...selectedRoomIds.value]; + eventFilters.applyFilters(EVENTS); +} + +function toggleLiveEnd() { + liveEnd.value = !liveEnd.value; + if (liveEnd.value) { + if (liveTimer) { clearInterval(liveTimer); liveTimer = null; } + // Update immediately and every 15s + rangeToStr.value = fmtLocal(new Date()); + applyTimeMode(); + liveTimer = setInterval(() => { + rangeToStr.value = fmtLocal(new Date()); + applyTimeMode(); + }, 15000); + } else { + if (liveTimer) { clearInterval(liveTimer); liveTimer = null; } + } +} + const EVENTS = [ 'p1_propose', 'p1_no_offer', @@ -477,46 +598,6 @@ const playersPage = computed(() => { const selectedUuids = ref([]); const selectedRoomIds = ref([]); -const roomSliceStart = ref(0); -const roomSliceEnd = ref(0); -const maxIndex = computed(() => Math.max(0, availableRooms.value.length - 1)); -const sliceInitialized = ref(false); -const roomSliceIds = computed(() => { - const a = Math.max(0, Math.min(roomSliceStart.value | 0, maxIndex.value)); - const b = Math.max(0, Math.min(roomSliceEnd.value | 0, maxIndex.value)); - const start = Math.min(a, b); - const end = Math.max(a, b); - return availableRooms.value.slice(start, end + 1).map(r => r.roomId); -}); -const sliceStartLabel = computed(() => roomSliceIds.value.length ? Math.min(roomSliceStart.value, roomSliceEnd.value) + 1 : 0); -const sliceEndLabel = computed(() => roomSliceIds.value.length ? Math.max(roomSliceStart.value, roomSliceEnd.value) + 1 : 0); -const startPct = computed(() => maxIndex.value > 0 ? (Math.min(roomSliceStart.value, roomSliceEnd.value) / maxIndex.value) * 100 : 0); -const endPct = computed(() => maxIndex.value > 0 ? (Math.max(roomSliceStart.value, roomSliceEnd.value) / maxIndex.value) * 100 : 0); -function onSliceStart() { - if (roomSliceStart.value > roomSliceEnd.value) roomSliceStart.value = roomSliceEnd.value; -} -function onSliceEnd() { - if (roomSliceEnd.value < roomSliceStart.value) roomSliceEnd.value = roomSliceStart.value; -} - -function selectRecent(count: number) { - const m = maxIndex.value; - if (m < 0) return; - const start = Math.max(0, m - (count - 1)); - roomSliceStart.value = start; - roomSliceEnd.value = m; -} - -function addRecent(count: number) { - const m = maxIndex.value; - if (m < 0) return; - // Current selection length anchored at end - const currentLen = roomSliceIds.value.length; - const newLen = Math.min(m + 1, Math.max(0, currentLen) + Math.max(1, count)); - const start = Math.max(0, m - (newLen - 1)); - roomSliceStart.value = start; - roomSliceEnd.value = m; -} const playerLoading = ref(false); const playerEventCounts = ref>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record)); const playersActionsByUuid = ref>>({}); @@ -549,33 +630,6 @@ function clearPlayers() { } -function clearRooms() { - suppressSliceSync = true; - selectedRoomIds.value = []; - eventFilters.roomFilter.value = []; - eventFilters.applyFilters(EVENTS); - // allow user to re-enable by moving sliders - requestAnimationFrame(() => { suppressSliceSync = false; }); -} - -// Keep event filters in sync with slice selection -let suppressSliceSync = false; -// Initialize full-range selection as soon as rooms are available (first time) -watch(maxIndex, (m) => { - if (m >= 0 && !sliceInitialized.value) { - roomSliceStart.value = 0; - roomSliceEnd.value = m; - selectedRoomIds.value = [...roomSliceIds.value]; - eventFilters.roomFilter.value = [...selectedRoomIds.value]; - sliceInitialized.value = true; - } -}); -watch([roomSliceStart, roomSliceEnd, () => availableRooms.value.length], () => { - if (suppressSliceSync) return; - selectedRoomIds.value = [...roomSliceIds.value]; - eventFilters.roomFilter.value = [...selectedRoomIds.value]; - eventFilters.applyFilters(EVENTS); -}); function goHome() { router.push('/'); @@ -604,187 +658,151 @@ const playerBarGradient = computed(() => '#8b5cf6'); const apiBase = (import.meta as any).env?.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`; -const esRooms = ref(null); -const esUuids = ref(null); const esActions = ref(null); +const rawActionsPayload = ref(null); +const showRaw = ref(false); +const prettyRaw = computed(() => { + try { + return JSON.stringify(rawActionsPayload.value, null, 2); + } catch { + return String(rawActionsPayload.value || ''); + } +}); +function copyRaw() { + try { + navigator.clipboard.writeText(prettyRaw.value); + } catch {} +} + +function quickRange(minutes: number) { + const now = new Date(); + const from = new Date(now.getTime() - minutes * 60 * 1000); + rangeFromStr.value = fmtLocal(from); + rangeToStr.value = fmtLocal(now); + applyTimeMode(); +} function closeStreams() { - try { esRooms.value?.close(); } catch {} - try { esUuids.value?.close(); } catch {} try { esActions.value?.close(); } catch {} - esRooms.value = null; - esUuids.value = null; esActions.value = null; } function setupStreams() { loading.value = true; - // Rooms stream closeStreams(); - esRooms.value = new EventSource(`${apiBase}/dashboard-stream`); - esRooms.value.onmessage = (e) => { - try { - const data = JSON.parse((e as MessageEvent).data || '{}'); - const details = data?.roomDetails || {}; - - // Room details are for active rooms, we'll get room data from player history instead - - // Collect detailed events for active rooms - const detailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = []; - const counts: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any; - - Object.entries(details).forEach(([roomId, d]: [string, any]) => { - (Array.isArray(d?.systemMessages) ? d.systemMessages : []).forEach((m: any) => { - const k = (m?.kind || '').toString(); - if (EVENTS.includes(k)) { - counts[k] = (counts[k] || 0) + 1; - detailedEvents.push({ - kind: k, - round: m?.round, - gameVariant: m?.gameVariant || m?.variant, - roomId: m?.roomId || roomId // Use message roomId if available, otherwise use room key - }); - } - }); - }); - - eventFilters.updateActiveRoomsData(detailedEvents, counts); - - // Compute additional metrics from active rooms (for current UI compatibility) - // computeMetrics(details); // Removed since we now use score history - - // Don't extract rooms from active data - we'll get them from aggregated player history - - // Apply filters and update display - if (eventFilters.dataSource.value === 'active-rooms') { - eventFilters.applyFilters(EVENTS); - } - // Build players list from room details (keep color if provided) - const playerMap = new Map(); - Object.values(details).forEach((d: any) => { - (d?.players || []).forEach((p: any) => { - const uuid = (p?.uuid || p?.sessionId || '').toString(); - if (uuid && !playerMap.has(uuid)) playerMap.set(uuid, { name: (p?.name || 'player'), color: p?.color }); - }); - }); - const merged = new Map(); - players.value.forEach(p => merged.set(p.uuid, { name: p.name, color: p.color })); - playerMap.forEach((obj, uuid) => merged.set(uuid, { name: obj.name, color: obj.color || merged.get(uuid)?.color })); - players.value = Array.from(merged.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name)); - } finally { - loading.value = false; - } - }; - esRooms.value.onerror = () => {}; - // UUIDs stream - esUuids.value = new EventSource(`${apiBase}/uuids-stream`); - esUuids.value.onmessage = (e) => { - try { - const data = JSON.parse((e as MessageEvent).data || '{}'); - const list = Array.isArray(data?.uuids) ? data.uuids : []; - const existing = new Map(); - players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color })); - list.forEach((u: any) => { - const uuid = (u?.uuid || '').toString(); - if (!uuid) return; - const prev = existing.get(uuid); - const name = (u?.name || prev?.name || 'player').toString(); - existing.set(uuid, { name, color: prev?.color }); - }); - players.value = Array.from(existing.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name)); - } catch {} - }; - esUuids.value.onerror = () => {}; - - // Per-player actions stream + // Único stream: players-actions-stream esActions.value = new EventSource(`${apiBase}/players-actions-stream`); esActions.value.onmessage = (e) => { try { const data = JSON.parse((e as MessageEvent).data || '{}'); + rawActionsPayload.value = data; + const list = Array.isArray(data?.players) ? data.players : []; - allPlayersActions.value = list.map((p: any) => ({ uuid: String(p.uuid||''), name: String(p.name||''), total: Number(p.total||0) })); - - // Store complete player data with room score history allPlayersWithScores.value = list; - - - // Collect all detailed events from all players - const allDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = []; - const aggregatedCounts: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any; - - // Update detailed counts map + + // Construir mapa de colores desde snapshot de salas activas (si está) + const colorMap = new Map(); + const activeRooms = data?.activeRooms?.rooms || []; + activeRooms.forEach((r: any) => { + (Array.isArray(r?.players) ? r.players : []).forEach((p: any) => { + const uuid = String(p?.uuid || ''); + if (uuid && !colorMap.has(uuid)) colorMap.set(uuid, p?.color || undefined); + }); + }); + + // Lista de jugadores para chips con color (si disponible) + players.value = list + .map((p: any) => ({ + uuid: String(p?.uuid || ''), + name: String(p?.name || 'player'), + color: colorMap.get(String(p?.uuid || '')) || undefined + })) + .filter((p: any) => !!p.uuid) + .sort((a: any, b: any) => a.name.localeCompare(b.name)); + + // Mapear counts por jugador const byUuid: Record> = {}; list.forEach((p: any) => { const uuid = String(p?.uuid || ''); if (!uuid) return; const src = p?.counts || {}; const normalized: Record = Object.fromEntries(EVENTS.map(k => [k, 0])) as any; - - // Add detailed history events - if (Array.isArray(p?.detailedHistory)) { - allDetailedEvents.push(...p.detailedHistory); - } - - EVENTS.forEach(k => { - const count = Number(src[k] || 0); - normalized[k] = count; - aggregatedCounts[k] = (aggregatedCounts[k] || 0) + count; - }); + EVENTS.forEach(k => { normalized[k] = Number(src[k] || 0); }); byUuid[uuid] = normalized; }); - playersActionsByUuid.value = byUuid; - eventFilters.updateAggregatedData(allDetailedEvents, aggregatedCounts); - - // Extract unique room IDs from aggregated events and track "newness" by last seen index - const roomIds = new Set(); - const lastSeenIndex: Record = {}; - allDetailedEvents.forEach((event, idx) => { - const rid = (event.roomId || '').trim(); - if (!rid) return; - roomIds.add(rid); - lastSeenIndex[rid] = idx; // increasing idx means newer - }); - // Build available rooms list sorted by lastSeenIndex ASC (older first, newest get the highest index) - availableRooms.value = Array.from(roomIds) - .map(roomId => ({ - roomId, - name: `Sala ${roomId.slice(0, 8)}`, - playerCount: allDetailedEvents.filter(e => e.roomId === roomId).length, - _lastSeen: lastSeenIndex[roomId] ?? -1 - })) - .sort((a, b) => (a._lastSeen - b._lastSeen)) - .map(({ _lastSeen, ...rest }) => rest); - // Initialize slice window: full range by default, preserve user selection if present - if (availableRooms.value.length > 0 && selectedRoomIds.value.length === 0) { - roomSliceStart.value = 0; - roomSliceEnd.value = availableRooms.value.length - 1; + // Datos agregados desde el payload + const aggEvents = Array.isArray(data?.aggregated?.detailedEvents) ? data.aggregated.detailedEvents : []; + // Store full events with timestamps for range filtering + fullAggregatedEvents.value = aggEvents as any; + // Apply current time mode (range/active) + applyTimeMode(); + + // Datos de salas activas → eventos activos y counts + if (data?.activeRooms?.rooms) { + const activeDetailed: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = []; + (data.activeRooms.rooms || []).forEach((r: any) => { + (Array.isArray(r?.systemMessages) ? r.systemMessages : []).forEach((m: any) => { + const k = String(m?.kind || ''); + if (EVENTS.includes(k)) { + activeDetailed.push({ + kind: k, + round: m?.round, + gameVariant: m?.gameVariant || m?.variant, + roomId: m?.roomId || r?.roomId + }); + } + }); + }); + const activeCounts = data?.activeRooms?.counts || Object.fromEntries(EVENTS.map(k => [k, 0])); + eventFilters.updateActiveRoomsData(activeDetailed, activeCounts); } - - // Compute metrics from score history + + // Construir listado de salas desde aggregated.rooms si está, si no derivar de eventos + const roomsList = Array.isArray(data?.aggregated?.rooms) ? data.aggregated.rooms : null; + if (roomsList) { + availableRooms.value = roomsList + .slice() + .sort((a: any, b: any) => (a?.lastSeenIndex || 0) - (b?.lastSeenIndex || 0)) + .map((r: any) => ({ + roomId: String(r?.roomId || ''), + name: `Sala ${String(r?.roomId || '').slice(0, 8)}`, + playerCount: Number(r?.messageCount || 0) + })) + .filter((r: any) => !!r.roomId); + } else { + const lastSeenIndex: Record = {}; + const ids = new Set(); + aggEvents.forEach((ev: any, i: number) => { + const rid = String(ev?.roomId || '').trim(); + if (!rid) return; + ids.add(rid); + lastSeenIndex[rid] = i; + }); + availableRooms.value = Array.from(ids).map(rid => ({ + roomId: rid, + name: `Sala ${rid.slice(0, 8)}`, + playerCount: aggEvents.filter((e: any) => e?.roomId === rid).length, + _lastSeen: lastSeenIndex[rid] ?? -1 + })).sort((a: any, b: any) => (a._lastSeen - b._lastSeen)).map(({ _lastSeen, ...rest }: any) => rest); + } + + // Sin slider: la selección de salas se sincroniza con el rango de tiempo + + // Recalcular métricas desde score history computeMetricsFromScores(); - - // Apply filters and update display if viewing aggregated data - if (eventFilters.dataSource.value === 'aggregated') { - eventFilters.applyFilters(EVENTS); - } - // If a player is selected, update playerEventCounts live - // Update selected players combined counts + + // Aplicar filtros según dataSource actual + eventFilters.applyFilters(EVENTS); + + // Actualizar conteos del/los jugador(es) seleccionado(s) updateSelectedPlayersCounts(); - // Merge names into players list; preserve colors from previous streams - const existing = new Map(); - players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color })); - list.forEach((u: any) => { - const uuid = (u?.uuid || '').toString(); - if (!uuid) return; - const prev = existing.get(uuid); - const name = (u?.name || prev?.name || 'player').toString(); - existing.set(uuid, { name, color: prev?.color }); - }); - players.value = Array.from(existing.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name)); - } catch {} + + } finally { + loading.value = false; + } }; esActions.value.onerror = () => {}; } @@ -818,11 +836,17 @@ onMounted(() => { // Set initial container width and add resize listener updateContainerWidth(); window.addEventListener('resize', updateContainerWidth); + initDefaultRange(); + // Start live-end timer by default if enabled + if (liveEnd.value) { + toggleLiveEnd(); // sets up the interval and applies + } }); onUnmounted(() => { closeStreams(); window.removeEventListener('resize', updateContainerWidth); + try { if (liveTimer) clearInterval(liveTimer); } catch {} }); // Reset to first page when search changes or players list length shrinks below current page @@ -832,7 +856,8 @@ watch(() => playersFiltered.value.length, () => { }); // Removed totals table and sorting; keep actions stream for per-player counts only -const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]); +// Deprecated: previously used to show a totals list; now unused +// const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]); function downloadJSON() { @@ -947,6 +972,8 @@ function downloadJSON() { .key.global { background: linear-gradient(90deg, #34d399, #10b981); box-shadow: 0 0 8px rgba(16,185,129,0.35); } .key.player { background: linear-gradient(90deg, #a78bfa, #6366f1); box-shadow: 0 0 8px rgba(99,102,241,0.35); } .key.room { background: linear-gradient(90deg, #f59e0b, #d97706); box-shadow: 0 0 8px rgba(245,158,11,0.35); } +.key.round { background: linear-gradient(90deg, #06b6d4, #0891b2); box-shadow: 0 0 8px rgba(8,145,178,0.35); } +.key.game { background: linear-gradient(90deg, #ec4899, #8b5cf6); box-shadow: 0 0 8px rgba(236,72,153,0.35); } .sep { opacity: 0.6; } .player-chips, .room-chips { @@ -1019,6 +1046,7 @@ function downloadJSON() { flex-wrap: wrap; align-items: center; } +.secondary-filters { margin: 6px 0 4px; } .chip { display:flex; align-items:center; gap:8px; background: color-mix(in srgb, var(--primary) 6%, white); border:1px solid color-mix(in srgb, var(--primary) 24%, #e5e7eb); padding:8px 12px; border-radius: 999px; color:#111827; cursor:pointer; transition: transform .18s ease, background .18s ease, box-shadow .18s ease; } .chip:hover { transform: translateY(-1px); background: color-mix(in srgb, var(--primary) 10%, white); box-shadow: 0 6px 18px rgba(102,126,234,0.18); } .chip.active { background: color-mix(in srgb, var(--primary) 18%, white); border-color: color-mix(in srgb, var(--primary) 45%, #c7d2fe); box-shadow: 0 6px 22px rgba(99,102,241,0.22); } @@ -1106,6 +1134,25 @@ function downloadJSON() { text-align: center; } +/* Raw payload styles */ +.raw-viewer { margin-top: 14px; padding: 12px; border-radius: 14px; } +.raw-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.raw-title { font-weight: 800; color: #334155; font-size: 14px; } +.raw-actions { display: flex; gap: 8px; } +.raw-pre { max-height: 420px; overflow: auto; background: #0b1020; color: #e5e7eb; padding: 12px; border-radius: 10px; font-size: 12px; line-height: 1.4; border: 1px solid #1f2937; } + +/* Time selector styles */ +.time-selector { display: flex; flex-wrap: wrap; gap: 12px; padding: 10px; margin-bottom: 12px; align-items: center; } +.mode-buttons { display: flex; gap: 6px; } +.mode-btn { padding: 6px 10px; border-radius: 8px; border: 1px solid #cbd5e1; background: #fff; color: #334155; font-weight: 800; font-size: 12px; cursor: pointer; } +.mode-btn.active { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-color: #667eea; } +.range-inputs { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.range-inputs.disabled { opacity: 0.6; filter: grayscale(0.1); } +.range-inputs label { display: flex; gap: 6px; align-items: center; font-weight: 700; color: #334155; } +.range-inputs input[type="datetime-local"] { padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 12px; } +.live-btn { padding: 6px 10px; border-radius: 8px; border: 1px solid #cbd5e1; background: #fff; color: #334155; font-weight: 800; font-size: 12px; cursor: pointer; } +.live-btn.active { background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 100%); color: #fff; border-color: #06b6d4; } + /* Filters section styles */ .filters-section { diff --git a/server/src/adminApi.ts b/server/src/adminApi.ts index 19d0168..5368e50 100644 --- a/server/src/adminApi.ts +++ b/server/src/adminApi.ts @@ -909,6 +909,7 @@ async function sendPlayersActionsUpdate(client?: Response) { const round = (entry as any)?.round; const gameVariant = (entry as any)?.gameVariant || (entry as any)?.variant; const role = (entry as any)?.role; + const timestamp = (entry as any)?.timestamp; if (!ACTION_EVENTS.includes(kind)) continue; if (!isActionMade(kind, role)) continue; @@ -919,7 +920,8 @@ async function sendPlayersActionsUpdate(client?: Response) { kind, round, gameVariant, - roomId + roomId, + timestamp }); } @@ -947,7 +949,136 @@ async function sendPlayersActionsUpdate(client?: Response) { }; }).filter((p: any) => p.total > 0 || p.name); - const payload = { players }; + // Build aggregated data across all players + const aggregatedCounts: Record = Object.fromEntries(ACTION_EVENTS.map(k => [k, 0])) as any; + const aggregatedDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string; playerUuid?: string; timestamp?: number }>[] = [] as any; + + const roomsIndex: Record = {}; + let idxCounter = 0; + players.forEach((p: any) => { + // Sum counts + ACTION_EVENTS.forEach(k => { + const c = Number(p?.counts?.[k] || 0); + aggregatedCounts[k] = (aggregatedCounts[k] || 0) + c; + }); + // Flatten detailed history and build rooms index + (Array.isArray(p?.detailedHistory) ? p.detailedHistory : []).forEach((ev: any) => { + const e = { + kind: String(ev?.kind || ''), + round: ev?.round != null ? Number(ev.round) : undefined, + gameVariant: (ev?.gameVariant || ev?.variant) ? String(ev.gameVariant || ev.variant) : undefined, + roomId: ev?.roomId ? String(ev.roomId) : undefined, + playerUuid: p.uuid, + timestamp: ev?.timestamp != null ? Number(ev.timestamp) : undefined + }; + aggregatedDetailedEvents.push(e as any); + const rid = e.roomId || ''; + if (rid) { + if (!roomsIndex[rid]) { + roomsIndex[rid] = { roomId: rid, lastSeenIndex: idxCounter, messageCount: 0 }; + } + roomsIndex[rid].lastSeenIndex = idxCounter; // newer index wins + roomsIndex[rid].messageCount += 1; + if (e.timestamp) { + roomsIndex[rid].lastEventAt = Math.max(roomsIndex[rid].lastEventAt || 0, e.timestamp); + } + idxCounter += 1; + } + }); + }); + + // Optionally include snapshot of active rooms similar to /dashboard-stream + let activeRoomsPayload: any | undefined = undefined; + try { + const rooms = await matchMaker.query({}); + const activeRoomDetails: any[] = []; + const activeCounts: Record = Object.fromEntries(ACTION_EVENTS.map(k => [k, 0])); + for (const room of rooms) { + if (room.name === 'game') { + try { + const detailData: any = await matchMaker.remoteRoomCall(room.roomId, "getState"); + // Map systemMessages to detailed events + const msgs = Array.isArray(detailData?.systemMessages) ? detailData.systemMessages : []; + const systemMessages = msgs.map((m: any) => ({ + kind: String(m?.kind || ''), + round: m?.round != null ? Number(m.round) : undefined, + gameVariant: (m?.gameVariant || m?.variant) ? String(m.gameVariant || m.variant) : undefined, + roomId: String(m?.roomId || room.roomId), + timestamp: m?.timestamp != null ? Number(m.timestamp) : undefined + })); + // Count events present in ACTION_EVENTS + systemMessages.forEach((m: any) => { + if (ACTION_EVENTS.includes(m.kind)) { + activeCounts[m.kind] = (activeCounts[m.kind] || 0) + 1; + } + }); + activeRoomDetails.push({ + roomId: room.roomId, + name: room.name, + metadata: { + gameStatus: room.metadata?.gameStatus || 'unknown', + currentVariant: room.metadata?.currentVariant || detailData?.variant || undefined, + currentRound: room.metadata?.currentRound || detailData?.round || undefined + }, + players: (Array.isArray(detailData?.players) ? detailData.players : []).map((p: any) => ({ + uuid: String(p?.uuid || p?.sessionId || ''), + name: p?.name || null, + color: p?.color || null + })), + systemMessages + }); + } catch (error) { + // Ignore errors per room to not break the stream + } + } + } + activeRoomsPayload = { + rooms: activeRoomDetails, + counts: activeCounts + }; + } catch (e) { + // If querying active rooms fails, omit activeRooms in payload + activeRoomsPayload = undefined; + } + + // Build summary metrics + const playersWithNames = players.filter((p: any) => !!p.name).length; + let totalP1 = 0, cntP1 = 0; + let totalP2 = 0, cntP2 = 0; + players.forEach((p: any) => { + (Array.isArray(p?.roomScoreHistory) ? p.roomScoreHistory : []).forEach((rs: any) => { + (Array.isArray(rs?.scores) ? rs.scores : []).forEach((s: any) => { + if (s?.role === 'P1') { totalP1 += Number(s.score || 0); cntP1 += 1; } + else if (s?.role === 'P2') { totalP2 += Number(s.score || 0); cntP2 += 1; } + }); + }); + }); + const avgP1 = cntP1 > 0 ? totalP1 / cntP1 : 0; + const avgP2 = cntP2 > 0 ? totalP2 / cntP2 : 0; + const overallCnt = cntP1 + cntP2; + const overallAvg = overallCnt > 0 ? (totalP1 + totalP2) / overallCnt : 0; + + const payload = { + version: '1.0', + timestamp: Date.now(), + players, + aggregated: { + detailedEvents: aggregatedDetailedEvents, + counts: aggregatedCounts, + rooms: Object.values(roomsIndex) + }, + activeRooms: activeRoomsPayload, + summary: { + totalPlayers: playersWithNames, + playersWithShame: players.filter((p: any) => Number(p?.shameTokens || 0) > 0).length, + playersWithoutShame: Math.max(0, playersWithNames - players.filter((p: any) => Number(p?.shameTokens || 0) > 0).length), + averageScore: { + p1: Number(avgP1.toFixed(2)), + p2: Number(avgP2.toFixed(2)), + overall: Number(overallAvg.toFixed(2)) + } + } + }; const message = `data: ${JSON.stringify(payload)}\n\n`; if (client) { if (!(client as any).destroyed) client.write(message);