561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
import { Request, Response, Router } from "express";
|
|
import { matchMaker } from "colyseus";
|
|
import { GameRoom } from "./rooms/GameRoom";
|
|
import { NameManager } from "./utils/nameManager";
|
|
|
|
// SSE connections storage
|
|
const sseClients = new Set<Response>();
|
|
|
|
const adminRouter = Router();
|
|
|
|
adminRouter.get("/rooms", async (req: Request, res: 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
|
|
}));
|
|
|
|
res.json(roomStats);
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error fetching rooms:", error);
|
|
res.status(500).json({ error: "Failed to fetch rooms" });
|
|
}
|
|
});
|
|
|
|
adminRouter.get("/rooms/:roomId/stats", async (req: Request, res: Response) => {
|
|
try {
|
|
const { roomId } = req.params;
|
|
const roomData = await matchMaker.remoteRoomCall(roomId, "getState");
|
|
|
|
res.json(roomData);
|
|
} catch (error) {
|
|
console.error(`[AdminAPI] Error fetching room ${req.params.roomId} stats:`, error);
|
|
res.status(500).json({ error: "Failed to fetch room stats" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/rooms/:roomId/pause", async (req: Request, res: Response) => {
|
|
try {
|
|
const { roomId } = req.params;
|
|
const rooms = await matchMaker.query({ roomId });
|
|
|
|
if (rooms.length === 0) {
|
|
return res.status(404).json({ error: "Room not found" });
|
|
}
|
|
|
|
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["pause"]);
|
|
|
|
res.json({ success: true, message: "Room paused" });
|
|
} catch (error) {
|
|
console.error(`[AdminAPI] Error pausing room ${req.params.roomId}:`, error);
|
|
res.status(500).json({ error: "Failed to pause room" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/rooms/:roomId/resume", async (req: Request, res: Response) => {
|
|
try {
|
|
const { roomId } = req.params;
|
|
const rooms = await matchMaker.query({ roomId });
|
|
|
|
if (rooms.length === 0) {
|
|
return res.status(404).json({ error: "Room not found" });
|
|
}
|
|
|
|
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["resume"]);
|
|
|
|
res.json({ success: true, message: "Room resumed" });
|
|
} catch (error) {
|
|
console.error(`[AdminAPI] Error resuming room ${req.params.roomId}:`, error);
|
|
res.status(500).json({ error: "Failed to resume room" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/rooms/:roomId/restart", async (req: Request, res: Response) => {
|
|
try {
|
|
const { roomId } = req.params;
|
|
const rooms = await matchMaker.query({ roomId });
|
|
|
|
if (rooms.length === 0) {
|
|
return res.status(404).json({ error: "Room not found" });
|
|
}
|
|
|
|
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["restart"]);
|
|
|
|
res.json({ success: true, message: "Room restarted" });
|
|
} catch (error) {
|
|
console.error(`[AdminAPI] Error restarting room ${req.params.roomId}:`, error);
|
|
res.status(500).json({ error: "Failed to restart room" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/rooms/:roomId/kick/:playerId", async (req: Request, res: Response) => {
|
|
try {
|
|
const { roomId, playerId } = req.params;
|
|
const rooms = await matchMaker.query({ roomId });
|
|
|
|
if (rooms.length === 0) {
|
|
return res.status(404).json({ error: "Room not found" });
|
|
}
|
|
|
|
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["kick", playerId]);
|
|
|
|
res.json({ success: true, message: `Player ${playerId} kicked` });
|
|
} catch (error) {
|
|
console.error(`[AdminAPI] Error kicking player from room ${req.params.roomId}:`, error);
|
|
res.status(500).json({ error: "Failed to kick player" });
|
|
}
|
|
});
|
|
|
|
adminRouter.get("/stats", async (req: Request, res: Response) => {
|
|
try {
|
|
const stats = await matchMaker.stats.fetchAll();
|
|
const globalCCU = await matchMaker.stats.getGlobalCCU();
|
|
|
|
res.json({
|
|
processes: stats,
|
|
globalCCU,
|
|
localCCU: matchMaker.stats.local.ccu,
|
|
localRoomCount: matchMaker.stats.local.roomCount
|
|
});
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error fetching stats:", error);
|
|
res.status(500).json({ error: "Failed to fetch stats" });
|
|
}
|
|
});
|
|
|
|
// Global admin endpoints
|
|
adminRouter.post("/admin/pause-all", async (req: Request, res: Response) => {
|
|
try {
|
|
const gameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (gameRooms.length === 0) {
|
|
return res.json({ success: true, message: "No game rooms to pause" });
|
|
}
|
|
|
|
const promises = gameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["pause"])
|
|
.catch(error => console.error(`Failed to pause room ${room.roomId}:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(promises);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Pause command sent to ${gameRooms.length} game rooms`
|
|
});
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error pausing all games:", error);
|
|
res.status(500).json({ error: "Failed to pause all games" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/admin/resume-all", async (req: Request, res: Response) => {
|
|
try {
|
|
const gameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (gameRooms.length === 0) {
|
|
return res.json({ success: true, message: "No game rooms to resume" });
|
|
}
|
|
|
|
const promises = gameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["resume"])
|
|
.catch(error => console.error(`Failed to resume room ${room.roomId}:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(promises);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Resume command sent to ${gameRooms.length} game rooms`
|
|
});
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error resuming all games:", error);
|
|
res.status(500).json({ error: "Failed to resume all games" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/admin/restart-all", async (req: Request, res: Response) => {
|
|
try {
|
|
const gameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (gameRooms.length === 0) {
|
|
return res.json({ success: true, message: "No game rooms to restart" });
|
|
}
|
|
|
|
const promises = gameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["restart"])
|
|
.catch(error => console.error(`Failed to restart room ${room.roomId}:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(promises);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Restart command sent to ${gameRooms.length} game rooms`
|
|
});
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error restarting all games:", error);
|
|
res.status(500).json({ error: "Failed to restart all games" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/admin/change-variant", async (req: Request, res: Response) => {
|
|
try {
|
|
const { variant } = req.body;
|
|
|
|
if (!variant || !['G1', 'G2', 'G3', 'G4', 'G5'].includes(variant)) {
|
|
return res.status(400).json({ error: "Invalid variant. Must be one of: G1, G2, G3, G4, G5" });
|
|
}
|
|
|
|
const gameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (gameRooms.length === 0) {
|
|
return res.json({ success: true, message: "No game rooms to change variant" });
|
|
}
|
|
|
|
const promises = gameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["setVariant", variant])
|
|
.catch(error => console.error(`Failed to change variant in room ${room.roomId}:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(promises);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Variant change to ${variant} sent to ${gameRooms.length} game rooms`
|
|
});
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error changing global variant:", error);
|
|
res.status(500).json({ error: "Failed to change global variant" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/admin/send-all-to-lobby", async (req: Request, res: Response) => {
|
|
try {
|
|
const gameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (gameRooms.length === 0) {
|
|
return res.json({ success: true, message: "No game rooms to close" });
|
|
}
|
|
|
|
console.log(`[AdminAPI] Sending ${gameRooms.length} game rooms to lobby and disposing them`);
|
|
|
|
// Send command to all game rooms
|
|
const promises = gameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["sendToLobby"])
|
|
.catch(error => console.error(`Failed to send room ${room.roomId} to lobby:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(promises);
|
|
|
|
// Wait a bit for rooms to dispose themselves, then force dispose any remaining
|
|
setTimeout(async () => {
|
|
try {
|
|
const remainingGameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (remainingGameRooms.length > 0) {
|
|
console.log(`[AdminAPI] Force disposing ${remainingGameRooms.length} remaining game rooms`);
|
|
|
|
const disposePromises = remainingGameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "disconnect").catch(() => {
|
|
// If remote call fails, try direct disposal
|
|
console.log(`[AdminAPI] Force disposing room ${room.roomId} directly`);
|
|
})
|
|
);
|
|
|
|
await Promise.allSettled(disposePromises);
|
|
}
|
|
|
|
// Broadcast dashboard update after cleanup
|
|
setTimeout(() => {
|
|
broadcastDashboardUpdate();
|
|
}, 500);
|
|
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error in cleanup phase:", error);
|
|
}
|
|
}, 3000);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Send to lobby command sent to ${gameRooms.length} game rooms. Rooms will be disposed.`
|
|
});
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error sending all to lobby:", error);
|
|
res.status(500).json({ error: "Failed to send all players to lobby" });
|
|
}
|
|
});
|
|
|
|
adminRouter.post("/admin/shuffle-players", async (req: Request, res: Response) => {
|
|
try {
|
|
console.log("[AdminAPI] Starting player shuffle...");
|
|
|
|
// 1. Get all game rooms and their players
|
|
const gameRooms = await matchMaker.query({ name: "game" });
|
|
|
|
if (gameRooms.length === 0) {
|
|
return res.json({ success: true, message: "No game rooms to shuffle" });
|
|
}
|
|
|
|
console.log(`[AdminAPI] Found ${gameRooms.length} game rooms for shuffle`);
|
|
|
|
// 2. Extract all players from all rooms
|
|
const allPlayers: any[] = [];
|
|
const roomsInfo: { roomId: string; variant: string }[] = [];
|
|
|
|
for (const room of gameRooms) {
|
|
try {
|
|
const roomState = await matchMaker.remoteRoomCall(room.roomId, "getState");
|
|
|
|
roomsInfo.push({
|
|
roomId: room.roomId,
|
|
variant: roomState.variant || 'G1'
|
|
});
|
|
|
|
// Collect players with their full info
|
|
if (roomState.players && roomState.players.length > 0) {
|
|
roomState.players.forEach((player: any) => {
|
|
allPlayers.push({
|
|
uuid: player.uuid || player.sessionId, // Use actual UUID if available
|
|
name: player.name,
|
|
color: player.color,
|
|
sessionId: player.sessionId
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to get state for room ${room.roomId}:`, error);
|
|
}
|
|
}
|
|
|
|
console.log(`[AdminAPI] Collected ${allPlayers.length} players for shuffle`);
|
|
|
|
if (allPlayers.length === 0) {
|
|
return res.json({ success: true, message: "No players found to shuffle" });
|
|
}
|
|
|
|
// 3. Shuffle players array
|
|
const shuffledPlayers = [...allPlayers];
|
|
for (let i = shuffledPlayers.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffledPlayers[i], shuffledPlayers[j]] = [shuffledPlayers[j], shuffledPlayers[i]];
|
|
}
|
|
|
|
// 4. Create groups of 2 players
|
|
const playerGroups: any[][] = [];
|
|
const extraPlayers: any[] = [];
|
|
|
|
for (let i = 0; i < shuffledPlayers.length; i += 2) {
|
|
if (i + 1 < shuffledPlayers.length) {
|
|
playerGroups.push([shuffledPlayers[i], shuffledPlayers[i + 1]]);
|
|
} else {
|
|
extraPlayers.push(shuffledPlayers[i]);
|
|
}
|
|
}
|
|
|
|
console.log(`[AdminAPI] Created ${playerGroups.length} player groups, ${extraPlayers.length} extra players`);
|
|
|
|
// 5. Start shuffle process
|
|
NameManager.getInstance().startShuffle();
|
|
|
|
// 6. Assign players to rooms and roles randomly
|
|
const assignments: { [roomId: string]: { p1: any; p2: any } } = {};
|
|
|
|
for (let i = 0; i < playerGroups.length && i < roomsInfo.length; i++) {
|
|
const group = playerGroups[i];
|
|
const roomInfo = roomsInfo[i];
|
|
|
|
// Randomly assign P1 and P2 roles
|
|
const [player1, player2] = Math.random() < 0.5 ? [group[0], group[1]] : [group[1], group[0]];
|
|
|
|
assignments[roomInfo.roomId] = {
|
|
p1: { ...player1, role: 'P1' },
|
|
p2: { ...player2, role: 'P2' }
|
|
};
|
|
|
|
// Store assignments in NameManager for lobby redirects
|
|
NameManager.getInstance().assignPlayerToRoom(player1.uuid, roomInfo.roomId, 'P1');
|
|
NameManager.getInstance().assignPlayerToRoom(player2.uuid, roomInfo.roomId, 'P2');
|
|
}
|
|
|
|
// 7. Clear all rooms first
|
|
console.log("[AdminAPI] Clearing all game rooms for shuffle...");
|
|
const clearPromises = gameRooms.map(room =>
|
|
matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["clearForShuffle"])
|
|
.catch(error => console.error(`Failed to clear room ${room.roomId}:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(clearPromises);
|
|
|
|
// 8. Wait a bit for rooms to clear, then assign new players
|
|
setTimeout(async () => {
|
|
console.log("[AdminAPI] Assigning shuffled players to rooms...");
|
|
|
|
const assignPromises = Object.entries(assignments).map(([roomId, assignment]) =>
|
|
matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["assignShuffledPlayers", assignment])
|
|
.catch(error => console.error(`Failed to assign players to room ${roomId}:`, error))
|
|
);
|
|
|
|
await Promise.allSettled(assignPromises);
|
|
|
|
// Handle extra players - they go to lobby
|
|
extraPlayers.forEach(player => {
|
|
console.log(`[AdminAPI] Player ${player.name} will remain in lobby (odd number of players)`);
|
|
});
|
|
|
|
console.log("[AdminAPI] Player shuffle completed!");
|
|
|
|
// Cleanup after a delay
|
|
setTimeout(() => {
|
|
NameManager.getInstance().endShuffle();
|
|
broadcastDashboardUpdate();
|
|
}, 10000); // 10 seconds for all players to reconnect
|
|
|
|
}, 3000); // 3 seconds delay
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Shuffle initiated for ${allPlayers.length} players across ${gameRooms.length} rooms. ${playerGroups.length} pairs created, ${extraPlayers.length} players will go to lobby.`
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error("[AdminAPI] Error shuffling players:", error);
|
|
NameManager.getInstance().endShuffle(); // Cleanup on error
|
|
res.status(500).json({ error: "Failed to shuffle players" });
|
|
}
|
|
});
|
|
|
|
// 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 }; |