mejorando leaderboard

This commit is contained in:
2025-08-29 10:49:54 -06:00
parent 90eb4aae2b
commit 4dc3d40123
2 changed files with 439 additions and 261 deletions

View File

@@ -21,9 +21,66 @@
<div class="filters-section" :class="{ collapsed: filtersCollapsed }">
<Transition name="filters-slide">
<div v-if="!filtersCollapsed" class="filters-content">
<DataSourceSelector v-model="eventFilters.dataSource.value" />
<div v-if="!filtersCollapsed" class="filters-content"></div>
</Transition>
</div>
<Transition name="filters-slide">
<div v-if="!filtersCollapsed" class="controls glass light">
<!-- Selector de periodo: Salas activas vs Rango de tiempo -->
<div class="time-selector glass light">
<div class="mode-buttons">
<button
class="mode-btn"
:class="{ active: timeMode === 'active' }"
@click="setTimeMode('active')"
title="Mostrar solo salas activas (tiempo real)"
>
🔴 Salas activas
</button>
<button
class="mode-btn"
:class="{ active: timeMode === 'range' }"
@click="setTimeMode('range')"
title="Filtrar por rango de fecha y hora"
>
📅 Rango de tiempo
</button>
</div>
<div class="range-inputs" :class="{ disabled: timeMode !== 'range' }">
<label>
Desde
<input type="datetime-local" v-model="rangeFromStr" :disabled="timeMode !== 'range'" @change="applyTimeMode" />
</label>
<label>
Hasta
<input type="datetime-local" v-model="rangeToStr" :disabled="timeMode !== 'range' || liveEnd" @change="applyTimeMode" />
</label>
<button class="live-btn" :class="{ active: liveEnd }" :disabled="timeMode !== 'range'" @click="toggleLiveEnd">
Hasta ahora
</button>
<div class="quick">
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="quickRange(5)">5m</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="quickRange(10)">10m</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="quickRange(30)">30m</button>
<button class="qs-btn" :disabled="timeMode !== 'range'" @click="quickRange(60)">1h</button>
</div>
</div>
</div>
<div class="legend">
<span class="key global"></span> Global
<span class="sep">·</span>
<span class="key round"></span> Ronda
<span class="sep">·</span>
<span class="key game"></span> Juego
<span class="sep" v-if="selectedUuids.length">·</span>
<span class="key player" v-if="selectedUuids.length"></span> Jugadores
<span class="sep" v-if="selectedRoomIds.length">·</span>
<span class="key room" v-if="selectedRoomIds.length"></span> Salas
</div>
<!-- Round/Game filters colocated with player filter -->
<div class="secondary-filters">
<EventFilters
:round-filter="eventFilters.roundFilter.value"
:game-filter="eventFilters.gameFilter.value"
@@ -34,18 +91,6 @@
@reset-filters="eventFilters.resetFilters"
/>
</div>
</Transition>
</div>
<Transition name="filters-slide">
<div v-if="!filtersCollapsed" class="controls glass light">
<div class="legend">
<span class="key global"></span> Global
<span class="sep">·</span>
<span class="key player" v-if="selectedUuids.length"></span> Jugadores
<span class="sep" v-if="selectedRoomIds.length">·</span>
<span class="key room" v-if="selectedRoomIds.length"></span> Salas
</div>
<div class="player-chips">
<div class="search-controls">
<input class="search" v-model="search" placeholder="Buscar jugador…" />
@@ -72,31 +117,7 @@
</div>
</div>
<!-- Room Filter Section: slice controls -->
<div class="room-slice">
<div class="slice-header">
<div class="slice-info">
<span class="total">Salas totales: {{ availableRooms.length }}</span>
<span v-if="availableRooms.length" class="slice-summary">
Analizando {{ roomSliceIds.length }} ({{ sliceStartLabel }}{{ sliceEndLabel }})
</span>
</div>
<button v-if="selectedRoomIds.length" class="chip clear" @click="clearRooms">Quitar selección</button>
</div>
<div class="dual-slider">
<div class="track"></div>
<div class="highlight" :style="{ left: startPct + '%', width: (endPct - startPct) + '%' }"></div>
<input class="range start" type="range" min="0" :max="maxIndex" v-model.number="roomSliceStart" @input="onSliceStart" />
<input class="range end" type="range" min="0" :max="maxIndex" v-model.number="roomSliceEnd" @input="onSliceEnd" />
</div>
<div class="quick-select">
<button class="qs-btn" @click="selectRecent(1)" title="Más reciente"></button>
<button class="qs-btn" @click="selectRecent(5)" title="5 recientes">5</button>
<button class="qs-btn" @click="selectRecent(10)" title="10 recientes">10</button>
<button class="qs-btn" @click="selectRecent(25)" title="25 recientes">25</button>
<button class="qs-btn" @click="addRecent(10)" title="Agregar 10 más">+10</button>
</div>
</div>
<!-- Room slice UI removida: reemplazada por rango de tiempo/activas -->
</div>
</Transition>
@@ -121,6 +142,18 @@
totalPlayers: totalPlayersCount
}"
/>
<!-- Raw payload viewer for players-actions-stream -->
<div class="raw-viewer glass light" v-if="rawActionsPayload">
<div class="raw-header">
<div class="raw-title">Datos del stream (players-actions-stream)</div>
<div class="raw-actions">
<button class="btn" @click="showRaw = !showRaw">{{ showRaw ? 'Ocultar' : 'Mostrar' }}</button>
<button class="btn" @click="copyRaw" title="Copiar JSON">Copiar</button>
</div>
</div>
<pre v-show="showRaw" class="raw-pre">{{ prettyRaw }}</pre>
</div>
<AppCredits position="bottom-right" />
</div>
</template>
@@ -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<TimeMode>('range');
const rangeFromStr = ref<string>('');
const rangeToStr = ref<string>('');
const liveEnd = ref<boolean>(true);
let liveTimer: any = null;
const fullAggregatedEvents = ref<Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string; playerUuid?: string; timestamp?: number }>>([]);
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<string, number> = 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<string, number> = {};
const ids = new Set<string>();
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<string[]>([]);
const selectedRoomIds = ref<string[]>([]);
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<Record<string, number>>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>));
const playersActionsByUuid = ref<Record<string, Record<string, number>>>({});
@@ -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<EventSource|null>(null);
const esUuids = ref<EventSource|null>(null);
const esActions = ref<EventSource|null>(null);
const rawActionsPayload = ref<any>(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<string, number> = 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<string, { name: string; color?: string }>();
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<string, { name: string; color?: string }>();
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<string, { name: string; color?: string }>();
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<string, number> = 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<string, string|undefined>();
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<string, Record<string, number>> = {};
list.forEach((p: any) => {
const uuid = String(p?.uuid || '');
if (!uuid) return;
const src = p?.counts || {};
const normalized: Record<string, number> = 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<string>();
const lastSeenIndex: Record<string, number> = {};
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<string, number> = {};
const ids = new Set<string>();
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<string, { name: string; color?: string }>();
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 {

View File

@@ -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<string, number> = 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<string, { roomId: string; lastSeenIndex: number; lastEventAt?: number; messageCount: number }> = {};
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<string, number> = 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);