mejorando leaderboard
This commit is contained in:
@@ -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 || '{}');
|
||||
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) }));
|
||||
rawActionsPayload.value = data;
|
||||
|
||||
// Store complete player data with room score history
|
||||
const list = Array.isArray(data?.players) ? data.players : [];
|
||||
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
|
||||
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;
|
||||
// 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));
|
||||
|
||||
// Update detailed counts map
|
||||
// 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
|
||||
// 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
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
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') {
|
||||
// Aplicar filtros según dataSource actual
|
||||
eventFilters.applyFilters(EVENTS);
|
||||
}
|
||||
// If a player is selected, update playerEventCounts live
|
||||
// Update selected players combined counts
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user