/** * Tank War PVP Server * WebSocket server for online 1v1 multiplayer. * Handles room management, message relay, and basic game state authority. * * Deployment note: * - /health → HTTP health check (used by K8s livenessProbe/readinessProbe) * - /tankwar/ws → WebSocket upgrade path (exposed publicly via Nginx) * Both share the same HTTP server on PORT. */ const { WebSocketServer } = require('ws'); const express = require('express'); const http = require('http'); // ============================================================ // Content Security Proxy Configuration// The content security service is now deployed as an independent // microservice in the "content-security" K8s namespace. // This proxy forwards /api/content/* requests to that service. // ============================================================ const CONTENT_SECURITY_SERVICE_URL = process.env.CONTENT_SECURITY_SERVICE_URL || 'http://content-security-service.content-security.svc.cluster.local:3000'; const GAME_ID = process.env.GAME_ID || 'tankwar'; // ============================================================ // Configuration // ============================================================ const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; const WS_PATH = process.env.WS_PATH || '/tankwar/ws'; const HEARTBEAT_INTERVAL = 10000; // ms const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout // ============================================================ // Express HTTP Server + Content Security API // ============================================================ const app = express(); const server = http.createServer(app); // Parse JSON bodies app.use(express.json({ limit: '2mb' })); // Health check endpoints (for K8s livenessProbe/readinessProbe) app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), activeConnections: players.size, activeRooms: rooms.size, activeTeamRooms: teamRooms.size }); }); app.get('/tankwar/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), activeConnections: players.size, activeRooms: rooms.size, activeTeamRooms: teamRooms.size }); }); app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: Date.now() }); }); // ============================================================ // Content Security Proxy // Forward /api/content/* requests to the independent // content-security-service in the content-security namespace. // This allows the tankwar-server to delegate all content // moderation to the shared microservice. // ============================================================ app.use('/api/content', (req, res, next) => { // Parse target host and port const urlObj = new URL(CONTENT_SECURITY_SERVICE_URL); const targetPath = `/api/content${req.path}`; const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''; const finalPath = qs ? `${targetPath}${qs}` : targetPath; const proxyReq = http.request({ hostname: urlObj.hostname, port: urlObj.port || 80, path: finalPath, method: req.method, headers: { ...req.headers, host: `${urlObj.hostname}:${urlObj.port || 80}`, 'X-Game-Id': GAME_ID, 'X-Forwarded-For': req.ip || req.socket.remoteAddress, }, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res, { end: true }); }); proxyReq.on('error', (err) => { console.error('[TankWar Proxy] Failed to forward to content-security-service:', err.message); if (!res.headersSent) { res.status(502).json({ errcode: -1, errmsg: '内容安全服务暂时不可用,请稍后再试', }); } }); // Pipe request body to proxy req.pipe(proxyReq, { end: true }); }); // ============================================================ // Message Types (must match client NET_MSG) // ============================================================ const NET_MSG = { CREATE_ROOM: 'create_room', JOIN_ROOM: 'join_room', ROOM_CREATED: 'room_created', ROOM_JOINED: 'room_joined', ROOM_ERROR: 'room_error', OPPONENT_JOINED: 'opponent_joined', OPPONENT_LEFT: 'opponent_left', GAME_START: 'game_start', PLAYER_INPUT: 'player_input', PLAYER_STATE: 'player_state', BULLET_FIRE: 'bullet_fire', BULLET_HIT: 'bullet_hit', PLAYER_HIT: 'player_hit', PLAYER_KILLED: 'player_killed', GAME_OVER: 'game_over', PING: 'ping', PONG: 'pong', SYNC_STATE: 'sync_state', // Team (3v3) CREATE_TEAM: 'create_team', JOIN_TEAM: 'join_team', LEAVE_TEAM: 'leave_team', TEAM_READY: 'team_ready', TEAM_KICK: 'team_kick', TEAM_DISBAND: 'team_disband', TEAM_STATE: 'team_state', MATCH_START: 'match_start', MATCH_CANCEL: 'match_cancel', MATCH_FOUND: 'match_found', MATCH_TIMEOUT: 'match_timeout', BASE_HIT: 'base_hit', BASE_DESTROYED: 'base_destroyed', PLAYER_RESPAWN: 'player_respawn', TEAM_GAME_START: 'team_game_start', TEAM_GAME_OVER: 'team_game_over', RECONNECT: 'reconnect', RECONNECT_OK: 'reconnect_ok', PLAYER_DISCONNECT: 'player_disconnect', BOT_TAKEOVER: 'bot_takeover', SOLO_MATCH: 'solo_match', REMATCH: 'rematch', REMATCH_READY: 'rematch_ready', }; // ============================================================ // 3v3 Configuration // ============================================================ const TEAM_SIZE = 3; const TEAM_MATCH_TIMEOUT = 6000; // 6s matchmaking timeout (testing) const TEAM_RECONNECT_TIMEOUT = 60000; // 60s to reconnect // ============================================================ // Battle Configuration (X vs X) // ============================================================ const BATTLE_CONFIG = { '1v1': { teamSize: 1, baseHp: 5, fillWithBots: false }, '3v3': { teamSize: 3, baseHp: 10, fillWithBots: true }, }; // ============================================================ // Room Management // ============================================================ /** @type {Map} */ const rooms = new Map(); /** @type {Map} */ const players = new Map(); /** @type {Map} */ const teamRooms = new Map(); /** @type {Array} Matching pool for team queues */ const teamMatchPool = []; /** @type {Array} Matching pool for solo players */ const soloMatchPool = []; class Room { constructor(id, host) { this.id = id; this.host = host; // WebSocket of player 1 this.guest = null; // WebSocket of player 2 this.state = 'waiting'; // waiting | playing | finished this.createdAt = Date.now(); this.mapId = Math.floor(Math.random() * 3) + 1; } isFull() { return this.host && this.guest; } getOpponent(ws) { if (ws === this.host) return this.guest; if (ws === this.guest) return this.host; return null; } getPlayerSlot(ws) { if (ws === this.host) return 1; if (ws === this.guest) return 2; return 0; } removePlayer(ws) { if (ws === this.host) this.host = null; if (ws === this.guest) this.guest = null; } isEmpty() { return !this.host && !this.guest; } } class PlayerInfo { constructor(ws, playerId) { this.ws = ws; this.playerId = playerId; this.nickname = ''; this.roomId = null; this.teamId = null; this.isAlive = true; this.lastPing = Date.now(); } } // ============================================================ // TeamRoom - 3v3 Team Room Management // ============================================================ class TeamRoom { /** * @param {string} id - Unique team room id * @param {WebSocket} leaderWs - WebSocket of the team leader * @param {string} leaderId - Player id of the leader * @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3') * @param {string} [leaderNickname=''] - Display nickname of the leader */ constructor(id, leaderWs, leaderId, battleMode = '3v3', leaderNickname = '') { this.id = id; this.state = 'forming'; // forming | matching | playing | finished this.createdAt = Date.now(); this.mapId = Math.floor(Math.random() * 3) + 1; this.battleMode = battleMode; const config = BATTLE_CONFIG[battleMode] || BATTLE_CONFIG['3v3']; this.teamSize = config.teamSize; this.fillWithBotsEnabled = config.fillWithBots; // Team A members: { ws, playerId, nickname, ready, isBot, disconnectedAt } this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', ready: true, isBot: false, disconnectedAt: null }]; // Team B members this.teamB = []; this.leaderId = leaderId; // Matching state this.matchStartTime = null; this.matchTimer = null; // Game state this.teamABaseHp = config.baseHp; this.teamBBaseHp = config.baseHp; this.gameStartTime = null; } /** Get all members of team A */ getTeamAMembers() { return this.teamA; } /** Get all members of team B */ getTeamBMembers() { return this.teamB; } /** Get all human (non-bot) members across both teams */ getAllHumanMembers() { return [...this.teamA, ...this.teamB].filter(m => !m.isBot); } /** Get all members across both teams */ getAllMembers() { return [...this.teamA, ...this.teamB]; } /** Find which team a player belongs to */ getPlayerTeam(playerId) { if (this.teamA.find(m => m.playerId === playerId)) return 'A'; if (this.teamB.find(m => m.playerId === playerId)) return 'B'; return null; } /** Find a member by playerId */ getMember(playerId) { return this.getAllMembers().find(m => m.playerId === playerId) || null; } /** Find a member by WebSocket */ getMemberByWs(ws) { return this.getAllMembers().find(m => m.ws === ws) || null; } /** Check if team A is full */ isTeamAFull() { return this.teamA.length >= this.teamSize; } /** Check if both teams are full */ isFull() { return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize; } /** Check if all team A members are ready */ isTeamAReady() { return this.teamA.length > 0 && this.teamA.every(m => m.ready || m.isBot); } /** Add a player to team A */ addToTeamA(ws, playerId, nickname = '') { if (this.isTeamAFull()) return false; this.teamA.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); return true; } /** Add a player to team B */ addToTeamB(ws, playerId, nickname = '') { if (this.teamB.length >= this.teamSize) return false; this.teamB.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); return true; } /** Remove a player from the team room */ removePlayer(playerId) { this.teamA = this.teamA.filter(m => m.playerId !== playerId); this.teamB = this.teamB.filter(m => m.playerId !== playerId); } /** Fill remaining slots with AI bots */ fillWithBots() { let botCounter = 0; while (this.teamA.length < this.teamSize) { botCounter++; this.teamA.push({ ws: null, playerId: `bot_a_${botCounter}_${this.id}`, nickname: '', ready: true, isBot: true, disconnectedAt: null, }); } while (this.teamB.length < this.teamSize) { botCounter++; this.teamB.push({ ws: null, playerId: `bot_b_${botCounter}_${this.id}`, nickname: '', ready: true, isBot: true, disconnectedAt: null, }); } } /** Broadcast a message to all human members in the room */ broadcast(type, data, excludeWs = null) { for (const member of this.getAllHumanMembers()) { if (member.ws && member.ws !== excludeWs && member.ws.readyState === 1) { sendMessage(member.ws, type, data); } } } /** * Reset the room for a rematch: clear game state, keep players. * Returns the room to 'forming' state so a new game can start. */ resetForRematch() { this.state = 'forming'; this.mapId = Math.floor(Math.random() * 3) + 1; this.matchStartTime = null; if (this.matchTimer) { clearTimeout(this.matchTimer); this.matchTimer = null; } const config = BATTLE_CONFIG[this.battleMode] || BATTLE_CONFIG['3v3']; this.teamABaseHp = config.baseHp; this.teamBBaseHp = config.baseHp; this.gameStartTime = null; // Reset rematch tracking this._rematchPlayers = new Set(); this._rematchTimer = null; // Reset ready state for all human members for (const member of this.getAllHumanMembers()) { member.disconnectedAt = null; member.isBot = false; } } /** Broadcast to all members of a specific team */ broadcastToTeam(team, type, data, excludeWs = null) { const members = team === 'A' ? this.teamA : this.teamB; for (const member of members) { if (member.ws && !member.isBot && member.ws !== excludeWs && member.ws.readyState === 1) { sendMessage(member.ws, type, data); } } } /** Get serializable team state for broadcasting */ getTeamState() { return { teamId: this.id, state: this.state, leaderId: this.leaderId, battleMode: this.battleMode, teamSize: this.teamSize, teamA: this.teamA.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', ready: m.ready, isBot: m.isBot, isLeader: m.playerId === this.leaderId, connected: m.isBot || (m.ws && m.ws.readyState === 1), })), teamB: this.teamB.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', ready: m.ready, isBot: m.isBot, connected: m.isBot || (m.ws && m.ws.readyState === 1), })), teamABaseHp: this.teamABaseHp, teamBBaseHp: this.teamBBaseHp, }; } } // ============================================================ // Utility Functions // ============================================================ /** * Generate a random room code (4-6 digit number). * @returns {string} */ function generateRoomCode() { let code; do { code = String(Math.floor(1000 + Math.random() * 9000)); // 4-digit code } while (rooms.has(code)); return code; } /** * Send a JSON message to a WebSocket client. * @param {WebSocket} ws * @param {string} type * @param {object} data */ function sendMessage(ws, type, data = {}) { if (!ws || ws.readyState !== 1) return; // 1 = OPEN try { ws.send(JSON.stringify({ type, data, timestamp: Date.now() })); } catch (e) { console.error('[Server] Send error:', e.message); } } /** * Relay a message from one player to their opponent. * @param {WebSocket} senderWs * @param {string} type * @param {object} data */ function relayToOpponent(senderWs, type, data) { const playerInfo = players.get(senderWs); if (!playerInfo || !playerInfo.roomId) return; const room = rooms.get(playerInfo.roomId); if (!room) return; const opponent = room.getOpponent(senderWs); if (opponent) { sendMessage(opponent, type, data); } } /** * Generate a unique team room id. * @returns {string} */ function generateTeamId() { let id; do { id = 'T' + String(Math.floor(10000 + Math.random() * 90000)); } while (teamRooms.has(id)); return id; } /** * Relay a message from one player to all other players in the same team room. * @param {WebSocket} senderWs * @param {string} type * @param {object} data */ function relayToTeamRoom(senderWs, type, data) { const playerInfo = players.get(senderWs); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom) return; teamRoom.broadcast(type, data, senderWs); } // ============================================================ // Message Handlers // ============================================================ function handleCreateRoom(ws, data) { const playerInfo = players.get(ws); if (!playerInfo) return; // Leave current room/team if any if (playerInfo.roomId) { leaveRoom(ws); } if (playerInfo.teamId) { handleLeaveTeam(ws, {}); } const roomCode = generateRoomCode(); // Create a TeamRoom in 1v1 mode instead of a legacy Room const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1', playerInfo.nickname || ''); teamRooms.set(roomCode, teamRoom); playerInfo.teamId = roomCode; console.log(`[Server] 1v1 Room ${roomCode} created by ${playerInfo.playerId} (using TeamRoom)`); sendMessage(ws, NET_MSG.ROOM_CREATED, { roomId: roomCode, roomCode: roomCode, playerSlot: 1, }); } function handleJoinRoom(ws, data) { const playerInfo = players.get(ws); if (!playerInfo) return; const roomId = data.roomId; if (!roomId) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room code is required' }); return; } // Look up in teamRooms (1v1 rooms are now TeamRooms) const teamRoom = teamRooms.get(roomId); if (!teamRoom) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room not found' }); return; } if (teamRoom.battleMode !== '1v1') { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room not found' }); return; } if (teamRoom.teamB.length >= teamRoom.teamSize) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room is full' }); return; } // Leave current room/team if any if (playerInfo.roomId) { leaveRoom(ws); } if (playerInfo.teamId) { handleLeaveTeam(ws, {}); } // Join as team B teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || ''); playerInfo.teamId = roomId; console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`); // Notify the joiner sendMessage(ws, NET_MSG.ROOM_JOINED, { roomId: roomId, roomCode: roomId, playerSlot: 2, }); // Notify the host const host = teamRoom.teamA[0]; if (host && host.ws) { sendMessage(host.ws, NET_MSG.OPPONENT_JOINED, { playerId: playerInfo.playerId, }); } // Start game after countdown teamRoom.state = 'playing'; setTimeout(() => { if (teamRoom.state === 'playing' && teamRoom.teamA.length > 0 && teamRoom.teamB.length > 0) { startTeamGame(teamRoom); } }, 3500); // 3.5s countdown } function leaveRoom(ws) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.roomId) return; const room = rooms.get(playerInfo.roomId); if (!room) { playerInfo.roomId = null; return; } const opponent = room.getOpponent(ws); room.removePlayer(ws); playerInfo.roomId = null; // Notify opponent if (opponent) { sendMessage(opponent, NET_MSG.OPPONENT_LEFT, {}); } // Clean up empty room if (room.isEmpty()) { rooms.delete(room.id); console.log(`[Server] Room ${room.id} deleted (empty)`); } } // ============================================================ // 3v3 Team Message Handlers // ============================================================ function handleCreateTeam(ws, data) { const playerInfo = players.get(ws); if (!playerInfo) return; // Leave current team if any if (playerInfo.teamId) { handleLeaveTeam(ws, {}); } const teamId = generateTeamId(); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRooms.set(teamId, teamRoom); playerInfo.teamId = teamId; console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId}`); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } function handleJoinTeam(ws, data) { const playerInfo = players.get(ws); if (!playerInfo) return; const teamId = data.teamId; if (!teamId) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Team ID is required' }); return; } let teamRoom = teamRooms.get(teamId); if (!teamRoom) { // Team was cleaned up (e.g. leader disconnected during dev-tool reload). // Auto-create a new room with the same ID so the invite link still works. console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`); teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRooms.set(teamId, teamRoom); playerInfo.teamId = teamId; sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); return; } if (teamRoom.state !== 'forming') { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Team is not accepting members' }); return; } if (teamRoom.isTeamAFull()) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Team is full' }); return; } // Leave current team if any if (playerInfo.teamId) { handleLeaveTeam(ws, {}); } teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || ''); playerInfo.teamId = teamId; console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`); // Broadcast updated team state to all members teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } function handleLeaveTeam(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom) { playerInfo.teamId = null; return; } // If team is matching and leader leaves, cancel match first const wasLeader = teamRoom.leaderId === playerInfo.playerId; if (teamRoom.state === 'matching' && wasLeader) { cancelMatch(teamRoom); } teamRoom.removePlayer(playerInfo.playerId); playerInfo.teamId = null; // Remove from solo match pool if present const soloIdx = soloMatchPool.indexOf(ws); if (soloIdx !== -1) soloMatchPool.splice(soloIdx, 1); console.log(`[Server] Player ${playerInfo.playerId} left team ${teamRoom.id}`); // If leader left, assign new leader or disband if (wasLeader) { const remainingHumans = teamRoom.teamA.filter(m => !m.isBot); if (remainingHumans.length > 0) { teamRoom.leaderId = remainingHumans[0].playerId; console.log(`[Server] New leader for team ${teamRoom.id}: ${teamRoom.leaderId}`); teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } else { // No humans left, disband cleanupTeamRoom(teamRoom.id); } } else { teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } } function handleTeamReady(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom || teamRoom.state !== 'forming') return; const member = teamRoom.getMember(playerInfo.playerId); if (!member) return; // Leader is always ready; toggle ready for non-leaders if (playerInfo.playerId !== teamRoom.leaderId) { member.ready = data.ready !== undefined ? !!data.ready : !member.ready; } teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } function handleTeamKick(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom) return; // Only leader can kick, and only during forming state if (teamRoom.leaderId !== playerInfo.playerId) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Only the leader can kick players' }); return; } if (teamRoom.state !== 'forming') { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Cannot kick during matching or game' }); return; } const targetId = data.playerId; if (!targetId || targetId === playerInfo.playerId) return; const targetMember = teamRoom.getMember(targetId); if (!targetMember) return; // Notify the kicked player if (targetMember.ws && targetMember.ws.readyState === 1) { sendMessage(targetMember.ws, NET_MSG.TEAM_DISBAND, { reason: 'kicked' }); const targetInfo = players.get(targetMember.ws); if (targetInfo) targetInfo.teamId = null; } teamRoom.removePlayer(targetId); console.log(`[Server] Player ${targetId} kicked from team ${teamRoom.id}`); teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } function handleTeamDisband(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom) return; // Only leader can disband if (teamRoom.leaderId !== playerInfo.playerId) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Only the leader can disband' }); return; } console.log(`[Server] Team ${teamRoom.id} disbanded by leader`); // Notify all members teamRoom.broadcast(NET_MSG.TEAM_DISBAND, { reason: 'disbanded' }); // Clear teamId for all human members for (const member of teamRoom.getAllHumanMembers()) { if (member.ws) { const info = players.get(member.ws); if (info) info.teamId = null; } } cleanupTeamRoom(teamRoom.id); } function handleMatchStart(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom) return; // Only leader can start matching if (teamRoom.leaderId !== playerInfo.playerId) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Only the leader can start matching' }); return; } if (teamRoom.state !== 'forming') return; // Check all team A members are ready if (!teamRoom.isTeamAReady()) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Not all team members are ready' }); return; } teamRoom.state = 'matching'; teamRoom.matchStartTime = Date.now(); // Add to match pool teamMatchPool.push(teamRoom); console.log(`[Server] Team ${teamRoom.id} entered matching pool`); // Broadcast matching state teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); // Set match timeout teamRoom.matchTimer = setTimeout(() => { if (teamRoom.state === 'matching') { handleMatchTimeout(teamRoom); } }, TEAM_MATCH_TIMEOUT); // Try to match immediately tryMatchTeams(); } function handleMatchCancel(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom || teamRoom.state !== 'matching') return; // Only leader can cancel if (teamRoom.leaderId !== playerInfo.playerId) return; cancelMatch(teamRoom); } function handleSoloMatch(ws, data) { const playerInfo = players.get(ws); if (!playerInfo) return; // Leave current team if any if (playerInfo.teamId) { handleLeaveTeam(ws, {}); } // Create a solo team room for this player const teamId = generateTeamId(); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRoom.state = 'matching'; teamRoom.matchStartTime = Date.now(); teamRooms.set(teamId, teamRoom); playerInfo.teamId = teamId; // Add to solo match pool soloMatchPool.push(ws); console.log(`[Server] Player ${playerInfo.playerId} entered solo match pool (team ${teamId})`); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); // Set match timeout teamRoom.matchTimer = setTimeout(() => { if (teamRoom.state === 'matching') { handleMatchTimeout(teamRoom); } }, TEAM_MATCH_TIMEOUT); // Try to match tryMatchTeams(); } function cancelMatch(teamRoom) { if (teamRoom.matchTimer) { clearTimeout(teamRoom.matchTimer); teamRoom.matchTimer = null; } teamRoom.state = 'forming'; teamRoom.matchStartTime = null; // Remove from match pool const idx = teamMatchPool.indexOf(teamRoom); if (idx !== -1) teamMatchPool.splice(idx, 1); // Remove solo players from solo pool for (const member of teamRoom.teamA) { const soloIdx = soloMatchPool.indexOf(member.ws); if (soloIdx !== -1) soloMatchPool.splice(soloIdx, 1); } console.log(`[Server] Match cancelled for team ${teamRoom.id}`); teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } function handleMatchTimeout(teamRoom) { console.log(`[Server] Match timeout for team ${teamRoom.id}, filling with bots`); // Remove from match pool const idx = teamMatchPool.indexOf(teamRoom); if (idx !== -1) teamMatchPool.splice(idx, 1); // Fill remaining slots with bots teamRoom.fillWithBots(); // Start the game startTeamGame(teamRoom); } /** * Try to match teams from the pool. * Simple matching: pair two teams or combine solo players. */ function tryMatchTeams() { // Try to pair two team queues if (teamMatchPool.length >= 2) { const teamA_room = teamMatchPool.shift(); const teamB_room = teamMatchPool.shift(); // Merge team B members into team A room as opponents for (const member of teamB_room.teamA) { teamA_room.addToTeamB(member.ws, member.playerId, member.nickname || ''); if (member.ws) { const info = players.get(member.ws); if (info) info.teamId = teamA_room.id; } } // Clean up team B room if (teamB_room.matchTimer) clearTimeout(teamB_room.matchTimer); teamRooms.delete(teamB_room.id); // Fill remaining with bots if needed teamA_room.fillWithBots(); console.log(`[Server] Teams matched: ${teamA_room.id}`); startTeamGame(teamA_room); return; } // Try to combine solo players into teams // Collect all solo players that are in matching state const availableSolos = soloMatchPool.filter(ws => { const info = players.get(ws); if (!info || !info.teamId) return false; const room = teamRooms.get(info.teamId); return room && room.state === 'matching'; }); if (availableSolos.length >= 2) { // Take up to 10 solo players and form a game const gamePlayers = availableSolos.splice(0, Math.min(10, availableSolos.length)); // Remove from solo pool for (const ws of gamePlayers) { const idx = soloMatchPool.indexOf(ws); if (idx !== -1) soloMatchPool.splice(idx, 1); } // Use the first player's team room as the game room const firstInfo = players.get(gamePlayers[0]); const gameRoom = teamRooms.get(firstInfo.teamId); // Clear match timer if (gameRoom.matchTimer) { clearTimeout(gameRoom.matchTimer); gameRoom.matchTimer = null; } // Add remaining players, alternating teams for (let i = 1; i < gamePlayers.length; i++) { const ws = gamePlayers[i]; const info = players.get(ws); if (!info) continue; // Clean up their old solo team room const oldRoom = teamRooms.get(info.teamId); if (oldRoom && oldRoom.id !== gameRoom.id) { if (oldRoom.matchTimer) clearTimeout(oldRoom.matchTimer); teamRooms.delete(oldRoom.id); } info.teamId = gameRoom.id; // Alternate: odd index -> team A, even index -> team B if (i % 2 === 1 && !gameRoom.isTeamAFull()) { gameRoom.addToTeamA(ws, info.playerId, info.nickname || ''); } else { gameRoom.addToTeamB(ws, info.playerId, info.nickname || ''); } } // Fill with bots gameRoom.fillWithBots(); console.log(`[Server] Solo players matched into team ${gameRoom.id}`); startTeamGame(gameRoom); } } /** * Start a team game (supports both 1v1 and 3v3). * @param {TeamRoom} teamRoom */ function startTeamGame(teamRoom) { teamRoom.state = 'playing'; teamRoom.gameStartTime = Date.now(); const config = BATTLE_CONFIG[teamRoom.battleMode] || BATTLE_CONFIG['3v3']; teamRoom.teamABaseHp = config.baseHp; teamRoom.teamBBaseHp = config.baseHp; if (teamRoom.matchTimer) { clearTimeout(teamRoom.matchTimer); teamRoom.matchTimer = null; } const gameData = { mapId: teamRoom.mapId, teamA: teamRoom.teamA.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot })), teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot })), teamABaseHp: teamRoom.teamABaseHp, teamBBaseHp: teamRoom.teamBBaseHp, battleMode: teamRoom.battleMode, roomId: teamRoom.id, }; console.log(`[Server] ${teamRoom.battleMode} game starting in room ${teamRoom.id}`); // For 1v1, use GAME_START message (client RoomScene listens for it) // For 3v3, use TEAM_GAME_START message (client TeamRoomScene listens for it) if (teamRoom.battleMode === '1v1') { // Send immediately (countdown already happened in handleJoinRoom) teamRoom.broadcast(NET_MSG.GAME_START, gameData); } else { // Notify all players with a short delay for loading setTimeout(() => { teamRoom.broadcast(NET_MSG.TEAM_GAME_START, gameData); }, 3000); } } /** * End a team game (supports both 1v1 and 3v3). * @param {TeamRoom} teamRoom * @param {string} reason - 'base_destroyed' (only valid reason) */ function endTeamGame(teamRoom, reason) { if (teamRoom.state !== 'playing') return; teamRoom.state = 'finished'; // Determine winner: the team whose base is destroyed loses let winner = ''; if (teamRoom.teamABaseHp <= 0) winner = 'B'; else if (teamRoom.teamBBaseHp <= 0) winner = 'A'; const resultData = { winner, reason: 'base_destroyed', teamABaseHp: teamRoom.teamABaseHp, teamBBaseHp: teamRoom.teamBBaseHp, battleMode: teamRoom.battleMode, }; console.log(`[Server] ${teamRoom.battleMode} game ended in room ${teamRoom.id}, winner: ${winner}, reason: base_destroyed`); // Use appropriate message type if (teamRoom.battleMode === '1v1') { teamRoom.broadcast(NET_MSG.GAME_OVER, resultData); } else { teamRoom.broadcast(NET_MSG.TEAM_GAME_OVER, resultData); } // Initialize rematch tracking teamRoom._rematchPlayers = new Set(); // Set a cleanup timer: if no rematch within 60s, clean up teamRoom._rematchTimer = setTimeout(() => { if (teamRoom.state === 'finished') { cleanupTeamRoom(teamRoom.id); } }, 60000); // 60s for result screen + rematch window } function handleBaseHit(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom || teamRoom.state !== 'playing') return; const { targetTeam, damage } = data; const dmg = Math.min(damage || 1, 3); // Cap damage to prevent abuse // Validate targetTeam value if (targetTeam !== 'A' && targetTeam !== 'B') return; if (targetTeam === 'A') { teamRoom.teamABaseHp = Math.max(0, teamRoom.teamABaseHp - dmg); } else if (targetTeam === 'B') { teamRoom.teamBBaseHp = Math.max(0, teamRoom.teamBBaseHp - dmg); } // Broadcast base hit to all players teamRoom.broadcast(NET_MSG.BASE_HIT, { targetTeam, damage: dmg, teamABaseHp: teamRoom.teamABaseHp, teamBBaseHp: teamRoom.teamBBaseHp, }); // Check if base destroyed if (teamRoom.teamABaseHp <= 0 || teamRoom.teamBBaseHp <= 0) { endTeamGame(teamRoom, 'base_destroyed'); } } function handleTeamPlayerDisconnect(ws) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) return; const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom) return; const member = teamRoom.getMemberByWs(ws); if (!member) return; if (teamRoom.state === 'playing') { if (teamRoom.battleMode === '1v1') { // 1v1 mode: opponent left = immediate win for the other player member.disconnectedAt = Date.now(); member.ws = null; console.log(`[Server] Player ${member.playerId} disconnected from 1v1 game ${teamRoom.id}`); teamRoom.broadcast(NET_MSG.OPPONENT_LEFT, { playerId: member.playerId }); } else { // 3v3 mode: mark as disconnected, allow reconnect with bot takeover member.disconnectedAt = Date.now(); member.ws = null; console.log(`[Server] Player ${member.playerId} disconnected from team game ${teamRoom.id}`); teamRoom.broadcast(NET_MSG.PLAYER_DISCONNECT, { playerId: member.playerId }); // Set bot takeover timer setTimeout(() => { // Only take over if still disconnected and game is still playing if (member.disconnectedAt && !member.ws && teamRoom.state === 'playing') { member.isBot = true; console.log(`[Server] Bot takeover for ${member.playerId} in team ${teamRoom.id}`); teamRoom.broadcast(NET_MSG.BOT_TAKEOVER, { playerId: member.playerId }); } }, TEAM_RECONNECT_TIMEOUT); } } else if (teamRoom.state === 'finished') { // Game finished, keep player in room for potential rematch // Just mark as disconnected but don't remove from room member.disconnectedAt = Date.now(); member.ws = null; console.log(`[Server] Player ${member.playerId} disconnected from finished game ${teamRoom.id} (kept for rematch)`); } else if (teamRoom.state === 'matching') { // During matching, cancel match and remove player if (teamRoom.leaderId === playerInfo.playerId) { cancelMatch(teamRoom); } handleLeaveTeam(ws, {}); } else { // Not in game, just remove // For 1v1 waiting rooms, notify the other player if (teamRoom.battleMode === '1v1') { teamRoom.broadcast(NET_MSG.OPPONENT_LEFT, { playerId: member.playerId }, ws); } handleLeaveTeam(ws, {}); } } function handleReconnect(ws, data) { const playerInfo = players.get(ws); if (!playerInfo) return; const { teamId, playerId } = data; if (!teamId || !playerId) return; const teamRoom = teamRooms.get(teamId); if (!teamRoom || teamRoom.state !== 'playing') { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Game not found or already ended' }); return; } const member = teamRoom.getMember(playerId); if (!member || !member.disconnectedAt) { sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Cannot reconnect' }); return; } // Reconnect successful member.ws = ws; member.disconnectedAt = null; member.isBot = false; playerInfo.teamId = teamId; playerInfo.playerId = playerId; console.log(`[Server] Player ${playerId} reconnected to team ${teamId}`); // Send current game state sendMessage(ws, NET_MSG.RECONNECT_OK, { teamState: teamRoom.getTeamState(), mapId: teamRoom.mapId, teamABaseHp: teamRoom.teamABaseHp, teamBBaseHp: teamRoom.teamBBaseHp, elapsed: Math.floor((Date.now() - teamRoom.gameStartTime) / 1000), }); teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); } /** * Handle a rematch request from a player. * When all human players in the room request rematch, reset and restart. */ function handleRematch(ws, data) { const playerInfo = players.get(ws); if (!playerInfo || !playerInfo.teamId) { console.log(`[Server] Rematch rejected: no playerInfo or teamId`, playerInfo ? playerInfo.playerId : 'unknown'); return; } const teamRoom = teamRooms.get(playerInfo.teamId); if (!teamRoom || teamRoom.state !== 'finished') { console.log(`[Server] Rematch rejected: room not found or not finished`, playerInfo.teamId, teamRoom ? teamRoom.state : 'no room'); return; } // Initialize rematch set if needed if (!teamRoom._rematchPlayers) { teamRoom._rematchPlayers = new Set(); } // Update the member's ws reference (in case of reconnect) const member = teamRoom.getMember(playerInfo.playerId); if (member) { member.ws = ws; member.disconnectedAt = null; } teamRoom._rematchPlayers.add(playerInfo.playerId); // Count all human members (connected or not) const allHumans = teamRoom.getAllHumanMembers(); console.log(`[Server] Rematch request from ${playerInfo.playerId} in room ${teamRoom.id} (${teamRoom._rematchPlayers.size}/${allHumans.length})`); // Notify all players about who wants rematch teamRoom.broadcast(NET_MSG.REMATCH_READY, { playerId: playerInfo.playerId, readyCount: teamRoom._rematchPlayers.size, totalCount: allHumans.length, }); // Check if all human players who are still connected want rematch const connectedHumans = allHumans.filter( m => m.ws && m.ws.readyState === 1 ); const allReady = connectedHumans.length > 0 && connectedHumans.every( m => teamRoom._rematchPlayers.has(m.playerId) ); console.log(`[Server] Rematch check: connectedHumans=${connectedHumans.length}, allReady=${allReady}, rematchPlayers=[${[...teamRoom._rematchPlayers].join(',')}]`); if (allReady) { // Clear the cleanup timer if (teamRoom._rematchTimer) { clearTimeout(teamRoom._rematchTimer); teamRoom._rematchTimer = null; } console.log(`[Server] All players ready for rematch in room ${teamRoom.id}`); // Reset room for rematch teamRoom.resetForRematch(); // Remove any bot members from previous game (they'll be re-filled) teamRoom.teamA = teamRoom.teamA.filter(m => !m.isBot); teamRoom.teamB = teamRoom.teamB.filter(m => !m.isBot); // Fill with bots if needed if (teamRoom.fillWithBotsEnabled) { teamRoom.fillWithBots(); } // Start the game again after a short delay if (teamRoom.battleMode === '1v1') { teamRoom.state = 'playing'; setTimeout(() => { startTeamGame(teamRoom); }, 3500); // 3.5s countdown } else { teamRoom.state = 'playing'; startTeamGame(teamRoom); } } } function cleanupTeamRoom(teamId) { const teamRoom = teamRooms.get(teamId); if (!teamRoom) return; if (teamRoom.matchTimer) clearTimeout(teamRoom.matchTimer); if (teamRoom._rematchTimer) clearTimeout(teamRoom._rematchTimer); // Clear teamId for all human members for (const member of teamRoom.getAllHumanMembers()) { if (member.ws) { const info = players.get(member.ws); if (info) info.teamId = null; } } // Remove from match pool const idx = teamMatchPool.indexOf(teamRoom); if (idx !== -1) teamMatchPool.splice(idx, 1); teamRooms.delete(teamId); console.log(`[Server] Team room ${teamId} cleaned up`); } // ============================================================ // Message Handlers // ============================================================ function handleMessage(ws, rawData) { let msg; try { msg = JSON.parse(rawData); } catch (e) { console.error('[Server] Invalid JSON:', rawData); return; } const { type, data, playerId, nickname } = msg; // Update player info const playerInfo = players.get(ws); if (playerInfo) { playerInfo.lastPing = Date.now(); if (playerId && !playerInfo.playerId) { playerInfo.playerId = playerId; } // Refresh nickname on every message (it may be granted mid-session). if (typeof nickname === 'string' && nickname) { if (playerInfo.nickname !== nickname) { playerInfo.nickname = nickname; // Also propagate into any active team room member entry. if (playerInfo.teamId) { const tr = teamRooms.get(playerInfo.teamId); if (tr) { const member = tr.getMemberByWs(ws); if (member && member.nickname !== nickname) { member.nickname = nickname; // Broadcast regardless of room state (forming / matching / playing) // so that peers always render the latest display name — in 3v3 a // player may only tap the UserInfoButton AFTER the match starts. tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState()); } } } } } } switch (type) { case NET_MSG.PING: sendMessage(ws, NET_MSG.PONG, {}); break; // 1v1 PVP case NET_MSG.CREATE_ROOM: handleCreateRoom(ws, data || {}); break; case NET_MSG.JOIN_ROOM: handleJoinRoom(ws, data || {}); break; // Relay gameplay messages case NET_MSG.PLAYER_INPUT: case NET_MSG.PLAYER_STATE: case NET_MSG.BULLET_FIRE: case NET_MSG.BULLET_HIT: case NET_MSG.PLAYER_HIT: case NET_MSG.PLAYER_KILLED: case NET_MSG.GAME_OVER: // All modes now use teamRoom relay if (playerInfo && playerInfo.teamId) { relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId }); } else if (playerInfo && playerInfo.roomId) { relayToOpponent(ws, type, data || {}); } break; // 3v3 Team messages case NET_MSG.CREATE_TEAM: handleCreateTeam(ws, data || {}); break; case NET_MSG.JOIN_TEAM: handleJoinTeam(ws, data || {}); break; case NET_MSG.LEAVE_TEAM: handleLeaveTeam(ws, data || {}); break; case NET_MSG.TEAM_READY: handleTeamReady(ws, data || {}); break; case NET_MSG.TEAM_KICK: handleTeamKick(ws, data || {}); break; case NET_MSG.TEAM_DISBAND: handleTeamDisband(ws, data || {}); break; case NET_MSG.MATCH_START: handleMatchStart(ws, data || {}); break; case NET_MSG.MATCH_CANCEL: handleMatchCancel(ws, data || {}); break; case NET_MSG.SOLO_MATCH: handleSoloMatch(ws, data || {}); break; case NET_MSG.BASE_HIT: // All modes now use server-authoritative base HP tracking via TeamRoom if (playerInfo && playerInfo.teamId) { handleBaseHit(ws, data || {}); } else if (playerInfo && playerInfo.roomId) { // Legacy fallback for old 1v1 rooms relayToOpponent(ws, type, data || {}); } break; case NET_MSG.PLAYER_RESPAWN: if (playerInfo && playerInfo.teamId) { relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId }); } break; case NET_MSG.RECONNECT: handleReconnect(ws, data || {}); break; case NET_MSG.REMATCH: handleRematch(ws, data || {}); break; default: console.warn(`[Server] Unknown message type: ${type}`); } } // ============================================================ // Anti-Cheat & Rate Limiting // ============================================================ /** @type {Map} IP-based ad request tracking */ const adRequestTracker = new Map(); /** @type {Map} IP-based purchase tracking for anomaly detection */ const purchaseTracker = new Map(); /** * Check if an IP has exceeded the ad request rate limit. * @param {string} ip * @returns {boolean} true if rate limited */ function isAdRateLimited(ip) { const now = Date.now(); const tracker = adRequestTracker.get(ip); if (!tracker || now > tracker.resetTime) { adRequestTracker.set(ip, { count: 1, resetTime: now + 60000 }); // 1 minute window return false; } tracker.count++; // More than 20 ad requests per minute is suspicious if (tracker.count > 20) { console.warn(`[AntiCheat] Ad rate limit triggered for IP: ${ip} (${tracker.count} requests/min)`); return true; } return false; } /** * Check for suspicious purchase patterns. * @param {string} ip * @param {number} amount - Purchase amount in fen * @returns {{ suspicious: boolean, reason?: string }} */ function checkPurchaseAnomaly(ip, amount) { const now = Date.now(); if (!purchaseTracker.has(ip)) { purchaseTracker.set(ip, []); } const history = purchaseTracker.get(ip); // Clean old entries (keep last 24 hours) const cutoff = now - 24 * 60 * 60 * 1000; while (history.length > 0 && history[0] < cutoff) { history.shift(); } history.push(now); // Flag: more than 10 purchases in 24 hours if (history.length > 10) { console.warn(`[AntiCheat] Suspicious purchase frequency from IP: ${ip} (${history.length} in 24h)`); return { suspicious: true, reason: 'high_frequency' }; } // Flag: single purchase > ¥500 (50000 fen) if (amount > 50000) { console.warn(`[AntiCheat] Large purchase flagged from IP: ${ip} (¥${amount / 100})`); return { suspicious: true, reason: 'large_amount' }; } return { suspicious: false }; } /** * Validate game session stats for impossible values. * @param {object} stats - { kills, timeElapsed, score, playerId } * @returns {{ valid: boolean, flags: string[] }} */ function validateGameStats(stats) { const flags = []; if (!stats) return { valid: true, flags }; // Impossible kill rate: >10 kills per minute if (stats.kills && stats.timeElapsed) { const killsPerMinute = stats.kills / (stats.timeElapsed / 60); if (killsPerMinute > 10) { flags.push('impossible_kill_rate'); } } // Impossible score if (stats.score && stats.score > 100000) { flags.push('impossible_score'); } // Impossible speed (if movement data is provided) if (stats.maxSpeed && stats.maxSpeed > 500) { flags.push('impossible_speed'); } if (flags.length > 0) { console.warn(`[AntiCheat] Suspicious game stats from ${stats.playerId || 'unknown'}: ${flags.join(', ')}`); } return { valid: flags.length === 0, flags }; } // Clean up rate limit trackers periodically setInterval(() => { const now = Date.now(); for (const [ip, tracker] of adRequestTracker) { if (now > tracker.resetTime) { adRequestTracker.delete(ip); } } // Clean purchase tracker (entries older than 24h) for (const [ip, history] of purchaseTracker) { const cutoff = now - 24 * 60 * 60 * 1000; while (history.length > 0 && history[0] < cutoff) { history.shift(); } if (history.length === 0) { purchaseTracker.delete(ip); } } }, 300000); // Every 5 minutes // ============================================================ // ============================================================ // WebSocket Server (noServer mode, shares HTTP server with express) // ============================================================ // Use noServer mode so the WS upgrade only fires on the configured path. const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (req, socket, head) => { // Only upgrade on the configured WebSocket path; reject any other path. const pathname = (req.url || '').split('?')[0]; if (pathname !== WS_PATH) { console.warn(`[Server] Rejected WebSocket upgrade on unexpected path: ${req.url}`); socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); }); wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; console.log(`[Server] New connection from ${ip}`); const playerInfo = new PlayerInfo(ws, null); players.set(ws, playerInfo); ws.on('message', (rawData) => { handleMessage(ws, rawData.toString()); }); ws.on('close', () => { console.log(`[Server] Connection closed from ${ip}`); handleTeamPlayerDisconnect(ws); leaveRoom(ws); players.delete(ws); }); ws.on('error', (err) => { console.error(`[Server] WebSocket error from ${ip}:`, err.message); }); }); // ============================================================ // Heartbeat & Cleanup // ============================================================ setInterval(() => { const now = Date.now(); // Clean up stale players for (const [ws, info] of players) { if (now - info.lastPing > HEARTBEAT_INTERVAL * 3) { console.log(`[Server] Removing stale player ${info.playerId}`); leaveRoom(ws); players.delete(ws); try { ws.terminate(); } catch (e) { /* ignore */ } } } // Clean up old empty rooms for (const [id, room] of rooms) { if (room.isEmpty() || now - room.createdAt > ROOM_TIMEOUT) { rooms.delete(id); console.log(`[Server] Room ${id} cleaned up`); } } // Clean up old team rooms for (const [id, teamRoom] of teamRooms) { const allHumans = teamRoom.getAllHumanMembers(); const hasConnected = allHumans.some(m => m.ws && m.ws.readyState === 1); if (!hasConnected || now - teamRoom.createdAt > ROOM_TIMEOUT * 2) { cleanupTeamRoom(id); console.log(`[Server] Team room ${id} cleaned up (stale)`); } } }, HEARTBEAT_INTERVAL); // ============================================================ // Startup // ============================================================ server.listen(PORT, HOST, () => { console.log(`[Tank War Server] Running on ${HOST}:${PORT}`); console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`); console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`); console.log(`[Tank War Server] HTTP API URL: http://${HOST}:${PORT}`); });