Files
tankwar_proj/server/index.js
T
2026-04-10 22:59:39 +08:00

1608 lines
46 KiB
JavaScript

/**
* Tank War PVP Server
* WebSocket server for online 1v1 multiplayer.
* Handles room management, message relay, and basic game state authority.
*/
const { WebSocketServer } = require('ws');
// ============================================================
// Configuration
// ============================================================
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const HEARTBEAT_INTERVAL = 10000; // ms
const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout
// ============================================================
// 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.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')
*/
constructor(id, leaderWs, leaderId, battleMode = '3v3') {
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, ready, isBot, disconnectedAt }
this.teamA = [{ ws: leaderWs, playerId: leaderId, 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) {
if (this.isTeamAFull()) return false;
this.teamA.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null });
return true;
}
/** Add a player to team B */
addToTeamB(ws, playerId) {
if (this.teamB.length >= this.teamSize) return false;
this.teamB.push({ ws, playerId, 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}`,
ready: true,
isBot: true,
disconnectedAt: null,
});
}
while (this.teamB.length < this.teamSize) {
botCounter++;
this.teamB.push({
ws: null,
playerId: `bot_b_${botCounter}_${this.id}`,
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,
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,
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');
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.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);
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);
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.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);
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);
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);
} else {
gameRoom.addToTeamB(ws, info.playerId);
}
}
// 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, isBot: m.isBot })),
teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, 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 } = msg;
// Update player info
const playerInfo = players.get(ws);
if (playerInfo) {
playerInfo.lastPing = Date.now();
if (playerId && !playerInfo.playerId) {
playerInfo.playerId = playerId;
}
}
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
// ============================================================
const wss = new WebSocketServer({ host: HOST, port: PORT });
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 URL: ws://${HOST}:${PORT}`);