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>

View File

@@ -5,7 +5,9 @@ import { NameManager } from "./utils/nameManager";
import { getAllowedUuidCount, listAllowedUuids } from "./utils/uuidRegistry";
// SSE connections storage
const sseClients = new Set<Response>();
const sseClients = new Set<Response>(); // dashboard/rooms stream
const sseUuidClients = new Set<Response>(); // uuids stream
const ssePlayerActionsClients = new Set<Response>(); // per-player actions stream
const adminRouter = Router();
@@ -686,9 +688,164 @@ async function sendDashboardUpdate(client?: Response) {
}
}
// SSE endpoint for real-time UUIDs (allowlist + known names)
adminRouter.get("/uuids-stream", (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no'
});
try { (res as any).flushHeaders?.(); } catch {}
sseUuidClients.add(res);
console.log(`[AdminAPI] UUID SSE client connected. Total: ${sseUuidClients.size}`);
// Initial push
sendUuidsUpdate(res);
req.on('close', () => {
sseUuidClients.delete(res);
console.log(`[AdminAPI] UUID SSE client disconnected. Total: ${sseUuidClients.size}`);
});
const heartbeat = setInterval(() => {
if ((res as any).destroyed) {
clearInterval(heartbeat);
sseUuidClients.delete(res);
return;
}
res.write(':heartbeat\n\n');
}, 30000);
req.on('close', () => clearInterval(heartbeat));
});
async function sendUuidsUpdate(client?: Response) {
try {
const uuids = listAllowedUuids();
const nameManager = NameManager.getInstance();
const payload = {
count: (uuids || []).length,
uuids: (uuids || []).map(uuid => ({
uuid,
name: nameManager.getPlayerName(uuid) || null,
hasName: !!nameManager.getPlayerName(uuid)
}))
};
const message = `data: ${JSON.stringify(payload)}\n\n`;
if (client) {
if (!(client as any).destroyed) client.write(message);
} else {
const dead: Response[] = [];
sseUuidClients.forEach(c => {
if ((c as any).destroyed) dead.push(c);
else {
try { c.write(message); } catch { dead.push(c); }
}
});
dead.forEach(c => sseUuidClients.delete(c));
}
} catch (error) {
console.error('[AdminAPI] Error sending UUIDs SSE update:', error);
}
}
// Function to broadcast dashboard updates (called from room events)
function broadcastDashboardUpdate() {
sendDashboardUpdate();
// Also push UUIDs updates for any name/assignment changes indirectly affected
sendUuidsUpdate();
sendPlayersActionsUpdate();
}
// SSE endpoint for per-player actions made (real-time)
adminRouter.get("/players-actions-stream", (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no'
});
try { (res as any).flushHeaders?.(); } catch {}
ssePlayerActionsClients.add(res);
console.log(`[AdminAPI] Player actions SSE client connected. Total: ${ssePlayerActionsClients.size}`);
sendPlayersActionsUpdate(res);
req.on('close', () => {
ssePlayerActionsClients.delete(res);
console.log(`[AdminAPI] Player actions SSE client disconnected. Total: ${ssePlayerActionsClients.size}`);
});
const heartbeat = setInterval(() => {
if ((res as any).destroyed) {
clearInterval(heartbeat);
ssePlayerActionsClients.delete(res);
return;
}
res.write(':heartbeat\n\n');
}, 30000);
req.on('close', () => clearInterval(heartbeat));
});
function isActionMade(kind: string, role?: string) {
const k = (kind || '').toString();
if (!k) return false;
const prefix = k.slice(0,3).toLowerCase();
if (prefix === 'p1_' || prefix === 'p2_') {
const evRole = prefix === 'p1_' ? 'P1' : 'P2';
return ((role || '').toUpperCase() === evRole);
}
// System/agnostic events are ignored for this stream; only actions list below are counted
return false;
}
const ACTION_EVENTS = [
'p1_propose','p1_no_offer','p2_snatch','p2_accept','p2_force','p2_no_force','p2_reject','p1_shame','p1_no_shame','p1_report','p1_no_report'
];
async function sendPlayersActionsUpdate(client?: Response) {
try {
const nameManager = NameManager.getInstance();
const uuids = nameManager.getAllKnownUuids?.() || [];
const players = uuids.map((uuid: string) => {
const history = nameManager.getSystemHistory(uuid) || [];
const counts: any = Object.fromEntries(ACTION_EVENTS.map(k => [k, 0]));
for (const entry of history) {
const kind = (entry as any)?.kind || '';
if (!ACTION_EVENTS.includes(kind)) continue;
if (!isActionMade(kind, (entry as any)?.role)) continue;
counts[kind] = (counts[kind] || 0) + 1;
}
const total = ACTION_EVENTS.reduce((acc, k) => acc + (counts[k] || 0), 0);
return {
uuid,
name: nameManager.getPlayerName(uuid) || null,
counts,
total
};
}).filter((p: any) => p.total > 0 || p.name);
const payload = { players };
const message = `data: ${JSON.stringify(payload)}\n\n`;
if (client) {
if (!(client as any).destroyed) client.write(message);
} else {
const dead: Response[] = [];
ssePlayerActionsClients.forEach(c => {
if ((c as any).destroyed) dead.push(c);
else { try { c.write(message); } catch { dead.push(c); } }
});
dead.forEach(c => ssePlayerActionsClients.delete(c));
}
} catch (error) {
console.error('[AdminAPI] Error sending players actions SSE update:', error);
}
}
export { adminRouter, broadcastDashboardUpdate };