LeaderBoard creado v1

This commit is contained in:
2025-08-27 19:43:23 -06:00
parent fb07bdab51
commit bbfbd047c6
4 changed files with 311 additions and 12 deletions

View File

@@ -87,35 +87,66 @@ function buildCsvByRoom(): string {
return lines.join('\n') + '\n';
}
function buildCsvByUuid(): string {
async function buildCsvByUuid(): Promise<string> {
const headers = [
'roomId', 'variant', 'round', 'status',
'uuid', 'name', 'role', 'pavo', 'elote', 'shame'
'uuid', 'name', 'role', 'pavo', 'elote', 'shame', 'events_made'
];
const lines: string[] = [headers.join(',')];
gameRooms.value.forEach(room => {
const apiBase = (import.meta as any).env?.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
const historyCache = new Map<string, any[]>();
for (const room of gameRooms.value) {
const det = (props.roomDetails[room.roomId] || {}) as any;
const status = det.gameStatus || room?.metadata?.gameStatus || 'waiting';
const variant = det.variant || room?.metadata?.currentVariant || 'G1';
const round = det.round || room?.metadata?.currentRound || 1;
const players = (det.players || []) as PlayerRow[];
players.forEach(p => {
for (const p of players) {
const uuid = (p?.uuid || '').toString();
let history = uuid ? historyCache.get(uuid) : [];
if (uuid && !history) {
try {
const resp = await fetch(`${apiBase}/players/${uuid}/history`);
const data = await resp.json();
history = Array.isArray(data?.history) ? data.history : [];
} catch {
history = [];
}
historyCache.set(uuid, history);
}
// Build events_made generically:
// - If kind starts with p1_ or p2_, include only when event.role matches P1/P2
// - Otherwise include by default (system/agnostic events)
const events = (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; // role-agnostic
const r = ((h?.role || '') as string).toUpperCase();
return r === evRole;
}).map((h: any) => h.kind);
const eventsHistory = events.join('|');
const row = [
room.roomId,
variant,
round,
status,
p?.uuid || '',
uuid,
p?.name || '',
p?.role || '',
p?.pavoTokens ?? 0,
p?.eloteTokens ?? 0,
p?.shameTokens ?? 0
p?.shameTokens ?? 0,
eventsHistory
].map(csvEscape).join(',');
lines.push(row);
});
});
}
}
return lines.join('\n') + '\n';
}
@@ -137,7 +168,7 @@ function triggerDownload(csv: string, suffix: string) {
}
function downloadCsvByRoom() { triggerDownload(buildCsvByRoom(), 'by-room'); }
function downloadCsvByUuid() { triggerDownload(buildCsvByUuid(), 'by-uuid'); }
async function downloadCsvByUuid() { const csv = await buildCsvByUuid(); triggerDownload(csv, 'by-uuid'); }
</script>
<style scoped>

View File

@@ -4,6 +4,7 @@ import Game from '../views/Game.vue';
import Dashboard from '../views/Dashboard.vue';
import DemoGame from '../views/DemoGame.vue';
import UuidSelector from '../views/UuidSelector.vue';
import Leaderboard from '../views/Leaderboard.vue';
const router = createRouter({
history: createWebHistory(),
@@ -28,6 +29,11 @@ const router = createRouter({
name: 'Dashboard',
component: Dashboard
},
{
path: '/leaderboard',
name: 'Leaderboard',
component: Leaderboard
},
{
path: '/',
name: 'UuidSelector',

View File

@@ -0,0 +1,223 @@
<template>
<div class="leaderboard">
<div class="container">
<div class="header">
<h1>📈 Leaderboard</h1>
<div class="actions">
<button class="btn" @click="refreshAll" :disabled="loading">{{ loading ? 'Actualizando…' : 'Actualizar' }}</button>
<button class="btn toggle" :class="{ active: showPercent }" @click="showPercent = !showPercent" :disabled="loading">
{{ showPercent ? 'Ver conteos' : 'Ver %' }}
</button>
</div>
</div>
<div class="section">
<h2 class="section-title">Eventos globales (detalle por tipo)</h2>
<div v-if="loading" class="placeholder">Cargando datos</div>
<div v-else class="bars">
<div v-for="k in EVENTS" :key="k" class="bar-row">
<span class="label">{{ friendlyKind(k) }}</span>
<div class="bar">
<div class="bar-fill p1" :style="{ width: globalBarWidth(k) + '%' }"></div>
</div>
<span class="value">{{ globalValueLabel(k) }}</span>
</div>
<div class="hint small">Nota: basado en mensajes disponibles por sala.</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Estadísticas por jugador</h2>
<div class="player-filter">
<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>
</select>
</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>
<div v-else class="player-stats">
<div class="bars">
<div class="bar-row" v-for="k in EVENTS" :key="'p-' + k">
<span class="label">{{ friendlyKind(k) }}</span>
<div class="bar"><div class="bar-fill p2" :style="{ width: playerBarWidth(k) + '%' }"></div></div>
<span class="value">{{ playerValueLabel(k) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
interface RoomInfo { roomId: string; metadata?: any; }
interface RoomState { players?: any[]; systemMessages?: { kind: string }[] }
const loading = ref(false);
const 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'
];
const globalEventCounts = ref<Record<string, number>>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>));
const showPercent = ref(false);
const globalMax = computed(() => {
const vals = EVENTS.map(k => globalEventCounts.value[k] || 0);
const m = Math.max(0, ...vals);
return m || 1;
});
const globalTotal = computed(() => EVENTS.reduce((acc, k) => acc + (globalEventCounts.value[k] || 0), 0) || 1);
function globalBarWidth(k: string) {
const v = globalEventCounts.value[k] || 0;
return Math.round((v / (showPercent.value ? globalTotal.value : globalMax.value)) * 100);
}
function globalValueLabel(k: string) {
const v = globalEventCounts.value[k] || 0;
return showPercent.value ? `${Math.round((v / globalTotal.value) * 100)}%` : String(v);
}
const rooms = ref<RoomInfo[]>([]);
const players = ref<{ uuid: string; name: string }[]>([]);
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 playerMax = computed(() => {
const vals = EVENTS.map(k => playerEventCounts.value[k] || 0);
const m = Math.max(0, ...vals);
return m || 1;
});
const playerTotal = computed(() => EVENTS.reduce((acc, k) => acc + (playerEventCounts.value[k] || 0), 0) || 1);
function playerBarWidth(k: string) {
const v = playerEventCounts.value[k] || 0;
return Math.round((v / (showPercent.value ? playerTotal.value : playerMax.value)) * 100);
}
function playerValueLabel(k: string) {
const v = playerEventCounts.value[k] || 0;
return showPercent.value ? `${Math.round((v / playerTotal.value) * 100)}%` : String(v);
}
const apiBase = (import.meta as any).env?.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
async function fetchRooms(): Promise<RoomInfo[]> {
const res = await fetch(`${apiBase}/rooms`);
return await res.json();
}
async function fetchRoomStats(roomId: string): Promise<RoomState> {
const res = await fetch(`${apiBase}/rooms/${roomId}/stats`);
return await res.json();
}
async function refreshAll() {
loading.value = true;
try {
const list = await fetchRooms();
rooms.value = (list || []).filter((r: any) => r?.name === 'game');
// reset counts
globalEventCounts.value = Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>;
const playerMap = new Map<string, string>();
for (const r of rooms.value) {
try {
const s = await fetchRoomStats(r.roomId);
const msgs = (s?.systemMessages || []) as Array<{ kind: string }>;
msgs.forEach(m => {
const k = (m?.kind || '').toString();
if (EVENTS.includes(k)) {
globalEventCounts.value[k] = (globalEventCounts.value[k] || 0) + 1;
}
});
// collect players for filter
(s?.players || []).forEach((p: any) => {
const uuid = (p?.uuid || p?.sessionId || '').toString();
if (uuid && !playerMap.has(uuid)) playerMap.set(uuid, (p?.name || 'player'));
});
} catch {}
}
players.value = Array.from(playerMap.entries()).map(([uuid, name]) => ({ uuid, name }));
} finally {
loading.value = false;
}
}
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;
}
}
onMounted(refreshAll);
function friendlyKind(kind: string): string {
const k = (kind || '').toString();
const map: Record<string, string> = {
p1_propose: 'Ofrecer',
p1_no_offer: 'No Ofrecer',
p2_snatch: 'Robar',
p2_accept: 'Aceptar Oferta',
p2_force: 'Forzar Oferta',
p2_no_force: 'No Forzar Oferta',
p2_reject: 'Rechazar Oferta',
p1_shame: 'Asignar Vergüenza',
p1_no_shame: 'No Asignar Vergüenza',
p1_report: 'Denunciar',
p1_no_report: 'No Denunciar',
};
return map[k] || k;
}
</script>
<style scoped>
.leaderboard { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; }
.container { background:#fff; border-radius: 16px; border:1px solid #e5e9f0; box-shadow: 0 18px 50px rgba(0,0,0,0.35); max-width: 1000px; margin: 0 auto; padding: 16px; }
.header { display:flex; align-items:center; justify-content:space-between; }
.header h1 { margin: 0; }
.actions .btn { background:#667eea; color:#fff; border:none; border-radius:8px; padding:8px 12px; font-weight:700; cursor:pointer; }
.actions .btn.toggle { background:#eef2ff; color:#3949ab; border:1px solid #c7d2fe; }
.actions .btn.toggle.active { background:#3949ab; color:#fff; border-color:#2e3f9a; }
.section { margin-top: 16px; padding: 12px; background:#f8fafc; border:1px solid #e5e9f0; border-radius: 12px; }
.section-title { margin: 0 0 10px; color:#334155; }
.placeholder { color:#64748b; padding: 12px; background:#fff; border:1px dashed #e5e9f0; border-radius:10px; }
.bars { display:flex; flex-direction:column; gap:8px; }
.bar-row { display:grid; grid-template-columns: 180px 1fr 70px; gap:8px; align-items:center; }
.bar { height: 14px; background:#eef2ff; border-radius: 999px; overflow:hidden; border:1px solid #c7d2fe; }
.bar-fill { height: 100%; }
.bar-fill.p1 { background: #667eea; }
.bar-fill.p2 { background: #764ba2; }
.value { font-weight:800; color:#334155; text-align:right; }
.hint.small { font-size: 12px; color:#64748b; }
.player-filter { display:flex; align-items:center; gap:8px; margin-bottom: 8px; }
.select { padding:6px 8px; border:1px solid #cbd5e1; border-radius:8px; }
.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; }
</style>

View File

@@ -37,7 +37,12 @@
<div class="history-modal">
<div class="history-modal-header">
<div class="title">Historial del sistema {{ player.name }}</div>
<button class="close-history" @click.stop="toggleHistory">Cerrar</button>
<div class="header-actions">
<button class="btn-filter" :class="{ active: onlyEventsMade }" @click.stop="onlyEventsMade = !onlyEventsMade" :title="onlyEventsMade ? 'Mostrando solo eventos hechos por el jugador' : 'Mostrar solo eventos hechos por el jugador'">
{{ onlyEventsMade ? 'Filtro: activo' : 'Filtro: eventos del jugador' }}
</button>
<button class="close-history" @click.stop="toggleHistory">Cerrar</button>
</div>
</div>
<div v-if="loadingHistory" class="history-loading">Cargando</div>
<div v-else-if="!historyItems.length" class="history-empty">Sin historial</div>
@@ -51,11 +56,11 @@
<span class="th x">Mensaje</span>
<span class="th room">Sala</span>
</div>
<div v-for="m in historyItems" :key="m.timestamp + '-' + (m.kind||'')" class="history-row history-grid">
<div v-for="m in filteredHistory" :key="m.timestamp + '-' + (m.kind||'')" class="history-row history-grid">
<span class="t">{{ fmtTime(m.timestamp) }}</span>
<span class="r">{{ (m.role || '') || '—' }}</span>
<span class="tok">🦃 {{ m.pavoTokens ?? 0 }} · 🌽 {{ m.eloteTokens ?? 0 }} <span v-if="(m.shameTokens ?? 0) > 0">· 😶 {{ m.shameTokens }}</span></span>
<span class="k">{{ m.kind }}</span>
<span class="k">{{ friendlyKind(m.kind) }}</span>
<span class="x">{{ m.text }}</span>
<span class="room">{{ (m.roomId || '').slice(0,8) }}</span>
</div>
@@ -92,6 +97,19 @@ const primary = computed(() => props.player.color || '#667eea');
const showHistory = ref(false);
const loadingHistory = ref(false);
const historyItems = ref<any[]>([]);
const onlyEventsMade = ref(false);
const filteredHistory = computed(() => {
if (!onlyEventsMade.value) return historyItems.value || [];
const list = Array.isArray(historyItems.value) ? historyItems.value : [];
return list.filter((h: any) => {
const kind = (h?.kind || '').toString();
if (!kind) return false;
const prefix = kind.slice(0,3).toLowerCase();
if (prefix === 'p1_') return ((h?.role || '').toUpperCase() === 'P1');
if (prefix === 'p2_') return ((h?.role || '').toUpperCase() === 'P2');
return true; // system/agnostic events
});
});
const room = computed(() => colyseusService.gameRoom.value as any);
function toggleHistory() {
@@ -155,6 +173,24 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
});
function friendlyKind(kind: string): string {
const k = (kind || '').toString();
const map: Record<string, string> = {
p1_propose: 'Ofrecer',
p1_no_offer: 'No Ofrecer',
p2_snatch: 'Robar',
p2_accept: 'Aceptar Oferta',
p2_force: 'Forzar Oferta',
p2_no_force: 'No Forzar Oferta',
p2_reject: 'Rechazar Oferta',
p1_shame: 'Asignar Vergüenza',
p1_no_shame: 'No Asignar Vergüenza',
p1_report: 'Denunciar',
p1_no_report: 'No Denunciar',
};
return map[k] || k;
}
</script>
<style scoped>
@@ -175,6 +211,9 @@ onBeforeUnmount(() => {
.history-modal { width: min(900px, 94vw); background:#fff; border-radius:12px; border:1px solid #e5e9f0; box-shadow: 0 30px 80px rgba(0,0,0,0.45); }
.history-modal-header { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:1px solid #e5e9f0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color:#fff; border-top-left-radius:12px; border-top-right-radius:12px; }
.history-modal-header .title { font-weight:800; font-size:14px; }
.history-modal-header .header-actions { display:flex; align-items:center; gap:8px; }
.btn-filter { background:#eef2ff; color:#3949ab; border:1px solid #c7d2fe; border-radius:8px; padding:6px 10px; font-weight:700; cursor:pointer; }
.btn-filter.active { background:#3949ab; color:#fff; border-color:#2e3f9a; }
.close-history { background:#fff; color:#3949ab; border:1px solid #c7d2fe; border-radius:8px; padding:6px 10px; font-weight:700; cursor:pointer; }
.history-loading, .history-empty { font-size:12px; color:#666; padding:6px; text-align:center; }
.history-table {}