leaderBoard realtime per player and global

This commit is contained in:
2025-08-27 20:18:36 -06:00
parent bbfbd047c6
commit 0f6083453d
2 changed files with 336 additions and 27 deletions

View File

@@ -32,8 +32,9 @@
<label>Jugador:</label>
<select v-model="selectedUuid" @change="loadPlayerHistory" class="select">
<option value=""> Seleccionar </option>
<option v-for="p in players" :key="p.uuid" :value="p.uuid">{{ p.name }} ({{ p.uuid.slice(0,8) }})</option>
<option v-for="p in playersFiltered" :key="p.uuid" :value="p.uuid">{{ p.name }} ({{ p.uuid.slice(0,8) }})</option>
</select>
<input class="search" v-model="search" placeholder="Buscar jugador..." />
</div>
<div v-if="playerLoading" class="placeholder">Cargando historial del jugador</div>
<div v-else-if="!selectedUuid" class="placeholder">Selecciona un jugador para ver sus eventos.</div>
@@ -47,12 +48,28 @@
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Acciones por jugador (total)</h2>
<div class="table-wrapper">
<div class="table">
<div class="thead">
<div class="th name" @click="setSort('name')">Jugador <span class="sort" :class="sortIcon('name')"></span></div>
<div class="th total" @click="setSort('total')">Total <span class="sort" :class="sortIcon('total')"></span></div>
</div>
<div class="tr" v-for="row in sortedPlayersActions" :key="row.uuid">
<div class="td name">{{ row.name || '(sin nombre)' }} <span class="uid">({{ row.uuid.slice(0,8) }})</span></div>
<div class="td total">{{ row.total }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
interface RoomInfo { roomId: string; metadata?: any; }
interface RoomState { players?: any[]; systemMessages?: { kind: string }[] }
@@ -81,10 +98,17 @@ function globalValueLabel(k: string) {
}
const rooms = ref<RoomInfo[]>([]);
const players = ref<{ uuid: string; name: string }[]>([]);
const search = ref('');
const playersFiltered = computed(() => {
const q = (search.value || '').toLowerCase();
if (!q) return players.value;
return players.value.filter(p => (p.name || '').toLowerCase().includes(q) || (p.uuid || '').toLowerCase().includes(q));
});
const selectedUuid = ref('');
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>>>({});
const playerMax = computed(() => {
const vals = EVENTS.map(k => playerEventCounts.value[k] || 0);
const m = Math.max(0, ...vals);
@@ -101,6 +125,111 @@ function playerValueLabel(k: string) {
}
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);
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 || {};
// Recompute global counts
const counts: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
Object.values(details).forEach((d: 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;
});
});
globalEventCounts.value = counts as any;
// Build players list from room details
const playerMap = new Map<string, 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, (p?.name || 'player'));
});
});
const merged = new Map<string, string>();
players.value.forEach(p => merged.set(p.uuid, p.name));
playerMap.forEach((name, uuid) => merged.set(uuid, name));
players.value = Array.from(merged.entries()).map(([uuid, name]) => ({ uuid, name })).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, string>();
players.value.forEach(p => existing.set(p.uuid, p.name));
list.forEach((u: any) => {
const uuid = (u?.uuid || '').toString();
if (!uuid) return;
const name = (u?.name || existing.get(uuid) || 'player').toString();
existing.set(uuid, name);
});
players.value = Array.from(existing.entries()).map(([uuid, name]) => ({ uuid, name })).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.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) }));
// Update detailed counts map
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;
EVENTS.forEach(k => { normalized[k] = Number(src[k] || 0); });
byUuid[uuid] = normalized;
});
playersActionsByUuid.value = byUuid;
// If a player is selected, update playerEventCounts live
if (selectedUuid.value) {
const counts = byUuid[selectedUuid.value];
if (counts) playerEventCounts.value = counts as any;
}
// Merge names into players list
const existing = new Map<string, string>();
players.value.forEach(p => existing.set(p.uuid, p.name));
list.forEach((u: any) => {
const uuid = (u?.uuid || '').toString();
if (!uuid) return;
const name = (u?.name || existing.get(uuid) || 'player').toString();
existing.set(uuid, name);
});
players.value = Array.from(existing.entries()).map(([uuid, name]) => ({ uuid, name })).sort((a,b)=>a.name.localeCompare(b.name));
} catch {}
};
esActions.value.onerror = () => {};
}
async function fetchRooms(): Promise<RoomInfo[]> {
const res = await fetch(`${apiBase}/rooms`);
@@ -145,34 +274,45 @@ async function refreshAll() {
}
async function loadPlayerHistory() {
playerEventCounts.value = Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>;
if (!selectedUuid.value) return;
playerLoading.value = true;
try {
const res = await fetch(`${apiBase}/players/${selectedUuid.value}/history`);
const data = await res.json();
const history = Array.isArray(data?.history) ? data.history : [];
const made = history.filter((h: any) => {
const kind = (h?.kind || '').toString();
if (!kind) return false;
const evRole = kind.startsWith('p1_') ? 'P1' : (kind.startsWith('p2_') ? 'P2' : null);
if (!evRole) return true; // system/agnostic
return ((h?.role || '').toUpperCase() === evRole);
});
made.forEach((h: any) => {
const k = (h?.kind || '').toString();
if (EVENTS.includes(k)) {
playerEventCounts.value[k] = (playerEventCounts.value[k] || 0) + 1;
}
});
} catch {
playerEventCounts.value = Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>;
} finally {
playerLoading.value = false;
const next: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
const counts = playersActionsByUuid.value[selectedUuid.value || ''];
if (counts) {
EVENTS.forEach(k => { next[k] = Number(counts[k] || 0); });
}
playerEventCounts.value = next as any;
playerLoading.value = false;
}
onMounted(refreshAll);
onMounted(setupStreams);
onUnmounted(closeStreams);
// Table of all players actions (from SSE actions stream)
const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]);
const sortKey = ref<'name'|'total'>('total');
const sortDir = ref<'asc'|'desc'>('desc');
function setSort(key: 'name'|'total') {
if (sortKey.value === key) {
sortDir.value = (sortDir.value === 'asc' ? 'desc' : 'asc');
} else {
sortKey.value = key;
sortDir.value = (key === 'name' ? 'asc' : 'desc');
}
}
function sortIcon(key: 'name'|'total') {
if (sortKey.value !== key) return '';
return (sortDir.value === 'asc' ? 'asc' : 'desc');
}
const sortedPlayersActions = computed(() => {
const q = (search.value || '').toLowerCase();
let arr = allPlayersActions.value || [];
if (q) arr = arr.filter(p => (p.name || '').toLowerCase().includes(q) || (p.uuid || '').toLowerCase().includes(q));
return [...arr].sort((a,b) => {
const dir = (sortDir.value === 'asc' ? 1 : -1);
if (sortKey.value === 'name') return a.name.localeCompare(b.name) * dir;
return (a.total - b.total) * dir;
});
});
function friendlyKind(kind: string): string {
const k = (kind || '').toString();
@@ -215,9 +355,21 @@ function friendlyKind(kind: string): string {
.player-filter { display:flex; align-items:center; gap:8px; margin-bottom: 8px; }
.select { padding:6px 8px; border:1px solid #cbd5e1; border-radius:8px; }
.search { padding:6px 8px; border:1px solid #cbd5e1; border-radius:8px; min-width: 180px; }
.player-stats { display:flex; flex-direction:column; gap:10px; }
.events-list { background:#fff; border:1px solid #e5e9f0; border-radius:8px; padding:8px; }
.events-head { font-weight:800; color:#334155; font-size: 13px; margin-bottom: 6px; }
.events-body { display:flex; gap:6px; flex-wrap: wrap; }
.pill { padding:4px 8px; background:#f1f5f9; border:1px solid #cbd5e1; border-radius:999px; font-size:12px; font-weight:700; color:#334155; }
.table-wrapper { overflow:auto; }
.table { border:1px solid #e5e9f0; border-radius:8px; background:#fff; }
.thead { display:grid; grid-template-columns: 1fr 100px; background:#eef2ff; border-bottom:1px solid #e5e9f0; position: sticky; top: 0; z-index: 1; }
.th { padding:8px 10px; font-weight:800; color:#3949ab; cursor:pointer; display:flex; align-items:center; gap:6px; }
.tr { display:grid; grid-template-columns: 1fr 100px; border-bottom:1px solid #f1f5f9; }
.td { padding:8px 10px; }
.td.total { font-weight:800; text-align:right; }
.uid { color:#64748b; font-size:12px; margin-left:6px; }
.sort.asc::after { content:'▲'; font-size:10px; }
.sort.desc::after { content:'▼'; font-size:10px; }
</style>