1682 lines
49 KiB
JavaScript
1682 lines
49 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 http = require('http');
|
|
|
|
// ============================================================
|
|
// 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
|
|
|
|
// ============================================================
|
|
// HTTP Health Check Server
|
|
// ============================================================
|
|
const healthServer = http.createServer((req, res) => {
|
|
if (req.url === '/health' || req.url === '/tankwar/health') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString(),
|
|
activeConnections: players.size,
|
|
activeRooms: rooms.size,
|
|
activeTeamRooms: teamRooms.size
|
|
}));
|
|
} else {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not Found');
|
|
}
|
|
});
|
|
|
|
healthServer.listen(PORT, HOST, () => {
|
|
console.log(`[Health Server] Running on ${HOST}:${PORT}`);
|
|
});
|
|
|
|
// ============================================================
|
|
// 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<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.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<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 health check)
|
|
// ============================================================
|
|
// Use noServer mode so the WS upgrade only fires on the configured path.
|
|
// This lets /health stay as plain HTTP on the same port.
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
healthServer.on('upgrade', (req, socket, head) => {
|
|
// Only upgrade on the configured WebSocket path; reject any other path.
|
|
// We compare by pathname so query strings are tolerated.
|
|
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
|
|
// ============================================================
|
|
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`);
|