Files
snatchgame/server/src/adminApi.ts

963 lines
32 KiB
TypeScript

import { Request, Response, Router } from "express";
import { matchMaker } from "colyseus";
import { GameRoom } from "./rooms/GameRoom";
import { NameManager } from "./utils/nameManager";
import { getAllowedUuidCount, listAllowedUuids } from "./utils/uuidRegistry";
// SSE connections storage
const sseClients = new Set<Response>(); // dashboard/rooms stream
const sseUuidClients = new Set<Response>(); // uuids stream
const ssePlayerActionsClients = new Set<Response>(); // per-player actions stream
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.post("/rooms/:roomId/close", 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", ["sendToLobby"]);
res.json({ success: true, message: `Room ${roomId} closed and players sent to lobby` });
} catch (error) {
console.error(`[AdminAPI] Error closing room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to close room" });
}
});
adminRouter.post("/rooms/:roomId/variant", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
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 rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["setVariant", variant]);
res.json({ success: true, message: `Room ${roomId} variant changed to ${variant}` });
} catch (error) {
console.error(`[AdminAPI] Error changing variant for room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to change room variant" });
}
});
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" });
}
});
// Reset all stored UUID profiles (name, color, shame tokens)
adminRouter.post("/admin/reset-uuid-profiles", async (req: Request, res: Response) => {
try {
const allowlist = listAllowedUuids();
const known = NameManager.getInstance().getAllKnownUuids();
const all = Array.from(new Set([...(allowlist || []), ...(known || [])]));
let resetCount = 0;
all.forEach(uuid => {
if (uuid) {
NameManager.getInstance().clearPlayerProfile(uuid);
resetCount++;
}
});
// Optionally, we could also update active rooms, but we keep it as future joins behavior.
// Broadcast dashboard update so clients refresh any derived data
setTimeout(() => { try { broadcastDashboardUpdate(); } catch {} }, 100);
res.json({ success: true, message: `Reset profiles for ${resetCount} UUIDs` });
} catch (error) {
console.error("[AdminAPI] Error resetting UUID profiles:", error);
res.status(500).json({ error: "Failed to reset UUID profiles" });
}
});
adminRouter.post("/admin/shuffle-players", async (req: Request, res: Response) => {
console.log("[AdminAPI] Shuffle endpoint called!");
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) => {
const uuid = player.uuid || player.sessionId;
const shame = Number(player.shameTokens || 0);
// Persist sticky shame for this UUID before clearing rooms
try { NameManager.getInstance().setShameTokens(uuid, shame); } catch {}
allPlayers.push({
uuid,
name: player.name,
color: player.color,
sessionId: player.sessionId,
shameTokens: shame
});
});
}
} 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 } } = {};
// Shuffle rooms so pairs go to random rooms
const shuffledRooms = [...roomsInfo];
for (let i = shuffledRooms.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffledRooms[i], shuffledRooms[j]] = [shuffledRooms[j], shuffledRooms[i]];
}
for (let i = 0; i < playerGroups.length && i < shuffledRooms.length; i++) {
const group = playerGroups[i];
const roomInfo = shuffledRooms[i];
// Randomize roles P1/P2
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))
);
// Clear current-room and reconnection tokens for all players to avoid resume during shuffle
try {
allPlayers.forEach(p => {
const u = p.uuid;
if (u) {
NameManager.getInstance().clearCurrentRoom(u);
(NameManager.getInstance() as any).clearReconnectToken?.(u);
}
});
} catch {}
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);
console.error("[AdminAPI] Stack trace:", error instanceof Error ? error.stack : 'No stack trace');
NameManager.getInstance().endShuffle(); // Cleanup on error
if (!res.headersSent) {
res.status(500).json({ error: "Failed to shuffle players", details: error instanceof Error ? error.message : String(error) });
}
}
});
// UUID allowlist endpoint
adminRouter.get("/admin/uuids", async (req: Request, res: Response) => {
try {
const uuids = listAllowedUuids();
res.json({ count: getAllowedUuidCount(), uuids });
} catch (error) {
console.error("[AdminAPI] Error fetching UUIDs:", error);
res.status(500).json({ error: "Failed to fetch UUIDs" });
}
});
// UUID with names endpoint
adminRouter.get("/admin/uuids-with-names", async (req: Request, res: Response) => {
try {
const uuids = listAllowedUuids();
const nameManager = NameManager.getInstance();
const uuidsWithInfo = uuids.map(uuid => {
const name = nameManager.getPlayerName(uuid);
return {
uuid,
name: name || null,
hasName: !!name
};
});
res.json({ uuids: uuidsWithInfo });
} catch (error) {
console.error("[AdminAPI] Error fetching UUIDs with names:", error);
res.status(500).json({ error: "Failed to fetch UUIDs with names" });
}
});
// SSE endpoint for real-time dashboard updates
adminRouter.get("/dashboard-stream", (req: Request, res: Response) => {
// Set SSE headers (hardened for reverse proxies)
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform', // avoid proxy transformations
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'X-Accel-Buffering': 'no' // hint nginx to disable buffering
});
// Flush headers immediately so proxies establish the stream
try { (res as any).flushHeaders?.(); } catch {}
// 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);
}
}
// SSE endpoint for real-time UUIDs (allowlist + known names)
adminRouter.get("/uuids-stream", (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no'
});
try { (res as any).flushHeaders?.(); } catch {}
sseUuidClients.add(res);
console.log(`[AdminAPI] UUID SSE client connected. Total: ${sseUuidClients.size}`);
// Initial push
sendUuidsUpdate(res);
req.on('close', () => {
sseUuidClients.delete(res);
console.log(`[AdminAPI] UUID SSE client disconnected. Total: ${sseUuidClients.size}`);
});
const heartbeat = setInterval(() => {
if ((res as any).destroyed) {
clearInterval(heartbeat);
sseUuidClients.delete(res);
return;
}
res.write(':heartbeat\n\n');
}, 30000);
req.on('close', () => clearInterval(heartbeat));
});
async function sendUuidsUpdate(client?: Response) {
try {
const uuids = listAllowedUuids();
const nameManager = NameManager.getInstance();
const payload = {
count: (uuids || []).length,
uuids: (uuids || []).map(uuid => ({
uuid,
name: nameManager.getPlayerName(uuid) || null,
hasName: !!nameManager.getPlayerName(uuid)
}))
};
const message = `data: ${JSON.stringify(payload)}\n\n`;
if (client) {
if (!(client as any).destroyed) client.write(message);
} else {
const dead: Response[] = [];
sseUuidClients.forEach(c => {
if ((c as any).destroyed) dead.push(c);
else {
try { c.write(message); } catch { dead.push(c); }
}
});
dead.forEach(c => sseUuidClients.delete(c));
}
} catch (error) {
console.error('[AdminAPI] Error sending UUIDs SSE update:', error);
}
}
// Function to broadcast dashboard updates (called from room events)
function broadcastDashboardUpdate() {
sendDashboardUpdate();
// Also push UUIDs updates for any name/assignment changes indirectly affected
sendUuidsUpdate();
sendPlayersActionsUpdate();
}
// SSE endpoint for per-player actions made (real-time)
adminRouter.get("/players-actions-stream", (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no'
});
try { (res as any).flushHeaders?.(); } catch {}
ssePlayerActionsClients.add(res);
console.log(`[AdminAPI] Player actions SSE client connected. Total: ${ssePlayerActionsClients.size}`);
sendPlayersActionsUpdate(res);
req.on('close', () => {
ssePlayerActionsClients.delete(res);
console.log(`[AdminAPI] Player actions SSE client disconnected. Total: ${ssePlayerActionsClients.size}`);
});
const heartbeat = setInterval(() => {
if ((res as any).destroyed) {
clearInterval(heartbeat);
ssePlayerActionsClients.delete(res);
return;
}
res.write(':heartbeat\n\n');
}, 30000);
req.on('close', () => clearInterval(heartbeat));
});
function isActionMade(kind: string, role?: string) {
const k = (kind || '').toString();
if (!k) return false;
const prefix = k.slice(0,3).toLowerCase();
if (prefix === 'p1_' || prefix === 'p2_') {
const evRole = prefix === 'p1_' ? 'P1' : 'P2';
return ((role || '').toUpperCase() === evRole);
}
// System/agnostic events are ignored for this stream; only actions list below are counted
return false;
}
const ACTION_EVENTS = [
'p1_propose','p1_no_offer','p2_snatch','p2_accept','p2_force','p2_no_force','p2_reject','p1_shame','p1_no_shame','p1_report','p1_no_report'
];
// Calculate scores based on game variant and role
function calculateScore(pavoTokens: number, eloteTokens: number, role: string): number {
if (role === 'P1') {
return pavoTokens * 1 + eloteTokens * 2;
} else if (role === 'P2') {
return pavoTokens * 2 + eloteTokens * 1;
}
return 0;
}
// Check if event triggers score calculation for the game variant
function isScoreEvent(kind: string, gameVariant: string): boolean {
const variant = (gameVariant || '').toUpperCase();
switch (variant) {
case 'G1':
case 'G2':
case 'G5':
return ['p1_no_offer', 'p2_accept', 'p2_reject', 'p2_snatch'].includes(kind);
case 'G3':
return ['p1_no_offer', 'p2_accept', 'p2_reject', 'p1_shame', 'p1_no_shame'].includes(kind);
case 'G4':
return ['p1_no_offer', 'p2_accept', 'p2_reject', 'p1_report', 'p1_no_report'].includes(kind);
default:
return false;
}
}
async function sendPlayersActionsUpdate(client?: Response) {
try {
const nameManager = NameManager.getInstance();
const uuids = nameManager.getAllKnownUuids?.() || [];
const players = uuids.map((uuid: string) => {
const history = nameManager.getSystemHistory(uuid) || [];
const counts: any = Object.fromEntries(ACTION_EVENTS.map(k => [k, 0]));
const detailedHistory: any[] = [];
// Track room scores for this specific player
const playerRoomScoreMap = new Map<string, Array<{
round: number;
variant: string;
increment: number;
score: number;
role: string;
}>>();
// Track round/variant/increment combinations per room for this player
const roomGameTracker = new Map<string, Map<string, number>>(); // roomId -> "round-variant" -> increment
// First pass: process all events for score calculation
for (const entry of history) {
const kind = (entry as any)?.kind || '';
const roomId = (entry as any)?.roomId;
const round = (entry as any)?.round;
const gameVariant = (entry as any)?.gameVariant || (entry as any)?.variant;
const role = (entry as any)?.role;
const pavoTokens = (entry as any)?.pavoTokens || 0;
const eloteTokens = (entry as any)?.eloteTokens || 0;
// Process score calculation if this is a score-triggering event
if (roomId && round && gameVariant && isScoreEvent(kind, gameVariant) && (pavoTokens > 0 || eloteTokens > 0)) {
// Track game progression for increment calculation
if (!roomGameTracker.has(roomId)) {
roomGameTracker.set(roomId, new Map());
}
const gameKey = `${round}-${gameVariant}`;
const currentIncrement = roomGameTracker.get(roomId)!.get(gameKey) || 0;
roomGameTracker.get(roomId)!.set(gameKey, currentIncrement + 1);
// Calculate score
const score = calculateScore(pavoTokens, eloteTokens, role);
// Add to this player's room score history
if (!playerRoomScoreMap.has(roomId)) {
playerRoomScoreMap.set(roomId, []);
}
playerRoomScoreMap.get(roomId)!.push({
round: parseInt(round),
variant: gameVariant,
increment: currentIncrement + 1,
score,
role
});
}
}
// Second pass: process action events for counts and detailed history
for (const entry of history) {
const kind = (entry as any)?.kind || '';
const roomId = (entry as any)?.roomId;
const round = (entry as any)?.round;
const gameVariant = (entry as any)?.gameVariant || (entry as any)?.variant;
const role = (entry as any)?.role;
if (!ACTION_EVENTS.includes(kind)) continue;
if (!isActionMade(kind, role)) continue;
counts[kind] = (counts[kind] || 0) + 1;
// Include detailed event info
detailedHistory.push({
kind,
round,
gameVariant,
roomId
});
}
// Build RoomScoreHistory array for this player
const roomScoreHistory = Array.from(playerRoomScoreMap.entries()).map(([roomId, scores]) => ({
roomId,
scores: scores.sort((a, b) => {
// Sort by round, then variant, then increment
if (a.round !== b.round) return a.round - b.round;
if (a.variant !== b.variant) return a.variant.localeCompare(b.variant);
return a.increment - b.increment;
})
}));
const total = ACTION_EVENTS.reduce((acc, k) => acc + (counts[k] || 0), 0);
return {
uuid,
name: nameManager.getPlayerName(uuid) || null,
counts,
total,
detailedHistory,
rawHistory: history,
roomScoreHistory
};
}).filter((p: any) => p.total > 0 || p.name);
const payload = { players };
const message = `data: ${JSON.stringify(payload)}\n\n`;
if (client) {
if (!(client as any).destroyed) client.write(message);
} else {
const dead: Response[] = [];
ssePlayerActionsClients.forEach(c => {
if ((c as any).destroyed) dead.push(c);
else { try { c.write(message); } catch { dead.push(c); } }
});
dead.forEach(c => ssePlayerActionsClients.delete(c));
}
} catch (error) {
console.error('[AdminAPI] Error sending players actions SSE update:', error);
}
}
export { adminRouter, broadcastDashboardUpdate };