feat: FunConnect v1.0.0 - Minecraft联机平台完整版

- server: Node.js TCP中继服务器,支持多节点集群
- web: React管理面板(仪表盘、房间管理、节点管理)
- client: Electron桌面客户端(连接、创建/加入房间、本地代理)
- deploy: Ubuntu一键部署脚本
This commit is contained in:
FunMC
2026-02-22 23:33:00 +08:00
commit b17679cec6
44 changed files with 13783 additions and 0 deletions

211
server/src/api.ts Normal file
View File

@@ -0,0 +1,211 @@
import express, { Request, Response, Router } from 'express';
import { RoomManager } from './room';
import { NodeManager } from './node-manager';
import { RelayEngine } from './relay';
import config from './config';
import logger from './logger';
export function createApiRouter(
roomManager: RoomManager,
nodeManager: NodeManager,
relayEngine: RelayEngine
): Router {
const router = Router();
// ===== Health & Status =====
router.get('/health', (_req: Request, res: Response) => {
res.json({
status: 'ok',
nodeId: config.nodeId,
nodeName: config.nodeName,
isMaster: config.isMaster,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
router.get('/stats', (_req: Request, res: Response) => {
const nodeStats = config.isMaster ? nodeManager.getStats() : null;
res.json({
node: {
id: config.nodeId,
name: config.nodeName,
rooms: roomManager.getRoomCount(),
players: roomManager.getTotalPlayers(),
maxRooms: config.maxRooms,
},
cluster: nodeStats,
});
});
// ===== Room Management =====
router.get('/rooms', (_req: Request, res: Response) => {
const rooms = roomManager.listRooms();
res.json({ rooms, total: rooms.length });
});
router.post('/rooms', (req: Request, res: Response) => {
const { name, hostName, hostPort, gameVersion, gameEdition, maxPlayers, password } = req.body;
if (!name || !hostName || !hostPort || !gameVersion) {
res.status(400).json({ error: '缺少必要参数: name, hostName, hostPort, gameVersion' });
return;
}
const room = roomManager.createRoom({
name,
hostName,
hostPort: parseInt(hostPort),
gameVersion,
gameEdition: gameEdition || 'java',
maxPlayers: parseInt(maxPlayers) || 10,
nodeId: config.nodeId,
password,
});
if (!room) {
res.status(503).json({ error: '房间数量已达上限' });
return;
}
res.status(201).json({
room: room.toInfo(),
connectInfo: {
host: config.isMaster ? 'MASTER_HOST' : config.nodeName,
port: config.port,
roomId: room.id,
},
});
});
router.get('/rooms/:roomId', (req: Request, res: Response) => {
const room = roomManager.getRoom(req.params.roomId);
if (!room) {
res.status(404).json({ error: '房间不存在' });
return;
}
res.json({ room: room.toInfo() });
});
router.delete('/rooms/:roomId', (req: Request, res: Response) => {
const deleted = roomManager.deleteRoom(req.params.roomId);
if (!deleted) {
res.status(404).json({ error: '房间不存在' });
return;
}
res.json({ message: '房间已删除' });
});
// ===== Node Management (Master only) =====
router.get('/nodes', (_req: Request, res: Response) => {
if (!config.isMaster) {
res.status(403).json({ error: '仅主节点可管理节点列表' });
return;
}
const nodes = nodeManager.listNodes();
res.json({ nodes, total: nodes.length });
});
router.post('/nodes/register', (req: Request, res: Response) => {
if (!config.isMaster) {
res.status(403).json({ error: '仅主节点可注册节点' });
return;
}
const { name, host, apiPort, relayPort, maxRooms, region } = req.body;
if (!name || !host || !apiPort || !relayPort) {
res.status(400).json({ error: '缺少必要参数: name, host, apiPort, relayPort' });
return;
}
const node = nodeManager.registerNode({
name,
host,
apiPort: parseInt(apiPort),
relayPort: parseInt(relayPort),
maxRooms: parseInt(maxRooms) || 100,
region: region || 'default',
});
res.status(201).json({ node });
});
router.post('/nodes/:nodeId/heartbeat', (req: Request, res: Response) => {
if (!config.isMaster) {
res.status(403).json({ error: '仅主节点可接收心跳' });
return;
}
const { roomCount, playerCount } = req.body;
const updated = nodeManager.updateNodeHeartbeat(req.params.nodeId, {
roomCount: parseInt(roomCount) || 0,
playerCount: parseInt(playerCount) || 0,
});
if (!updated) {
res.status(404).json({ error: '节点不存在' });
return;
}
res.json({ status: 'ok' });
});
router.delete('/nodes/:nodeId', (req: Request, res: Response) => {
if (!config.isMaster) {
res.status(403).json({ error: '仅主节点可删除节点' });
return;
}
const removed = nodeManager.removeNode(req.params.nodeId);
if (!removed) {
res.status(404).json({ error: '节点不存在' });
return;
}
res.json({ message: '节点已移除' });
});
router.get('/nodes/best', (_req: Request, res: Response) => {
if (!config.isMaster) {
res.status(403).json({ error: '仅主节点可查询最佳节点' });
return;
}
const best = nodeManager.getBestNode();
if (!best) {
res.status(503).json({ error: '无可用节点' });
return;
}
res.json({ node: best });
});
// ===== Cluster Room Query (Master aggregates from all nodes) =====
router.get('/cluster/rooms', async (_req: Request, res: Response) => {
if (!config.isMaster) {
res.status(403).json({ error: '仅主节点可查询集群房间' });
return;
}
const allRooms = roomManager.listRooms();
const nodes = nodeManager.listNodes();
for (const node of nodes) {
if (node.status === 'offline') continue;
try {
const axios = (await import('axios')).default;
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/rooms`, {
timeout: 5000,
});
if (response.data.rooms) {
allRooms.push(...response.data.rooms);
}
} catch (err) {
logger.warn(`Failed to fetch rooms from node ${node.name}: ${err}`);
}
}
res.json({ rooms: allRooms, total: allRooms.length });
});
return router;
}

32
server/src/config.ts Normal file
View File

@@ -0,0 +1,32 @@
import dotenv from 'dotenv';
dotenv.config();
export interface ServerConfig {
port: number;
apiPort: number;
nodeId: string;
nodeName: string;
masterUrl: string;
isMaster: boolean;
secret: string;
maxRooms: number;
maxPlayersPerRoom: number;
heartbeatInterval: number;
logLevel: string;
}
const config: ServerConfig = {
port: parseInt(process.env.RELAY_PORT || '25565'),
apiPort: parseInt(process.env.API_PORT || '3000'),
nodeId: process.env.NODE_ID || '',
nodeName: process.env.NODE_NAME || 'relay-node-1',
masterUrl: process.env.MASTER_URL || '',
isMaster: process.env.IS_MASTER === 'true',
secret: process.env.SECRET || 'funmc-default-secret',
maxRooms: parseInt(process.env.MAX_ROOMS || '100'),
maxPlayersPerRoom: parseInt(process.env.MAX_PLAYERS_PER_ROOM || '20'),
heartbeatInterval: parseInt(process.env.HEARTBEAT_INTERVAL || '10000'),
logLevel: process.env.LOG_LEVEL || 'info',
};
export default config;

134
server/src/index.ts Normal file
View File

@@ -0,0 +1,134 @@
import express from 'express';
import cors from 'cors';
import http from 'http';
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
import config from './config';
import logger from './logger';
import { RoomManager } from './room';
import { NodeManager } from './node-manager';
import { RelayEngine } from './relay';
import { createApiRouter } from './api';
import { WebSocketHandler } from './websocket';
async function main() {
logger.info('========================================');
logger.info(' FunMC Relay Server Starting...');
logger.info('========================================');
if (!config.nodeId) {
config.nodeId = uuidv4().substring(0, 8);
}
logger.info(`Node ID: ${config.nodeId}`);
logger.info(`Node Name: ${config.nodeName}`);
logger.info(`Mode: ${config.isMaster ? 'MASTER' : 'WORKER'}`);
logger.info(`Relay Port: ${config.port}`);
logger.info(`API Port: ${config.apiPort}`);
const roomManager = new RoomManager(config.maxRooms);
const nodeManager = new NodeManager();
const relayEngine = new RelayEngine(roomManager);
const wsHandler = new WebSocketHandler(roomManager, nodeManager);
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api', createApiRouter(roomManager, nodeManager, relayEngine));
app.get('/', (_req, res) => {
res.json({
name: 'FunMC Relay Server',
version: '1.0.0',
nodeId: config.nodeId,
nodeName: config.nodeName,
isMaster: config.isMaster,
status: 'running',
});
});
const httpServer = http.createServer(app);
wsHandler.attach(httpServer);
httpServer.listen(config.apiPort, '0.0.0.0', () => {
logger.info(`HTTP API server listening on port ${config.apiPort}`);
});
await relayEngine.start(config.port);
if (!config.isMaster && config.masterUrl) {
await registerWithMaster();
startHeartbeat(roomManager);
}
if (config.isMaster) {
nodeManager.registerNode({
name: config.nodeName,
host: '127.0.0.1',
apiPort: config.apiPort,
relayPort: config.port,
maxRooms: config.maxRooms,
region: 'local',
});
}
process.on('SIGINT', () => {
logger.info('Shutting down...');
relayEngine.stop();
wsHandler.stop();
httpServer.close();
process.exit(0);
});
process.on('SIGTERM', () => {
logger.info('Shutting down...');
relayEngine.stop();
wsHandler.stop();
httpServer.close();
process.exit(0);
});
logger.info('FunMC Relay Server is ready!');
}
async function registerWithMaster(): Promise<void> {
try {
const response = await axios.post(`${config.masterUrl}/api/nodes/register`, {
name: config.nodeName,
host: getPublicHost(),
apiPort: config.apiPort,
relayPort: config.port,
maxRooms: config.maxRooms,
region: 'default',
});
logger.info(`Registered with master node, assigned ID: ${response.data.node?.id}`);
config.nodeId = response.data.node?.id || config.nodeId;
} catch (err: any) {
logger.error(`Failed to register with master: ${err.message}`);
logger.warn('Running in standalone mode');
}
}
function startHeartbeat(roomManager: RoomManager): void {
setInterval(async () => {
try {
await axios.post(`${config.masterUrl}/api/nodes/${config.nodeId}/heartbeat`, {
roomCount: roomManager.getRoomCount(),
playerCount: roomManager.getTotalPlayers(),
});
} catch (err: any) {
logger.warn(`Heartbeat failed: ${err.message}`);
}
}, config.heartbeatInterval);
}
function getPublicHost(): string {
return process.env.PUBLIC_HOST || '0.0.0.0';
}
main().catch((err) => {
logger.error(`Fatal error: ${err.message}`);
process.exit(1);
});

20
server/src/logger.ts Normal file
View File

@@ -0,0 +1,20 @@
import winston from 'winston';
import config from './config';
const logger = winston.createLogger({
level: config.logLevel,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
})
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
export default logger;

154
server/src/node-manager.ts Normal file
View File

@@ -0,0 +1,154 @@
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import logger from './logger';
import config from './config';
export interface RelayNode {
id: string;
name: string;
host: string;
apiPort: number;
relayPort: number;
status: 'online' | 'offline' | 'busy';
roomCount: number;
playerCount: number;
maxRooms: number;
region: string;
lastHeartbeat: Date;
registeredAt: Date;
}
export class NodeManager {
private nodes: Map<string, RelayNode> = new Map();
private heartbeatTimers: Map<string, NodeJS.Timeout> = new Map();
constructor() {
if (config.isMaster) {
setInterval(() => this.checkNodeHealth(), config.heartbeatInterval);
}
}
registerNode(nodeInfo: {
name: string;
host: string;
apiPort: number;
relayPort: number;
maxRooms: number;
region: string;
}): RelayNode {
const existing = Array.from(this.nodes.values()).find(
n => n.host === nodeInfo.host && n.apiPort === nodeInfo.apiPort
);
if (existing) {
existing.status = 'online';
existing.lastHeartbeat = new Date();
existing.name = nodeInfo.name;
existing.maxRooms = nodeInfo.maxRooms;
existing.region = nodeInfo.region;
logger.info(`Node re-registered: ${existing.name} (${existing.id})`);
return existing;
}
const node: RelayNode = {
id: uuidv4().substring(0, 8),
name: nodeInfo.name,
host: nodeInfo.host,
apiPort: nodeInfo.apiPort,
relayPort: nodeInfo.relayPort,
status: 'online',
roomCount: 0,
playerCount: 0,
maxRooms: nodeInfo.maxRooms,
region: nodeInfo.region,
lastHeartbeat: new Date(),
registeredAt: new Date(),
};
this.nodes.set(node.id, node);
logger.info(`New node registered: ${node.name} (${node.id}) at ${node.host}:${node.relayPort}`);
return node;
}
removeNode(nodeId: string): boolean {
const timer = this.heartbeatTimers.get(nodeId);
if (timer) clearTimeout(timer);
this.heartbeatTimers.delete(nodeId);
const removed = this.nodes.delete(nodeId);
if (removed) {
logger.info(`Node removed: ${nodeId}`);
}
return removed;
}
updateNodeHeartbeat(nodeId: string, stats: { roomCount: number; playerCount: number }): boolean {
const node = this.nodes.get(nodeId);
if (!node) return false;
node.lastHeartbeat = new Date();
node.roomCount = stats.roomCount;
node.playerCount = stats.playerCount;
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
return true;
}
getNode(nodeId: string): RelayNode | undefined {
return this.nodes.get(nodeId);
}
listNodes(): RelayNode[] {
return Array.from(this.nodes.values());
}
getAvailableNodes(): RelayNode[] {
return Array.from(this.nodes.values()).filter(n => n.status === 'online');
}
getBestNode(): RelayNode | undefined {
const available = this.getAvailableNodes();
if (available.length === 0) return undefined;
return available.sort((a, b) => {
const aLoad = a.roomCount / a.maxRooms;
const bLoad = b.roomCount / b.maxRooms;
return aLoad - bLoad;
})[0];
}
private async checkNodeHealth(): Promise<void> {
const now = new Date();
for (const [nodeId, node] of this.nodes) {
const timeSinceHeartbeat = now.getTime() - node.lastHeartbeat.getTime();
if (timeSinceHeartbeat > config.heartbeatInterval * 3) {
node.status = 'offline';
logger.warn(`Node ${node.name} (${nodeId}) marked offline - no heartbeat for ${Math.round(timeSinceHeartbeat / 1000)}s`);
} else {
try {
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/health`, {
timeout: 5000,
});
if (response.data.status === 'ok') {
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
node.lastHeartbeat = new Date();
}
} catch {
if (timeSinceHeartbeat > config.heartbeatInterval * 2) {
node.status = 'offline';
}
}
}
}
}
getStats(): { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } {
const nodes = Array.from(this.nodes.values());
return {
totalNodes: nodes.length,
onlineNodes: nodes.filter(n => n.status !== 'offline').length,
totalRooms: nodes.reduce((sum, n) => sum + n.roomCount, 0),
totalPlayers: nodes.reduce((sum, n) => sum + n.playerCount, 0),
};
}
}

205
server/src/relay.ts Normal file
View File

@@ -0,0 +1,205 @@
import net from 'net';
import logger from './logger';
import { Room, RoomManager } from './room';
import config from './config';
export class RelayEngine {
private server: net.Server | null = null;
private roomManager: RoomManager;
private hostConnections: Map<string, net.Socket> = new Map();
private pendingConnections: Map<string, { roomId: string; playerName: string; resolve: (socket: net.Socket) => void }> = new Map();
constructor(roomManager: RoomManager) {
this.roomManager = roomManager;
}
start(port: number): Promise<void> {
return new Promise((resolve, reject) => {
this.server = net.createServer((clientSocket) => {
this.handleConnection(clientSocket);
});
this.server.on('error', (err) => {
logger.error(`Relay server error: ${err.message}`);
reject(err);
});
this.server.listen(port, '0.0.0.0', () => {
logger.info(`Relay TCP server listening on port ${port}`);
resolve();
});
});
}
private handleConnection(clientSocket: net.Socket): void {
const remoteAddr = `${clientSocket.remoteAddress}:${clientSocket.remotePort}`;
logger.info(`New TCP connection from ${remoteAddr}`);
let buffer = Buffer.alloc(0);
let identified = false;
const timeout = setTimeout(() => {
if (!identified) {
logger.warn(`Connection from ${remoteAddr} timed out waiting for identification`);
clientSocket.destroy();
}
}, 15000);
clientSocket.once('data', (data) => {
clearTimeout(timeout);
identified = true;
buffer = Buffer.concat([buffer, data]);
this.identifyAndRoute(clientSocket, buffer, remoteAddr);
});
clientSocket.on('error', (err) => {
logger.debug(`Socket error from ${remoteAddr}: ${err.message}`);
});
}
private identifyAndRoute(clientSocket: net.Socket, initialData: Buffer, remoteAddr: string): void {
try {
const headerStr = initialData.toString('utf8', 0, Math.min(initialData.length, 512));
if (headerStr.startsWith('FUNMC_HOST:')) {
this.handleHostConnection(clientSocket, headerStr, remoteAddr);
} else if (headerStr.startsWith('FUNMC_JOIN:')) {
this.handlePlayerJoin(clientSocket, headerStr, initialData, remoteAddr);
} else {
logger.debug(`Unknown protocol from ${remoteAddr}, closing`);
clientSocket.destroy();
}
} catch (err: any) {
logger.error(`Error routing connection from ${remoteAddr}: ${err.message}`);
clientSocket.destroy();
}
}
private handleHostConnection(hostSocket: net.Socket, header: string, remoteAddr: string): void {
const parts = header.split('\n')[0].replace('FUNMC_HOST:', '').split('|');
if (parts.length < 2) {
hostSocket.write('ERROR:INVALID_FORMAT\n');
hostSocket.destroy();
return;
}
const roomId = parts[0].trim();
const hostPort = parseInt(parts[1].trim());
const room = this.roomManager.getRoom(roomId);
if (!room) {
hostSocket.write('ERROR:ROOM_NOT_FOUND\n');
hostSocket.destroy();
return;
}
room.hostSocket = hostSocket;
this.hostConnections.set(roomId, hostSocket);
hostSocket.write('OK:HOST_REGISTERED\n');
logger.info(`Host registered for room ${roomId} from ${remoteAddr}, target port ${hostPort}`);
hostSocket.on('close', () => {
logger.info(`Host disconnected from room ${roomId}`);
this.hostConnections.delete(roomId);
this.roomManager.deleteRoom(roomId);
});
hostSocket.on('error', (err) => {
logger.error(`Host socket error for room ${roomId}: ${err.message}`);
});
}
private handlePlayerJoin(playerSocket: net.Socket, header: string, initialData: Buffer, remoteAddr: string): void {
const parts = header.split('\n')[0].replace('FUNMC_JOIN:', '').split('|');
if (parts.length < 2) {
playerSocket.write('ERROR:INVALID_FORMAT\n');
playerSocket.destroy();
return;
}
const roomId = parts[0].trim();
const playerName = parts[1].trim();
const password = parts[2]?.trim();
const room = this.roomManager.getRoom(roomId);
if (!room) {
playerSocket.write('ERROR:ROOM_NOT_FOUND\n');
playerSocket.destroy();
return;
}
if (room.password && room.password !== password) {
playerSocket.write('ERROR:WRONG_PASSWORD\n');
playerSocket.destroy();
return;
}
if (room.isFull) {
playerSocket.write('ERROR:ROOM_FULL\n');
playerSocket.destroy();
return;
}
const hostSocket = this.hostConnections.get(roomId);
if (!hostSocket || hostSocket.destroyed) {
playerSocket.write('ERROR:HOST_OFFLINE\n');
playerSocket.destroy();
return;
}
const player = room.addPlayer(playerName, playerSocket);
playerSocket.write('OK:CONNECTED\n');
this.setupRelay(roomId, player.id, playerSocket, hostSocket, room);
}
private setupRelay(roomId: string, playerId: string, playerSocket: net.Socket, hostSocket: net.Socket, room: Room): void {
const hostAddr = hostSocket.remoteAddress;
const hostPort = room.hostPort;
const targetSocket = new net.Socket();
targetSocket.connect(hostPort, hostAddr || '127.0.0.1', () => {
logger.info(`Relay established: player ${playerId} <-> host ${hostAddr}:${hostPort} in room ${roomId}`);
playerSocket.pipe(targetSocket);
targetSocket.pipe(playerSocket);
});
targetSocket.on('error', (err) => {
logger.error(`Target connection error for player ${playerId}: ${err.message}`);
playerSocket.destroy();
});
targetSocket.on('close', () => {
playerSocket.destroy();
room.removePlayer(playerId);
});
playerSocket.on('close', () => {
targetSocket.destroy();
room.removePlayer(playerId);
});
playerSocket.on('error', (err) => {
logger.debug(`Player socket error: ${err.message}`);
targetSocket.destroy();
});
}
registerHostDirect(roomId: string, hostSocket: net.Socket): void {
this.hostConnections.set(roomId, hostSocket);
}
stop(): void {
if (this.server) {
this.server.close();
logger.info('Relay TCP server stopped');
}
for (const [, socket] of this.hostConnections) {
socket.destroy();
}
this.hostConnections.clear();
}
}

187
server/src/room.ts Normal file
View File

@@ -0,0 +1,187 @@
import { v4 as uuidv4 } from 'uuid';
import net from 'net';
import logger from './logger';
export interface Player {
id: string;
name: string;
socket: net.Socket;
joinedAt: Date;
}
export interface RoomInfo {
id: string;
name: string;
hostId: string;
hostName: string;
hostPort: number;
gameVersion: string;
gameEdition: 'java' | 'bedrock';
maxPlayers: number;
currentPlayers: number;
nodeId: string;
createdAt: Date;
password?: string;
}
export class Room {
public id: string;
public name: string;
public hostId: string;
public hostName: string;
public hostSocket: net.Socket | null = null;
public hostPort: number;
public gameVersion: string;
public gameEdition: 'java' | 'bedrock';
public maxPlayers: number;
public password?: string;
public nodeId: string;
public createdAt: Date;
public players: Map<string, Player> = new Map();
private playerSockets: Map<string, net.Socket> = new Map();
constructor(options: {
name: string;
hostName: string;
hostPort: number;
gameVersion: string;
gameEdition: 'java' | 'bedrock';
maxPlayers: number;
nodeId: string;
password?: string;
}) {
this.id = uuidv4().substring(0, 8).toUpperCase();
this.name = options.name;
this.hostId = uuidv4();
this.hostName = options.hostName;
this.hostPort = options.hostPort;
this.gameVersion = options.gameVersion;
this.gameEdition = options.gameEdition;
this.maxPlayers = options.maxPlayers;
this.nodeId = options.nodeId;
this.password = options.password;
this.createdAt = new Date();
}
get currentPlayers(): number {
return this.players.size;
}
get isFull(): boolean {
return this.currentPlayers >= this.maxPlayers;
}
addPlayer(name: string, socket: net.Socket): Player {
const player: Player = {
id: uuidv4(),
name,
socket,
joinedAt: new Date(),
};
this.players.set(player.id, player);
this.playerSockets.set(player.id, socket);
logger.info(`Player ${name} joined room ${this.name} (${this.id})`);
return player;
}
removePlayer(playerId: string): void {
const player = this.players.get(playerId);
if (player) {
logger.info(`Player ${player.name} left room ${this.name} (${this.id})`);
this.players.delete(playerId);
this.playerSockets.delete(playerId);
}
}
toInfo(): RoomInfo {
return {
id: this.id,
name: this.name,
hostId: this.hostId,
hostName: this.hostName,
hostPort: this.hostPort,
gameVersion: this.gameVersion,
gameEdition: this.gameEdition,
maxPlayers: this.maxPlayers,
currentPlayers: this.currentPlayers,
nodeId: this.nodeId,
createdAt: this.createdAt,
password: this.password ? '***' : undefined,
};
}
destroy(): void {
for (const [, player] of this.players) {
try {
player.socket.destroy();
} catch (e) { /* ignore */ }
}
if (this.hostSocket) {
try {
this.hostSocket.destroy();
} catch (e) { /* ignore */ }
}
this.players.clear();
this.playerSockets.clear();
logger.info(`Room ${this.name} (${this.id}) destroyed`);
}
}
export class RoomManager {
private rooms: Map<string, Room> = new Map();
private maxRooms: number;
constructor(maxRooms: number) {
this.maxRooms = maxRooms;
}
createRoom(options: {
name: string;
hostName: string;
hostPort: number;
gameVersion: string;
gameEdition: 'java' | 'bedrock';
maxPlayers: number;
nodeId: string;
password?: string;
}): Room | null {
if (this.rooms.size >= this.maxRooms) {
logger.warn('Max rooms reached, cannot create new room');
return null;
}
const room = new Room(options);
this.rooms.set(room.id, room);
logger.info(`Room created: ${room.name} (${room.id}) on node ${room.nodeId}`);
return room;
}
getRoom(roomId: string): Room | undefined {
return this.rooms.get(roomId);
}
deleteRoom(roomId: string): boolean {
const room = this.rooms.get(roomId);
if (room) {
room.destroy();
this.rooms.delete(roomId);
return true;
}
return false;
}
listRooms(): RoomInfo[] {
return Array.from(this.rooms.values()).map(r => r.toInfo());
}
getRoomCount(): number {
return this.rooms.size;
}
getTotalPlayers(): number {
let total = 0;
for (const [, room] of this.rooms) {
total += room.currentPlayers;
}
return total;
}
}

133
server/src/websocket.ts Normal file
View File

@@ -0,0 +1,133 @@
import WebSocket from 'ws';
import http from 'http';
import { RoomManager } from './room';
import { NodeManager } from './node-manager';
import logger from './logger';
import config from './config';
export class WebSocketHandler {
private wss: WebSocket.Server | null = null;
private clients: Set<WebSocket> = new Set();
private broadcastInterval: NodeJS.Timeout | null = null;
constructor(
private roomManager: RoomManager,
private nodeManager: NodeManager
) {}
attach(server: http.Server): void {
this.wss = new WebSocket.Server({ server, path: '/ws' });
this.wss.on('connection', (ws: WebSocket) => {
this.clients.add(ws);
logger.debug(`WebSocket client connected, total: ${this.clients.size}`);
ws.send(JSON.stringify({
type: 'welcome',
data: {
nodeId: config.nodeId,
nodeName: config.nodeName,
isMaster: config.isMaster,
},
}));
ws.on('message', (message: string) => {
try {
const msg = JSON.parse(message.toString());
this.handleMessage(ws, msg);
} catch {
ws.send(JSON.stringify({ type: 'error', data: { message: '无效的消息格式' } }));
}
});
ws.on('close', () => {
this.clients.delete(ws);
logger.debug(`WebSocket client disconnected, total: ${this.clients.size}`);
});
ws.on('error', (err) => {
logger.debug(`WebSocket error: ${err.message}`);
this.clients.delete(ws);
});
});
this.broadcastInterval = setInterval(() => {
this.broadcastStatus();
}, 3000);
logger.info('WebSocket server attached');
}
private handleMessage(ws: WebSocket, msg: any): void {
switch (msg.type) {
case 'getRooms':
ws.send(JSON.stringify({
type: 'rooms',
data: { rooms: this.roomManager.listRooms() },
}));
break;
case 'getNodes':
if (config.isMaster) {
ws.send(JSON.stringify({
type: 'nodes',
data: { nodes: this.nodeManager.listNodes() },
}));
}
break;
case 'getStats':
ws.send(JSON.stringify({
type: 'stats',
data: {
rooms: this.roomManager.getRoomCount(),
players: this.roomManager.getTotalPlayers(),
nodes: config.isMaster ? this.nodeManager.getStats() : null,
},
}));
break;
default:
ws.send(JSON.stringify({ type: 'error', data: { message: `未知消息类型: ${msg.type}` } }));
}
}
private broadcastStatus(): void {
if (this.clients.size === 0) return;
const status = JSON.stringify({
type: 'status',
data: {
rooms: this.roomManager.getRoomCount(),
players: this.roomManager.getTotalPlayers(),
nodes: config.isMaster ? this.nodeManager.getStats() : null,
timestamp: new Date().toISOString(),
},
});
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(status);
}
}
}
broadcast(type: string, data: any): void {
const message = JSON.stringify({ type, data });
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
stop(): void {
if (this.broadcastInterval) {
clearInterval(this.broadcastInterval);
}
if (this.wss) {
this.wss.close();
}
this.clients.clear();
}
}