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:
@@ -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 };
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user