leaderboard implementado v3
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<span class="collapse-text">{{ filtersCollapsed ? 'Mostrar filtros' : 'Ocultar filtros' }}</span>
|
||||
</button>
|
||||
<button class="btn" @click="refreshAll" :disabled="loading">{{ loading ? 'Actualizando…' : 'Actualizar' }}</button>
|
||||
<button class="btn" @click="downloadCSV" :disabled="loading" title="Descargar datos actuales como CSV">
|
||||
📊 CSV
|
||||
<button class="btn" @click="downloadJSON" :disabled="loading" title="Descargar datos actuales como JSON">
|
||||
📊 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -289,29 +294,33 @@ function computeSelectedPlayerMetrics(uuid: string) {
|
||||
let p1Count = 0;
|
||||
let p2Count = 0;
|
||||
|
||||
playerData.roomScoreHistory.forEach((roomScore: any) => {
|
||||
roomScore.scores.forEach((score: any) => {
|
||||
// Apply filters to scores
|
||||
if (!scorePassesFilters(score, roomScore.roomId)) {
|
||||
return;
|
||||
}
|
||||
const hasShame = playerData.shameTokens && playerData.shameTokens > 0;
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Add metric data
|
||||
const currentMetrics = eventFilters.dataSource.value === 'aggregated'
|
||||
? additionalMetrics.value
|
||||
: additionalMetrics.value; // Same source for now
|
||||
// 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
|
||||
},
|
||||
|
||||
METRICS.forEach(metricType => {
|
||||
const count = currentMetrics[metricType] || 0;
|
||||
if (count > 0) {
|
||||
rows.push([
|
||||
'metric',
|
||||
metricType,
|
||||
count.toString(),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
eventFilters.dataSource.value
|
||||
]);
|
||||
}
|
||||
});
|
||||
// Global event counts (filtered)
|
||||
globalEventCounts: eventFilters.globalEventCounts.value,
|
||||
|
||||
// Convert to CSV string
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
// 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
|
||||
}))
|
||||
};
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user