leaderBoard realtime per player and global
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user