Files
2026-06-07 22:08:00 +08:00

1836 lines
56 KiB
JavaScript

/**
* 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',
TERRAIN_CHANGE: 'terrain_change',
BOT_STATE: 'bot_state',
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 },
'2v2': { teamSize: 2, baseHp: 8, fillWithBots: true },
'3v3': { teamSize: 3, baseHp: 10, fillWithBots: true },
};
// ============================================================
// Room Management
// ============================================================
/** @type {Map<string, Room>} */
const rooms = new Map();
/** @type {Map<WebSocket, PlayerInfo>} */
const players = new Map();
/** @type {Map<string, TeamRoom>} */
const teamRooms = new Map();
/** @type {Array<TeamRoom>} Matching pool for team queues */
const teamMatchPool = [];
/** @type {Array<WebSocket>} 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.avatarUrl = '';
this.skinId = ''; // equipped tank skin id
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 = '', leaderAvatarUrl = '', leaderSkinId = '') {
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, avatarUrl, skinId, ready, isBot, disconnectedAt }
this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', avatarUrl: leaderAvatarUrl || '', skinId: leaderSkinId || '', 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 team B is full */
isTeamBFull() {
return this.teamB.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 = '', avatarUrl = '', skinId = '') {
if (this.teamA.length >= this.teamSize) return false;
// Prevent duplicate playerId across both teams
if (this.teamA.some(m => m.playerId === playerId) || this.teamB.some(m => m.playerId === playerId)) return false;
this.teamA.push({ ws, playerId, nickname, avatarUrl, skinId, ready: false, isBot: false, disconnectedAt: null });
return true;
}
addToTeamB(ws, playerId, nickname = '', avatarUrl = '', skinId = '') {
if (this.teamB.length >= this.teamSize) return false;
// Prevent duplicate playerId across both teams
if (this.teamA.some(m => m.playerId === playerId) || this.teamB.some(m => m.playerId === playerId)) return false;
this.teamB.push({ ws, playerId, nickname, avatarUrl, skinId, 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: '',
avatarUrl: '',
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: '',
avatarUrl: '',
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 || '',
avatarUrl: m.avatarUrl || '',
skinId: m.skinId || '',
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 || '',
avatarUrl: m.avatarUrl || '',
skinId: m.skinId || '',
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 || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
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.avatarUrl || '', playerInfo.skinId || '');
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, {});
}
// Read battleMode from client data, fall back to '3v3' if not provided
const battleMode = (data && data.battleMode) || '3v3';
const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, battleMode, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId;
console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId} (mode: ${battleMode})`);
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, (data && data.battleMode) || '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
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.avatarUrl || '', playerInfo.skinId || '');
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 (dedup guard)
if (!teamMatchPool.includes(teamRoom)) {
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, {});
}
// Read battleMode from client data, fall back to '3v3' if not provided
const battleMode = (data && data.battleMode) || '3v3';
// Create a solo team room for this player
const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, battleMode, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRoom.state = 'matching';
teamRoom.matchStartTime = Date.now();
teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId;
// Add to solo match pool (dedup guard)
if (!soloMatchPool.includes(ws)) {
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 room members into team A room as opponents
// Both teamA and teamB of teamB_room should be moved
const allBMembers = [...teamB_room.teamA, ...teamB_room.teamB];
for (const member of allBMembers) {
teamA_room.addToTeamB(member.ws, member.playerId, member.nickname || '', member.avatarUrl || '', member.skinId || '');
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) {
// Deduplicate by playerId — same player should not appear twice
const seenIds = new Set();
const gamePlayersRaw = availableSolos.splice(0, Math.min(10, availableSolos.length));
const gamePlayers = gamePlayersRaw.filter(ws => {
const info = players.get(ws);
if (!info || seenIds.has(info.playerId)) return false;
seenIds.add(info.playerId);
return true;
});
// 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 teams: first remaining player → teamB, next → teamA, etc.
// (player at index 0 is already in teamA as the room creator)
const isTeamBSlot = (i % 2 === 1);
if (isTeamBSlot && !gameRoom.isTeamBFull()) {
gameRoom.addToTeamB(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} else if (!isTeamBSlot && !gameRoom.isTeamAFull()) {
gameRoom.addToTeamA(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} else if (!gameRoom.isTeamBFull()) {
gameRoom.addToTeamB(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} else {
gameRoom.addToTeamA(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
}
}
// Fill with bots
gameRoom.fillWithBots();
console.log(`[Server] Solo players matched into team ${gameRoom.id}`);
// Send MATCH_FOUND to all players before starting the game
gameRoom.broadcast(NET_MSG.MATCH_FOUND, {});
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, skinId: m.skinId || '' })),
teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot, skinId: m.skinId || '' })),
teamABaseHp: teamRoom.teamABaseHp,
teamBBaseHp: teamRoom.teamBBaseHp,
battleMode: teamRoom.battleMode,
roomId: teamRoom.id,
};
console.log(`[Server] ${teamRoom.battleMode} game starting in room ${teamRoom.id}`);
console.log(`[Server] teamA: ${JSON.stringify(gameData.teamA.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`);
console.log(`[Server] teamB: ${JSON.stringify(gameData.teamB.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`);
// 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, avatarUrl, skinId } = 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());
}
}
}
}
}
// Refresh avatarUrl on every message (it may be granted mid-session).
if (typeof avatarUrl === 'string' && avatarUrl && playerInfo.avatarUrl !== avatarUrl) {
playerInfo.avatarUrl = avatarUrl;
if (playerInfo.teamId) {
const tr = teamRooms.get(playerInfo.teamId);
if (tr) {
const member = tr.getMemberByWs(ws);
if (member && member.avatarUrl !== avatarUrl) {
member.avatarUrl = avatarUrl;
tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState());
}
}
}
}
// Refresh skinId on every message (equipped skin may change mid-session).
if (typeof skinId === 'string' && skinId && playerInfo.skinId !== skinId) {
playerInfo.skinId = skinId;
if (playerInfo.teamId) {
const tr = teamRooms.get(playerInfo.teamId);
if (tr) {
const member = tr.getMemberByWs(ws);
if (member && member.skinId !== skinId) {
member.skinId = skinId;
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:
// Messages where playerId is the sender themselves — override to prevent spoofing
case NET_MSG.PLAYER_INPUT:
if (playerInfo && playerInfo.teamId) {
relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId });
} else if (playerInfo && playerInfo.roomId) {
relayToOpponent(ws, type, data || {});
}
break;
// Messages where playerId/victimId/killerId refer to specific entities (bots, etc.)
// — must NOT be overwritten with the sender's playerId
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:
case NET_MSG.TERRAIN_CHANGE:
case NET_MSG.BOT_STATE:
if (playerInfo && playerInfo.teamId) {
relayToTeamRoom(ws, type, data || {});
} 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<string, { count: number, resetTime: number }>} IP-based ad request tracking */
const adRequestTracker = new Map();
/** @type {Map<string, number[]>} 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}`);
});