From 318a7847b8ceca01ca0c75b98da6a2e55605d869 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Thu, 28 Aug 2025 02:47:07 -0600 Subject: [PATCH] leaderboard implementado v3 --- client/src/components/DashboardActions.vue | 15 +- client/src/views/Leaderboard.vue | 201 +++++++++++---------- server/src/adminApi.ts | 37 +++- server/src/index.ts | 3 +- 4 files changed, 150 insertions(+), 106 deletions(-) diff --git a/client/src/components/DashboardActions.vue b/client/src/components/DashboardActions.vue index 4eb9bb5..5273ddd 100644 --- a/client/src/components/DashboardActions.vue +++ b/client/src/components/DashboardActions.vue @@ -248,11 +248,22 @@ async function handleFileUpload(event: Event) { } const result = await response.json(); - alert(`Estado cargado exitosamente. ${result.message || 'NameManager actualizado.'}`); + const message = result.importedUuids + ? `Estado cargado exitosamente. ${result.importedUuids} UUIDs importados. ${result.message || ''}` + : `Estado cargado exitosamente. ${result.message || 'NameManager actualizado.'}`; + alert(message); } catch (error) { console.error('Error uploading nameManager state:', error); - alert('Error al cargar el estado del nameManager. Verifica que el archivo sea válido.'); + + // Handle different error types + if (error.message && error.message.includes('413')) { + alert('Error: El archivo es demasiado grande (máximo 50MB). Considera crear un archivo de guardado más pequeño.'); + } else if (error.message && error.message.includes('400')) { + alert('Error: Archivo inválido. Asegúrate de subir un archivo .snatchSave válido.'); + } else { + alert('Error al cargar el estado del nameManager. Verifica que el archivo sea válido y la conexión sea estable.'); + } } finally { // Reset file input target.value = ''; diff --git a/client/src/views/Leaderboard.vue b/client/src/views/Leaderboard.vue index 0273714..8328f53 100644 --- a/client/src/views/Leaderboard.vue +++ b/client/src/views/Leaderboard.vue @@ -13,8 +13,8 @@ {{ filtersCollapsed ? 'Mostrar filtros' : 'Ocultar filtros' }} - @@ -236,6 +236,11 @@ function computeMetricsFromScores() { if (player.name) { totalPlayersWithNames++; + // Count players with shame tokens + if (player.shameTokens && player.shameTokens > 0) { + playersWithShame++; + } + // Extract scores from roomScoreHistory if available if (player.roomScoreHistory) { player.roomScoreHistory.forEach((roomScore: any) => { @@ -273,13 +278,13 @@ function computeMetricsFromScores() { // Function to compute metrics for a selected player function computeSelectedPlayerMetrics(uuid: string) { const playerData = allPlayersWithScores.value.find(p => p.uuid === uuid); - if (!playerData?.roomScoreHistory) { + if (!playerData) { selectedPlayerMetrics.value = { players_seated: 1, score_p1: 0, score_p2: 0, players_with_shame: 0, - players_without_shame: 0 + players_without_shame: 1 }; return; } @@ -288,30 +293,34 @@ function computeSelectedPlayerMetrics(uuid: string) { let totalP2Scores = 0; let p1Count = 0; let p2Count = 0; + + const hasShame = playerData.shameTokens && playerData.shameTokens > 0; - playerData.roomScoreHistory.forEach((roomScore: any) => { - roomScore.scores.forEach((score: any) => { - // Apply filters to scores - if (!scorePassesFilters(score, roomScore.roomId)) { - return; - } - - if (score.role === 'P1') { - totalP1Scores += score.score; - p1Count++; - } else if (score.role === 'P2') { - totalP2Scores += score.score; - p2Count++; - } + if (playerData.roomScoreHistory) { + playerData.roomScoreHistory.forEach((roomScore: any) => { + roomScore.scores.forEach((score: any) => { + // Apply filters to scores + if (!scorePassesFilters(score, roomScore.roomId)) { + return; + } + + if (score.role === 'P1') { + totalP1Scores += score.score; + p1Count++; + } else if (score.role === 'P2') { + totalP2Scores += score.score; + p2Count++; + } + }); }); - }); + } selectedPlayerMetrics.value = { players_seated: 1, score_p1: p1Count > 0 ? Math.round((totalP1Scores / p1Count) * 10) / 10 : 0, score_p2: p2Count > 0 ? Math.round((totalP2Scores / p2Count) * 10) / 10 : 0, - players_with_shame: 0, // Would need shame data - players_without_shame: 0 + players_with_shame: hasShame ? 1 : 0, + players_without_shame: hasShame ? 0 : 1 }; } @@ -815,86 +824,82 @@ watch(() => playersFiltered.value.length, () => { const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]); -function downloadCSV() { - const currentEvents = eventFilters.currentSourceEvents.value; - - // Create CSV headers - const headers = ['Type', 'Event', 'Count', 'Round', 'GameVariant', 'PlayerUuid', 'PlayerName', 'RoomId', 'DataSource']; - - // Create CSV rows - one row per individual event occurrence - const rows: string[][] = []; - - // Add detailed individual event rows if we have detailed data - currentEvents.forEach(event => { - if (EVENTS.includes(event.kind)) { - rows.push([ - 'event', - event.kind, - '1', - event.round?.toString() || '', - event.gameVariant || '', - event.playerUuid || '', - event.playerName || '', - event.roomId || '', - eventFilters.dataSource.value - ]); - } - }); - - // Also add per-player aggregated data from playersActionsByUuid - Object.entries(playersActionsByUuid.value).forEach(([uuid, counts]) => { - const player = players.value.find(p => p.uuid === uuid); - EVENTS.forEach(eventType => { - const count = counts[eventType] || 0; - if (count > 0) { - // Add one row per occurrence for proper import compatibility - for (let i = 0; i < count; i++) { - rows.push([ - 'event', - eventType, - '1', - '', // Round info not available in aggregated player data - '', // GameVariant info not available in aggregated player data - uuid, - player?.name || '', - '', // Room info not available in aggregated player data - 'player-aggregated' - ]); - } +function downloadJSON() { + // Create a comprehensive snapshot of current interface data + const currentData = { + metadata: { + timestamp: new Date().toISOString(), + dataSource: eventFilters.dataSource.value, + filters: { + room: eventFilters.roomFilter.value, + round: eventFilters.roundFilter.value, + game: eventFilters.gameFilter.value, + hasActiveFilters: eventFilters.hasActiveFilters.value, + filterSummary: eventFilters.filterSummary.value, + selectedPlayer: selectedUuid.value ? { + uuid: selectedUuid.value, + name: players.value.find(p => p.uuid === selectedUuid.value)?.name || 'Jugador' + } : null, + selectedRoom: selectedRoomId.value ? { + roomId: selectedRoomId.value, + name: availableRooms.value.find(r => r.roomId === selectedRoomId.value)?.name || 'Sala' + } : null } - }); - }); + }, + + // Current chart totals being displayed + chartTotals: { + offers: offersTotal.value, + responses: responsesTotal.value, + force: forceTotal.value, + shame: shameTotal.value, + report: reportTotal.value, + averageScore: averageScoreTotal.value, + totalPlayers: totalPlayersCount.value + }, + + // Global event counts (filtered) + globalEventCounts: eventFilters.globalEventCounts.value, + + // Selected player event counts (if applicable) + selectedPlayerEventCounts: selectedUuid.value ? playerEventCounts.value : null, + + // Additional metrics being displayed + additionalMetrics: additionalMetrics.value, + + // Selected player metrics (if applicable) + selectedPlayerMetrics: selectedUuid.value ? selectedPlayerMetrics.value : null, + + // Combined counts used in charts + combinedGlobalCounts: combinedGlobalCounts.value, + combinedPlayerCounts: selectedUuid.value ? combinedPlayerCounts.value : null, + + // Players data + players: players.value, + availableRooms: availableRooms.value, + + // Current filtered data if available + currentSourceEvents: eventFilters.currentSourceEvents.value, + + // Per-player action counts + playersActionsByUuid: playersActionsByUuid.value, + + // All players with scores data + allPlayersWithScores: allPlayersWithScores.value.map(player => ({ + uuid: player.uuid, + name: player.name, + total: player.total, + shameTokens: player.shameTokens, + counts: player.counts, + roomScoreHistory: player.roomScoreHistory + })) + }; - // Add metric data - const currentMetrics = eventFilters.dataSource.value === 'aggregated' - ? additionalMetrics.value - : additionalMetrics.value; // Same source for now - - METRICS.forEach(metricType => { - const count = currentMetrics[metricType] || 0; - if (count > 0) { - rows.push([ - 'metric', - metricType, - count.toString(), - '', - '', - '', - '', - '', - eventFilters.dataSource.value - ]); - } - }); - - // Convert to CSV string - const csvContent = [ - headers.join(','), - ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) - ].join('\n'); + // Convert to formatted JSON string + const jsonString = JSON.stringify(currentData, null, 2); // Create and download file - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.setAttribute('href', url); @@ -903,7 +908,7 @@ function downloadCSV() { const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const dataSourceLabel = eventFilters.dataSource.value === 'aggregated' ? 'agregados' : 'activos'; const filterLabel = eventFilters.hasActiveFilters.value ? `_${eventFilters.filterSummary.value.replace(/\s+/g, '_')}` : ''; - const filename = `datos_${dataSourceLabel}${filterLabel}_${timestamp}.csv`; + const filename = `leaderboard_${dataSourceLabel}${filterLabel}_${timestamp}.json`; link.setAttribute('download', filename); link.style.visibility = 'hidden'; diff --git a/server/src/adminApi.ts b/server/src/adminApi.ts index e4ab28f..291f416 100644 --- a/server/src/adminApi.ts +++ b/server/src/adminApi.ts @@ -938,7 +938,8 @@ async function sendPlayersActionsUpdate(client?: Response) { total, detailedHistory, rawHistory: history, - roomScoreHistory + roomScoreHistory, + shameTokens: nameManager.getShameTokens(uuid) }; }).filter((p: any) => p.total > 0 || p.name); @@ -977,23 +978,49 @@ adminRouter.post("/admin/namemanager/import", async (req: Request, res: Response const nameManager = NameManager.getInstance(); const state = req.body; - if (!state || !state.data) { - return res.status(400).json({ error: 'Invalid state format' }); + // Validate request size and format + if (!state) { + return res.status(400).json({ error: 'No data provided' }); } + if (!state.data) { + return res.status(400).json({ + error: 'Invalid state format - missing data property', + hint: 'Ensure you are uploading a valid .snatchSave file' + }); + } + + console.log(`[AdminAPI] Importing nameManager state - Version: ${state.version || 'unknown'}`); + nameManager.importState(state); // Broadcast update to SSE clients after importing await sendUuidsUpdate(); await sendPlayersActionsUpdate(); + const importedUuids = nameManager.getAllKnownUuids().length; + console.log(`[AdminAPI] Successfully imported nameManager state with ${importedUuids} UUIDs`); + res.json({ success: true, - message: `NameManager state imported successfully. Version: ${state.version || 'unknown'}` + message: `NameManager state imported successfully. Version: ${state.version || 'unknown'}, UUIDs: ${importedUuids}`, + importedUuids }); } catch (error) { console.error('[AdminAPI] Error importing nameManager state:', error); - res.status(500).json({ error: 'Failed to import nameManager state', details: error.message }); + + // Handle specific error types + if (error.message && error.message.includes('PayloadTooLargeError')) { + return res.status(413).json({ + error: 'File too large', + details: 'The save file is too large to process. Maximum size: 50MB' + }); + } + + res.status(500).json({ + error: 'Failed to import nameManager state', + details: error.message + }); } }); diff --git a/server/src/index.ts b/server/src/index.ts index 6860592..7bed675 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,7 +12,8 @@ const port = Number(process.env.PORT) || 3000; const app = express(); app.use(cors()); -app.use(express.json()); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ limit: '50mb', extended: true })); const server = createServer(app); const gameServer = new Server({