Dashboard real-time updates con SSE y tabla de rooms

- Implementación de Server-Sent Events (SSE) para actualizaciones en tiempo real
- Nuevo componente RoomsTable para vista de águila con animaciones de tokens
- Componente RoomCard extraído para reutilización
- Modal para ver detalles de rooms
- SystemMessageDisplay usando AnimatedNumber creativamente
- Indicador de conexión SSE con fallback a polling
- Colores dinámicos de texto basados en brillo del fondo
- Backend: broadcast de actualizaciones del dashboard
- Backend: mensajes del sistema (excepto cambios de ronda) visibles en dashboard
- Configuración de Vite para acceso desde red local
This commit is contained in:
2025-08-11 23:07:09 -06:00
parent deb63d4e38
commit 32f69805f0
9 changed files with 1689 additions and 265 deletions

View File

@@ -2,6 +2,9 @@ import { Request, Response, Router } from "express";
import { matchMaker } from "colyseus";
import { GameRoom } from "./rooms/GameRoom";
// SSE connections storage
const sseClients = new Set<Response>();
const adminRouter = Router();
adminRouter.get("/rooms", async (req: Request, res: Response) => {
@@ -126,4 +129,130 @@ adminRouter.get("/stats", async (req: Request, res: Response) => {
}
});
export { adminRouter };
// SSE endpoint for real-time dashboard updates
adminRouter.get("/dashboard-stream", (req: Request, res: Response) => {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
// Add client to our set
sseClients.add(res);
console.log(`[AdminAPI] SSE client connected. Total clients: ${sseClients.size}`);
// Send initial data
sendDashboardUpdate(res);
// Handle client disconnect
req.on('close', () => {
sseClients.delete(res);
console.log(`[AdminAPI] SSE client disconnected. Total clients: ${sseClients.size}`);
});
// Keep connection alive with periodic heartbeat
const heartbeat = setInterval(() => {
if (res.destroyed) {
clearInterval(heartbeat);
sseClients.delete(res);
return;
}
res.write(':heartbeat\n\n');
}, 30000); // 30 seconds
req.on('close', () => {
clearInterval(heartbeat);
});
});
// Function to send dashboard data to SSE clients
async function sendDashboardUpdate(client?: Response) {
try {
const rooms = await matchMaker.query({});
const roomStats = rooms.map(room => ({
roomId: room.roomId,
name: room.name,
clients: room.clients,
maxClients: room.maxClients,
metadata: room.metadata,
locked: room.locked,
private: room.private,
createdAt: room.createdAt
}));
// Get detailed stats for all game rooms
const roomDetails: { [key: string]: any } = {};
for (const room of rooms) {
if (room.name === 'game') {
try {
const detailData = await matchMaker.remoteRoomCall(room.roomId, "getState");
roomDetails[room.roomId] = detailData;
} catch (error) {
console.warn(`[AdminAPI] Failed to get details for room ${room.roomId}:`, error);
// Set empty details if room call fails
roomDetails[room.roomId] = {
players: [],
gameStatus: room.metadata?.gameStatus || 'waiting',
variant: room.metadata?.currentVariant || 'G1',
round: room.metadata?.currentRound || 1
};
}
}
}
const stats = await matchMaker.stats.fetchAll();
const globalCCU = await matchMaker.stats.getGlobalCCU();
const dashboardData = {
rooms: roomStats,
roomDetails: roomDetails,
globalStats: {
processes: stats,
globalCCU,
localCCU: matchMaker.stats.local.ccu,
localRoomCount: matchMaker.stats.local.roomCount
}
};
const message = `data: ${JSON.stringify(dashboardData)}\n\n`;
if (client) {
// Send to specific client (for initial connection)
if (!client.destroyed) {
client.write(message);
}
} else {
// Broadcast to all clients
const deadClients: Response[] = [];
sseClients.forEach(client => {
if (client.destroyed) {
deadClients.push(client);
} else {
try {
client.write(message);
} catch (error) {
console.error('[AdminAPI] Error writing to SSE client:', error);
deadClients.push(client);
}
}
});
// Clean up dead connections
deadClients.forEach(client => sseClients.delete(client));
}
} catch (error) {
console.error('[AdminAPI] Error sending dashboard update:', error);
}
}
// Function to broadcast dashboard updates (called from room events)
function broadcastDashboardUpdate() {
sendDashboardUpdate();
}
export { adminRouter, broadcastDashboardUpdate };

View File

@@ -2,27 +2,45 @@ import { Room, Client } from "colyseus";
import { GameState } from "./schemas/GameState";
import { GameStatus } from "../../../shared/types";
import { NameManager } from "../utils/nameManager";
import { broadcastDashboardUpdate } from "../adminApi";
export class GameRoom extends Room<GameState> {
maxClients = 2;
private gameInterval?: NodeJS.Timeout;
private recentSystemMessage: { text: string; kind: string; timestamp: number } | null = null;
private sysChat(text: string, kind: string) {
const timestamp = Date.now();
// Store the most recent system message for dashboard (exclude round changes)
if (kind !== 'round_advance') {
this.recentSystemMessage = { text, kind, timestamp };
}
this.broadcast("chat", {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: `${timestamp}-${Math.random().toString(36).slice(2)}`,
text,
from: "Sistema",
fromId: "system",
ts: Date.now(),
ts: timestamp,
kind,
} as any);
// Notify dashboard immediately after system message
setTimeout(() => {
broadcastDashboardUpdate();
}, 50);
}
onCreate(options: any) {
this.setState(new GameState());
this.state.roomId = this.roomId;
// Expose status via metadata for lobby listing
this.setMetadata({ gameStatus: 'waiting' });
this.setMetadata({
gameStatus: 'waiting',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
// Variant selection (both players can change)
this.onMessage("setVariant", (client, variant: string) => {
@@ -34,6 +52,12 @@ export class GameRoom extends Room<GameState> {
if (this.state.gameStatus === GameStatus.FINISHED) {
this.state.gameStatus = GameStatus.PLAYING;
}
// Update metadata with new variant and round
this.setMetadata({
gameStatus: this.state.gameStatus === GameStatus.WAITING ? 'waiting' : 'playing',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
// G2: Force offer by default
if (variant === 'G2') {
this.state.forcedByP2 = true;
@@ -145,6 +169,9 @@ export class GameRoom extends Room<GameState> {
const rE = this.state.requestElote;
if (p2.pavoTokens >= rP) { p2.pavoTokens -= rP; p1.pavoTokens += rP; }
if (p2.eloteTokens >= rE) { p2.eloteTokens -= rE; p1.eloteTokens += rE; }
// Notify dashboard of token changes
broadcastDashboardUpdate();
}
// Clear offer now
this.clearOffer();
@@ -179,7 +206,11 @@ export class GameRoom extends Room<GameState> {
if (assign && this.state.currentVariant === "G3" && this.state.p2Action === "snatch") {
// increment P2 shame immediately
const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined;
if (p2) p2.shameTokens += 1;
if (p2) {
p2.shameTokens += 1;
// Notify dashboard of token change
broadcastDashboardUpdate();
}
}
// System chat feedback
if (assign) this.sysChat('😶 P1 asignó un token de vergüenza a P2', 'p1_shame');
@@ -232,6 +263,16 @@ export class GameRoom extends Room<GameState> {
roomId: this.roomId
});
// System message for player join
if (this.state.players.size === 1) {
this.sysChat(`👋 ${playerName} se unió - esperando oponente`, 'player_join');
} else if (this.state.players.size === 2) {
this.sysChat(`🎯 Todos los jugadores conectados`, 'players_ready');
}
// Notify dashboard of player join
broadcastDashboardUpdate();
if (this.state.players.size === 2 && this.state.gameStatus === GameStatus.WAITING) {
this.startGame();
}
@@ -246,6 +287,9 @@ export class GameRoom extends Room<GameState> {
// Don't release the name here - it's managed by the LobbyRoom
}
// Notify dashboard of player leave
broadcastDashboardUpdate();
if (this.state.gameStatus === GameStatus.PLAYING) {
if (this.getConnectedPlayersCount() < 2) {
this.pauseGame();
@@ -265,7 +309,13 @@ export class GameRoom extends Room<GameState> {
} catch {}
if (this.state.gameStatus === GameStatus.PAUSED && this.getConnectedPlayersCount() === 2) {
this.state.resumeGame();
this.setMetadata({ gameStatus: 'playing' });
this.setMetadata({
gameStatus: 'playing',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
// Notify dashboard of game resume
broadcastDashboardUpdate();
}
}).catch(() => {
// reconnection window expired; nothing to do here
@@ -305,7 +355,11 @@ export class GameRoom extends Room<GameState> {
private startGame() {
console.log(`[GameRoom] Starting demo game in room ${this.roomId}`);
this.state.startGame();
this.setMetadata({ gameStatus: 'playing' });
this.setMetadata({
gameStatus: 'playing',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
// G2: Force offer by default when starting game
if (this.state.currentVariant === 'G2') {
this.state.forcedByP2 = true;
@@ -313,19 +367,35 @@ export class GameRoom extends Room<GameState> {
this.broadcast("gameStart");
// System chat: start at round 1
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
// Notify dashboard of game start (with some delay to ensure sysChat is processed)
setTimeout(() => {
broadcastDashboardUpdate();
}, 100);
}
private pauseGame() {
console.log(`[GameRoom] Pausing game in room ${this.roomId}`);
this.state.pauseGame();
this.broadcast("gamePaused");
this.setMetadata({ gameStatus: 'paused' });
this.setMetadata({
gameStatus: 'paused',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
// Notify dashboard of game pause
broadcastDashboardUpdate();
}
private endGame() {
console.log(`[GameRoom] Demo game ended in room ${this.roomId}`);
this.broadcast("gameEnd", {});
this.setMetadata({ gameStatus: 'finished' });
this.setMetadata({
gameStatus: 'finished',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
// Notify dashboard of game end
broadcastDashboardUpdate();
}
private resolveP2Action() {
@@ -351,6 +421,8 @@ export class GameRoom extends Room<GameState> {
p2.eloteTokens -= this.state.requestElote; p1.eloteTokens += this.state.requestElote;
}
this.clearOffer();
// Notify dashboard of token changes
broadcastDashboardUpdate();
}
else if (p2Action === 'reject') {
// No changes
@@ -363,6 +435,8 @@ export class GameRoom extends Room<GameState> {
p1.eloteTokens -= this.state.offerElote; p2.eloteTokens += this.state.offerElote;
}
// Keep offer data around for potential G4 report; it will be cleared on report or next round
// Notify dashboard of token changes
broadcastDashboardUpdate();
}
}
@@ -386,7 +460,11 @@ export class GameRoom extends Room<GameState> {
this.state.restartGame();
this.broadcast("gameRestart");
this.setMetadata({ gameStatus: 'waiting' });
this.setMetadata({
gameStatus: 'waiting',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
if (this.state.players.size === 2) {
setTimeout(() => this.startGame(), 500);
@@ -411,7 +489,7 @@ export class GameRoom extends Room<GameState> {
}
getState() {
return {
const result = {
roomId: this.roomId,
players: Array.from(this.state.players.values()).map(p => ({
sessionId: p.sessionId,
@@ -420,10 +498,12 @@ export class GameRoom extends Room<GameState> {
pavoTokens: p.pavoTokens,
eloteTokens: p.eloteTokens,
shameTokens: p.shameTokens,
color: p.color,
})),
gameStatus: this.state.gameStatus,
variant: this.state.currentVariant,
round: this.state.currentRound,
recentSystemMessage: this.recentSystemMessage,
decisions: {
p1Action: this.state.p1Action,
p2Action: this.state.p2Action,
@@ -440,14 +520,24 @@ export class GameRoom extends Room<GameState> {
},
outcome: {}
};
return result;
}
private advanceRound() {
if (this.state.currentRound < 3) {
this.state.currentRound += 1;
this.state.resetRound();
// Update metadata with new round
this.setMetadata({
gameStatus: 'playing',
currentRound: this.state.currentRound,
currentVariant: this.state.currentVariant
});
this.broadcast("roundStarted", { round: this.state.currentRound });
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
// Notify dashboard of round advance
broadcastDashboardUpdate();
} else {
this.state.finishGame();
this.endGame();