Server: - 添加房间密码验证 POST /rooms/:id/join - 添加玩家踢出 POST /rooms/:id/kick/:playerId - 添加房间过期自动清理(30分钟无活动) - 添加流量统计 GET /traffic - 添加token认证中间件保护写操作API - 房间详情返回玩家列表 Client: - 添加设置持久化(electron-store) - 添加设置页面(玩家名、本地端口、自动重连、托盘最小化) - 添加系统托盘支持(最小化到托盘、右键菜单) - 添加最近连接服务器记录 - 连接成功自动保存服务器地址 - 加入房间自动填充默认端口
262 lines
6.5 KiB
TypeScript
262 lines
6.5 KiB
TypeScript
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 lastActivity: Date;
|
|
public bytesIn: number = 0;
|
|
public bytesOut: number = 0;
|
|
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();
|
|
this.lastActivity = new Date();
|
|
}
|
|
|
|
checkPassword(pwd?: string): boolean {
|
|
if (!this.password) return true;
|
|
return this.password === pwd;
|
|
}
|
|
|
|
touch(): void {
|
|
this.lastActivity = new Date();
|
|
}
|
|
|
|
addTraffic(bytesIn: number, bytesOut: number): void {
|
|
this.bytesIn += bytesIn;
|
|
this.bytesOut += bytesOut;
|
|
this.touch();
|
|
}
|
|
|
|
kickPlayer(playerId: string, reason?: string): boolean {
|
|
const player = this.players.get(playerId);
|
|
if (!player) return false;
|
|
logger.info(`Player ${player.name} kicked from room ${this.name}: ${reason || 'no reason'}`);
|
|
try { player.socket.destroy(); } catch (e) { /* ignore */ }
|
|
this.players.delete(playerId);
|
|
this.playerSockets.delete(playerId);
|
|
return true;
|
|
}
|
|
|
|
isExpired(maxIdleMs: number): boolean {
|
|
if (this.currentPlayers > 0) return false;
|
|
return Date.now() - this.lastActivity.getTime() > maxIdleMs;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
getPlayerList(): { id: string; name: string; joinedAt: Date }[] {
|
|
return Array.from(this.players.values()).map(p => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
joinedAt: p.joinedAt,
|
|
}));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
getTrafficStats(): { totalBytesIn: number; totalBytesOut: number } {
|
|
let totalBytesIn = 0;
|
|
let totalBytesOut = 0;
|
|
for (const [, room] of this.rooms) {
|
|
totalBytesIn += room.bytesIn;
|
|
totalBytesOut += room.bytesOut;
|
|
}
|
|
return { totalBytesIn, totalBytesOut };
|
|
}
|
|
|
|
cleanupExpiredRooms(maxIdleMs: number = 30 * 60 * 1000): number {
|
|
let cleaned = 0;
|
|
for (const [id, room] of this.rooms) {
|
|
if (room.isExpired(maxIdleMs)) {
|
|
logger.info(`Room ${room.name} (${id}) expired, cleaning up`);
|
|
room.destroy();
|
|
this.rooms.delete(id);
|
|
cleaned++;
|
|
}
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
startCleanupTimer(intervalMs: number = 5 * 60 * 1000, maxIdleMs: number = 30 * 60 * 1000): void {
|
|
setInterval(() => {
|
|
const cleaned = this.cleanupExpiredRooms(maxIdleMs);
|
|
if (cleaned > 0) {
|
|
logger.info(`Cleaned up ${cleaned} expired rooms`);
|
|
}
|
|
}, intervalMs);
|
|
}
|
|
}
|