mejorando leaderboard
This commit is contained in:
@@ -21,9 +21,66 @@
|
|||||||
<div class="filters-section" :class="{ collapsed: filtersCollapsed }">
|
<div class="filters-section" :class="{ collapsed: filtersCollapsed }">
|
||||||
|
|
||||||
<Transition name="filters-slide">
|
<Transition name="filters-slide">
|
||||||
<div v-if="!filtersCollapsed" class="filters-content">
|
<div v-if="!filtersCollapsed" class="filters-content"></div>
|
||||||
<DataSourceSelector v-model="eventFilters.dataSource.value" />
|
</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
|
<EventFilters
|
||||||
:round-filter="eventFilters.roundFilter.value"
|
:round-filter="eventFilters.roundFilter.value"
|
||||||
:game-filter="eventFilters.gameFilter.value"
|
:game-filter="eventFilters.gameFilter.value"
|
||||||
@@ -34,18 +91,6 @@
|
|||||||
@reset-filters="eventFilters.resetFilters"
|
@reset-filters="eventFilters.resetFilters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="player-chips">
|
||||||
<div class="search-controls">
|
<div class="search-controls">
|
||||||
<input class="search" v-model="search" placeholder="Buscar jugador…" />
|
<input class="search" v-model="search" placeholder="Buscar jugador…" />
|
||||||
@@ -72,31 +117,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room Filter Section: slice controls -->
|
<!-- Room slice UI removida: reemplazada por rango de tiempo/activas -->
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
@@ -121,6 +142,18 @@
|
|||||||
totalPlayers: totalPlayersCount
|
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" />
|
<AppCredits position="bottom-right" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -130,7 +163,6 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import EventChart from '../components/EventChart.vue';
|
import EventChart from '../components/EventChart.vue';
|
||||||
import EventFilters from '../components/EventFilters.vue';
|
import EventFilters from '../components/EventFilters.vue';
|
||||||
import DataSourceSelector from '../components/DataSourceSelector.vue';
|
|
||||||
import GameLogo from '../components/GameLogo.vue';
|
import GameLogo from '../components/GameLogo.vue';
|
||||||
import AppCredits from '../components/AppCredits.vue';
|
import AppCredits from '../components/AppCredits.vue';
|
||||||
import { useEventFilters } from '../composables/useEventFilters';
|
import { useEventFilters } from '../composables/useEventFilters';
|
||||||
@@ -141,6 +173,95 @@ const loading = ref(false);
|
|||||||
const eventFilters = useEventFilters();
|
const eventFilters = useEventFilters();
|
||||||
const filtersCollapsed = ref(false);
|
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 = [
|
const EVENTS = [
|
||||||
'p1_propose', 'p1_no_offer',
|
'p1_propose', 'p1_no_offer',
|
||||||
@@ -477,46 +598,6 @@ const playersPage = computed(() => {
|
|||||||
|
|
||||||
const selectedUuids = ref<string[]>([]);
|
const selectedUuids = ref<string[]>([]);
|
||||||
const selectedRoomIds = 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 playerLoading = ref(false);
|
||||||
const playerEventCounts = ref<Record<string, number>>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>));
|
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>>>({});
|
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() {
|
function goHome() {
|
||||||
router.push('/');
|
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 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 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() {
|
function closeStreams() {
|
||||||
try { esRooms.value?.close(); } catch {}
|
|
||||||
try { esUuids.value?.close(); } catch {}
|
|
||||||
try { esActions.value?.close(); } catch {}
|
try { esActions.value?.close(); } catch {}
|
||||||
esRooms.value = null;
|
|
||||||
esUuids.value = null;
|
|
||||||
esActions.value = null;
|
esActions.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStreams() {
|
function setupStreams() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
// Rooms stream
|
|
||||||
closeStreams();
|
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
|
// Único stream: players-actions-stream
|
||||||
|
|
||||||
// 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
|
|
||||||
esActions.value = new EventSource(`${apiBase}/players-actions-stream`);
|
esActions.value = new EventSource(`${apiBase}/players-actions-stream`);
|
||||||
esActions.value.onmessage = (e) => {
|
esActions.value.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse((e as MessageEvent).data || '{}');
|
const data = JSON.parse((e as MessageEvent).data || '{}');
|
||||||
const list = Array.isArray(data?.players) ? data.players : [];
|
rawActionsPayload.value = data;
|
||||||
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
|
const list = Array.isArray(data?.players) ? data.players : [];
|
||||||
allPlayersWithScores.value = list;
|
allPlayersWithScores.value = list;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Collect all detailed events from all players
|
// Lista de jugadores para chips con color (si disponible)
|
||||||
const allDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
|
players.value = list
|
||||||
const aggregatedCounts: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
.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));
|
||||||
|
|
||||||
// Update detailed counts map
|
// Mapear counts por jugador
|
||||||
const byUuid: Record<string, Record<string, number>> = {};
|
const byUuid: Record<string, Record<string, number>> = {};
|
||||||
list.forEach((p: any) => {
|
list.forEach((p: any) => {
|
||||||
const uuid = String(p?.uuid || '');
|
const uuid = String(p?.uuid || '');
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
const src = p?.counts || {};
|
const src = p?.counts || {};
|
||||||
const normalized: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
const normalized: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
||||||
|
EVENTS.forEach(k => { normalized[k] = Number(src[k] || 0); });
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
byUuid[uuid] = normalized;
|
byUuid[uuid] = normalized;
|
||||||
});
|
});
|
||||||
|
|
||||||
playersActionsByUuid.value = byUuid;
|
playersActionsByUuid.value = byUuid;
|
||||||
eventFilters.updateAggregatedData(allDetailedEvents, aggregatedCounts);
|
|
||||||
|
|
||||||
// Extract unique room IDs from aggregated events and track "newness" by last seen index
|
// Datos agregados desde el payload
|
||||||
const roomIds = new Set<string>();
|
const aggEvents = Array.isArray(data?.aggregated?.detailedEvents) ? data.aggregated.detailedEvents : [];
|
||||||
const lastSeenIndex: Record<string, number> = {};
|
// Store full events with timestamps for range filtering
|
||||||
allDetailedEvents.forEach((event, idx) => {
|
fullAggregatedEvents.value = aggEvents as any;
|
||||||
const rid = (event.roomId || '').trim();
|
// Apply current time mode (range/active)
|
||||||
if (!rid) return;
|
applyTimeMode();
|
||||||
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)
|
// Datos de salas activas → eventos activos y counts
|
||||||
availableRooms.value = Array.from(roomIds)
|
if (data?.activeRooms?.rooms) {
|
||||||
.map(roomId => ({
|
const activeDetailed: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
|
||||||
roomId,
|
(data.activeRooms.rooms || []).forEach((r: any) => {
|
||||||
name: `Sala ${roomId.slice(0, 8)}`,
|
(Array.isArray(r?.systemMessages) ? r.systemMessages : []).forEach((m: any) => {
|
||||||
playerCount: allDetailedEvents.filter(e => e.roomId === roomId).length,
|
const k = String(m?.kind || '');
|
||||||
_lastSeen: lastSeenIndex[roomId] ?? -1
|
if (EVENTS.includes(k)) {
|
||||||
}))
|
activeDetailed.push({
|
||||||
.sort((a, b) => (a._lastSeen - b._lastSeen))
|
kind: k,
|
||||||
.map(({ _lastSeen, ...rest }) => rest);
|
round: m?.round,
|
||||||
// Initialize slice window: full range by default, preserve user selection if present
|
gameVariant: m?.gameVariant || m?.variant,
|
||||||
if (availableRooms.value.length > 0 && selectedRoomIds.value.length === 0) {
|
roomId: m?.roomId || r?.roomId
|
||||||
roomSliceStart.value = 0;
|
});
|
||||||
roomSliceEnd.value = availableRooms.value.length - 1;
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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();
|
computeMetricsFromScores();
|
||||||
|
|
||||||
// Apply filters and update display if viewing aggregated data
|
// Aplicar filtros según dataSource actual
|
||||||
if (eventFilters.dataSource.value === 'aggregated') {
|
eventFilters.applyFilters(EVENTS);
|
||||||
eventFilters.applyFilters(EVENTS);
|
|
||||||
}
|
// Actualizar conteos del/los jugador(es) seleccionado(s)
|
||||||
// If a player is selected, update playerEventCounts live
|
|
||||||
// Update selected players combined counts
|
|
||||||
updateSelectedPlayersCounts();
|
updateSelectedPlayersCounts();
|
||||||
// Merge names into players list; preserve colors from previous streams
|
|
||||||
const existing = new Map<string, { name: string; color?: string }>();
|
} finally {
|
||||||
players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color }));
|
loading.value = false;
|
||||||
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 {}
|
|
||||||
};
|
};
|
||||||
esActions.value.onerror = () => {};
|
esActions.value.onerror = () => {};
|
||||||
}
|
}
|
||||||
@@ -818,11 +836,17 @@ onMounted(() => {
|
|||||||
// Set initial container width and add resize listener
|
// Set initial container width and add resize listener
|
||||||
updateContainerWidth();
|
updateContainerWidth();
|
||||||
window.addEventListener('resize', 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(() => {
|
onUnmounted(() => {
|
||||||
closeStreams();
|
closeStreams();
|
||||||
window.removeEventListener('resize', updateContainerWidth);
|
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
|
// 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
|
// 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() {
|
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.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.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.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; }
|
.sep { opacity: 0.6; }
|
||||||
|
|
||||||
.player-chips, .room-chips {
|
.player-chips, .room-chips {
|
||||||
@@ -1019,6 +1046,7 @@ function downloadJSON() {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
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 { 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: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); }
|
.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;
|
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 styles */
|
||||||
.filters-section {
|
.filters-section {
|
||||||
|
|||||||
@@ -909,6 +909,7 @@ async function sendPlayersActionsUpdate(client?: Response) {
|
|||||||
const round = (entry as any)?.round;
|
const round = (entry as any)?.round;
|
||||||
const gameVariant = (entry as any)?.gameVariant || (entry as any)?.variant;
|
const gameVariant = (entry as any)?.gameVariant || (entry as any)?.variant;
|
||||||
const role = (entry as any)?.role;
|
const role = (entry as any)?.role;
|
||||||
|
const timestamp = (entry as any)?.timestamp;
|
||||||
|
|
||||||
if (!ACTION_EVENTS.includes(kind)) continue;
|
if (!ACTION_EVENTS.includes(kind)) continue;
|
||||||
if (!isActionMade(kind, role)) continue;
|
if (!isActionMade(kind, role)) continue;
|
||||||
@@ -919,7 +920,8 @@ async function sendPlayersActionsUpdate(client?: Response) {
|
|||||||
kind,
|
kind,
|
||||||
round,
|
round,
|
||||||
gameVariant,
|
gameVariant,
|
||||||
roomId
|
roomId,
|
||||||
|
timestamp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,7 +949,136 @@ async function sendPlayersActionsUpdate(client?: Response) {
|
|||||||
};
|
};
|
||||||
}).filter((p: any) => p.total > 0 || p.name);
|
}).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`;
|
const message = `data: ${JSON.stringify(payload)}\n\n`;
|
||||||
if (client) {
|
if (client) {
|
||||||
if (!(client as any).destroyed) client.write(message);
|
if (!(client as any).destroyed) client.write(message);
|
||||||
|
|||||||
Reference in New Issue
Block a user