UNPKG

rce.js

Version:

A wrapper for Rust Console Edition (RCE) that allows you to use RCON to create integrations.

962 lines (827 loc) 27.2 kB
import { type IServerOptions, type IServer, type IEvent, RCEEvent, type IRustServerInformation, type IOptions, type ILogger, type IPlayer, type ITeam, GameRole, IServerKit, IServerZone, } from "./types"; import SocketManager from "./socket/socketManager"; import { EventEmitter } from "events"; import CommandManager from "./commands/commandManager"; import Logger from "./logger"; import { parseTeamInfo } from "./data/teamInfo"; import { parseRoleInfo } from "./data/roleInfo"; import { RCEIntent } from "./types"; export default class RCEManager extends EventEmitter { private servers: Map<string, IServer> = new Map(); public logger: ILogger; /** * * @param opts Options for the RCEManager instance. * @param opts.logLevel The log level for the logger. Default is LogLevel.Info * Creates an instance of RCEManager. */ public constructor(opts: IOptions) { super(); this.logger = opts.logger?.instance || new Logger(opts.logger?.level, opts.logger?.file); this.on(RCEEvent.Error, (payload) => { if (payload.server) { this.logger.error(`[${payload.server.identifier}] ${payload.error}`); } else { this.logger.error(payload.error); } }); this.on(RCEEvent.PlayerRoleAdd, (payload) => { const server = this.getServer(payload.server.identifier); if (!server) return; if ( !server.intents.includes(RCEIntent.PlayerList) || !server.intents.includes(RCEIntent.RoleInfo) ) { return; } const player = server.players.find((p) => p.ign === payload.player.ign); if (player) { if (payload.role === "Banned") { player.role = null; } else { player.role = payload.role as GameRole; } this.updateServer(server); } }); this.on(RCEEvent.PlayerRoleRemove, (payload) => { const server = this.getServer(payload.server.identifier); if (!server) return; if ( !server.intents.includes(RCEIntent.PlayerList) || !server.intents.includes(RCEIntent.RoleInfo) ) { return; } const player = server.players.find((p) => p.ign === payload.player.ign); if (player) { player.role = null; this.updateServer(server); } }); this.on(RCEEvent.Ready, (payload) => { const server = payload.server; if (!server) { this.logger.warn("[Unknown] Received Ready event with no server data."); return; } const id = server.identifier; // Check intents and set up intervals accordingly if (server.intents.includes(RCEIntent.ServerInfo)) { this.fetchInfo(id); const timer = server.intentTimers?.[RCEIntent.ServerInfo] || 60_000; server.intervals.serverInfoInterval = setInterval(() => { this.fetchInfo(id); }, timer); } if (server.intents.includes(RCEIntent.PlayerList)) { this.updatePlayers(id); const timer = server.intentTimers?.[RCEIntent.PlayerList] || 15_000; server.intervals.playerListInterval = setInterval(() => { this.updatePlayers(id); }, timer); } if (server.intents.includes(RCEIntent.Frequencies)) { this.updateBroadcasters(id); const timer = server.intentTimers?.[RCEIntent.Frequencies] || 30_000; server.intervals.frequenciesInterval = setInterval(() => { this.updateBroadcasters(id); }, timer); } if (server.intents.includes(RCEIntent.Gibs)) { this.fetchGibs(id); const timer = server.intentTimers?.[RCEIntent.Gibs] || 30_000; server.intervals.gibsInterval = setInterval(() => { this.fetchGibs(id); }, timer); } if (server.intents.includes(RCEIntent.Kits)) { this.fetchKits(id); const timer = server.intentTimers?.[RCEIntent.Kits] || 300_000; server.intervals.kitsInterval = setInterval(() => { this.fetchKits(id); }, timer); } if (server.intents.includes(RCEIntent.CustomZones)) { this.fetchCustomZones(id); const timer = server.intentTimers?.[RCEIntent.CustomZones] || 300_000; server.intervals.customZonesInterval = setInterval(() => { this.fetchCustomZones(id); }, timer); } if (server.intents.includes(RCEIntent.Teams)) { if (server.intents.includes(RCEIntent.PlayerList)) { this.fetchTeamInfo(id); } } if (server.intents.includes(RCEIntent.RoleInfo)) { if (server.intents.includes(RCEIntent.PlayerList)) { this.fetchRoleInfo(id); } } this.logger.info(`[${id}] Server Successfully Added!`); }); } /** * * @param options Options for the server to be added. * @param options.identifier Unique identifier for the server. * @param options.state Any additional information for the server. * @param options.rcon RCON connection options for the server. * @param options.rcon.port RCON port for the server. * @param options.rcon.password RCON password for the server. * @param options.rcon.host RCON host for the server. * Creates a new server instance and adds it to the manager. * @returns Promise<boolean> Whether the server was added successfully. */ public async addServer(options: IServerOptions): Promise<boolean> { this.logger.debug(`[${options.identifier}] Attempting To Add Server...`); if (this.servers.has(options.identifier)) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${options.identifier}" Already Exists!`, }); return false; } // 1. Create SocketManager but don't connect yet const socketManager = new SocketManager(this); const server: IServer = { identifier: options.identifier, socket: null, socketManager, flags: [], intents: options.intents || [], intentTimers: options.intentTimers || {}, intervals: {} as any, state: options.state || [], players: [], kits: [], frequencies: [], teams: [], info: undefined, }; // 2. Register server before connecting this.servers.set(options.identifier, server); // 3. Now connect const connected = await socketManager.connect(options); if (!connected) { this.logger.warn( `[${options.identifier}] Failed to connect to WebSocket.` ); this.servers.delete(options.identifier); return false; } return true; } /** * * @param server The server object to update. * @param server.identifier Unique identifier for the server. * @returns void * Updates an existing server instance in the manager. */ public updateServer(server: IServer): void { if (!this.servers.has(server.identifier)) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${server.identifier}" Does Not Exist!`, }); return; } this.servers.set(server.identifier, server); } /** * * @param identifier Unique identifier for the server to be removed. * Removes a server instance from the manager. * @returns void */ public removeServer(identifier: string): void { if (!this.servers.has(identifier)) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const server = this.servers.get(identifier); if (server) { // Properly destroy the SocketManager to stop reconnection attempts if (server.socketManager) { server.socketManager.destroy(); } if (server.socket) { server.socket.close(); } Object.values(server.intervals).forEach(clearInterval); } this.servers.delete(identifier); this.logger.info(`[${identifier}] Server Successfully Removed!`); } /** * * @param identifier Unique identifier for the server to be fetched. * Retrieves a server instance by its identifier. * @returns IServer | null */ public getServer(identifier: string): IServer | null { return this.servers.get(identifier) || null; } /** * Retrieves all server instances managed by the RCEManager. * @returns IServer[] */ public getServers(): IServer[] { return Array.from(this.servers.values()); } /** * * @param identifier Unique identifier for the server to fetch information from. * Fetches server information using the RCON command "serverinfo". * @returns IRustServerInformation | string | undefined */ public async fetchInfo(identifier: string) { const server = this.getServer(identifier); if (!server) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const info = await this.sendCommand(identifier, "serverinfo"); if (!info) { this.emit(RCEEvent.Error, { error: `Failed To Fetch Server Information For "${identifier}"!`, }); return; } let cleanOutput = info.replace(/\\n/g, "").trim(); try { const json = JSON.parse(cleanOutput); server.info = json as IRustServerInformation; this.updateServer(server); this.emit(RCEEvent.ServerInfoUpdated, { info: server.info, server, }); return json as IRustServerInformation; } catch (error) { return info; } } /** * * @param identifier Unique identifier for the server to send the command to. * @param command The command to be sent to the server. * Sends a command to the server using the RCON connection. * @returns Promise<string | undefined> */ public async sendCommand( identifier: string, command: string ): Promise<string | undefined> { const server = this.getServer(identifier); if (!server?.socket) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist Or Is Not Connected!`, }); return; } if (!server.flags.includes("READY")) return undefined; if (server.socket.readyState === server.socket.OPEN) { const rand = Math.floor(Math.random() * 999999999); return new Promise(async (resolve, reject) => { CommandManager.add({ identifier, command, uniqueId: rand, resolve, reject, }); server.socket.send( JSON.stringify({ message: command, identifier: rand, }) ); this.logger.debug( `[${identifier}] Sending Command: ${command} (ID: ${rand})` ); const cmd = CommandManager.get(identifier, rand); if (cmd) { cmd.timeout = setTimeout(() => { CommandManager.remove(cmd); resolve(undefined); }, 3_000); } }); } else { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Is Not Connected!`, }); return; } } /** * Destroys the RCEManager instance, cleaning up all resources. * Closes all server sockets, clears intervals, and removes all listeners. */ public destroy(): void { this.servers.forEach((server) => { // Properly destroy the SocketManager to stop reconnection attempts if (server.socketManager) { server.socketManager.destroy(); } if (server.socket) { server.socket.close(); } Object.values(server.intervals).forEach(clearInterval); }); this.servers.clear(); this.removeAllListeners(); this.logger.info("RCEManager Successfully Destroyed!"); } /* Event Handling */ emit<K extends keyof IEvent>( event: K, ...args: IEvent[K] extends undefined ? [] : [IEvent[K]] ): boolean { return super.emit(event, ...args); } on<K extends keyof IEvent>( event: K, listener: (payload: IEvent[K]) => void ): this { return super.on(event, listener); } once<K extends keyof IEvent>( event: K, listener: (payload: IEvent[K]) => void ): this { return super.once(event, listener); } off<K extends keyof IEvent>( event: K, listener: (payload: IEvent[K]) => void ): this { return super.off(event, listener); } /* Zone Caching */ private async fetchCustomZones( identifier: string ): Promise<IServerZone[] | undefined> { const server = this.getServer(identifier); if (!server) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const zoneList = await this.sendCommand( identifier, "zones.listcustomzones" ); const zones: IServerZone[] = []; if (zoneList) { const zoneRegex = /Name\s*\[(.*?)\]\s*Enabled\s*\[(True|False)\]/gi; let match; while ((match = zoneRegex.exec(zoneList)) !== null) { const zoneInfo = await this.sendCommand( identifier, `zones.customzoneinfo "${match[1]}"` ); if (zoneInfo) { const text = zoneInfo.replaceAll("\\n", "\n").trim(); const parseVector = (str: string): [number, number, number] => str .replace(/[()]/g, "") .split(",") .map((n) => parseFloat(n.trim())) as [number, number, number]; const get = (key: string): string | undefined => { const match = text.match(new RegExp(`${key}\\s*\\[(.*?)\\]`)); return match ? match[1] : undefined; }; const zone: IServerZone = { name: match[1], enabled: match[2].toLowerCase() === "true", config: { position: parseVector(get("Position") ?? "(0,0,0)"), size: parseFloat(get("Size") ?? "0"), type: (get("Type") as "Sphere" | "Box") ?? "Sphere", playerDamage: get("Player Damage") === "True", npcDamage: get("NPC Damage") === "True", radiationDamage: parseFloat(get("Radiation Damage") ?? "0"), playerBuildingDamage: get("Player Building Damage") === "True", allowBuilding: get("Allow Building") === "True", showArea: get("Show Area") === "True", color: parseVector(get("Color") ?? "(255,255,255)"), showChatMessage: get("Show Chat Message") === "True", enterMessage: (get("Enter Message") ?? "").replace(/^"|"$/g, ""), leaveMessage: (get("Leave Message") ?? "").replace(/^"|"$/g, ""), }, }; zones.push(zone); } } } server.customZones = zones; this.updateServer(server); return zones; } /* Kit Caching */ private async fetchKits( identifier: string ): Promise<IServerKit[] | undefined> { const server = this.getServer(identifier); if (!server) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const kitList = await this.sendCommand(identifier, "kit list"); if (kitList) { const lines = kitList .split("\n") .map((line) => line.trim()) .filter((line) => line && !line.startsWith("[KITMANAGER]")); const kits: IServerKit[] = lines.map((line) => ({ name: line, items: [], })); for (const kit of kits) { const kitInfo = await this.sendCommand( identifier, `kit info "${kit.name}"` ); if (kitInfo) { const cleaned = kitInfo.replaceAll("\\n", "\n"); const itemRegex = /Shortname:\s*(\S+)\s+Amount:\s*\[(\d+)\]\s+Condition:\s*\[(\d+)\]\s+Container:\s*\[(Main|Belt|Wear)\]/g; let match; while ((match = itemRegex.exec(cleaned)) !== null) { kit.items.push({ shortName: match[1], quantity: Number(match[2]), condition: Number(match[3]), container: match[4] as "Main" | "Belt" | "Wear", }); } } } server.kits = kits; this.updateServer(server); return kits; } } /* Scheduled Tasks */ private async updateBroadcasters(identifier: string): Promise<void> { const server = this.getServer(identifier); if (!server) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const broadcasters = await this.sendCommand( identifier, "rf.listboardcaster" ); if (broadcasters) { const broadcasts = []; const regex = /\[(\d+) MHz\] Position: \(([\d.-]+), ([\d.-]+), ([\d.-]+)\), Range: (\d+)/g; let match; while ((match = regex.exec(broadcasters)) !== null) { const frequency = parseInt(match[1], 10); const coordinates = [ parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4]), ]; const range = parseInt(match[5], 10); broadcasts.push({ frequency, coordinates, range }); } server.frequencies.forEach((freq) => { if (!broadcasts.find((b) => parseInt(b.frequency) === freq)) { this.emit(RCEEvent.FrequencyLost, { server, frequency: freq, }); server.frequencies = server.frequencies.filter((f) => f !== freq); } }); broadcasts.forEach((broadcast) => { if (server.frequencies.includes(broadcast.frequency)) return; server.frequencies.push(broadcast.frequency); if (broadcast.frequency === 4765) { this.emit(RCEEvent.EventStart, { server, event: "Small Oil Rig", special: false, }); } else if (broadcast.frequency === 4768) { this.emit(RCEEvent.EventStart, { server, event: "Oil Rig", special: false, }); } this.emit(RCEEvent.FrequencyGained, { server, frequency: broadcast.frequency, coordinates: broadcast.coordinates, range: broadcast.range, }); }); this.updateServer(server); } } private async fetchGibs(identifier: string): Promise<void> { const server = this.getServer(identifier); if (!server) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const bradley = await this.sendCommand( identifier, "find_entity servergibs_bradley" ); const helicopter = await this.sendCommand( identifier, "find_entity servergibs_patrolhelicopter" ); if (!bradley || !helicopter) { this.emit(RCEEvent.Error, { error: `Failed To Fetch Gibs For Server "${identifier}"`, }); return; } if ( bradley.includes("servergibs_bradley") && !server.flags.includes("BRADLEY") ) { server.flags.push("BRADLEY"); setTimeout(() => { const s = this.getServer(identifier); if (s) { s.flags = s.flags.filter((f) => f !== "BRADLEY"); this.updateServer(s); } }, 60_000 * 10); this.emit(RCEEvent.EventStart, { server, event: "Bradley APC Debris", special: false, }); } if ( helicopter.includes("servergibs_patrolhelicopter") && !server.flags.includes("HELICOPTER") ) { server.flags.push("HELICOPTER"); setTimeout(() => { const s = this.getServer(identifier); if (s) { s.flags = s.flags.filter((f) => f !== "HELICOPTER"); this.updateServer(s); } }, 60_000 * 10); this.emit(RCEEvent.EventStart, { server, event: "Patrol Helicopter Debris", special: false, }); } this.updateServer(server); } /** * Creates a placeholder player or returns existing one, optionally updating player data * @param identifier Server identifier * @param playerName Player's IGN * @param playerData Optional data to set on the player * @returns Player object (existing or newly created placeholder) */ public upsertPlayer( identifier: string, playerName: string, playerData?: Partial<IPlayer>, markAsOnline: boolean = false ): IPlayer { const server = this.getServer(identifier); if (!server) { throw new Error(`Server With Identifier "${identifier}" Does Not Exist!`); } let player = server.players.find((p) => p.ign === playerName); let isNewPlayer = false; let wasOffline = false; if (!player) { // Create new player player = { ign: playerName, ping: 0, timeConnected: 0, health: 0, team: null, platform: undefined, state: [], isOnline: markAsOnline, lastSeen: new Date(), }; server.players.push(player); isNewPlayer = true; } else { // Player exists, check if they were offline wasOffline = !player.isOnline; if (markAsOnline) { player.isOnline = true; player.lastSeen = new Date(); } } // Apply any provided data if (playerData) { Object.assign(player, playerData); } // Emit join event if player is newly created or was offline and now online if (markAsOnline && (isNewPlayer || wasOffline)) { this.emit(RCEEvent.PlayerJoined, { server, player, }); } // Update server if we created a new player or modified existing data if (isNewPlayer || playerData) { this.updateServer(server); } return player; } public async fetchRoleInfo(identifier: string): Promise<void> { const server = this.getServer(identifier); if (!server) return; const rawRoleInfo = await this.sendCommand(identifier, "getauthlevels"); if (!rawRoleInfo) return; server.players.forEach((player) => { player.role = undefined; // Reset roles before updating }); const { players } = parseRoleInfo(rawRoleInfo, server.players); server.players = players; this.updateServer(server); } /** * Fetches team information and updates team references for all players * @param identifier Server identifier */ public async fetchTeamInfo(identifier: string): Promise<void> { const server = this.getServer(identifier); if (!server) return; const rawTeamInfo = await this.sendCommand( identifier, "relationshipmanager.teaminfoall" ); if (!rawTeamInfo) return; // Clear existing team references server.players.forEach((player) => { player.team = null; }); const { teams } = parseTeamInfo(rawTeamInfo, server.players); // Update teams list server.teams = teams; this.updateServer(server); } /** * Gets all teams on the server * @param identifier Server identifier * @returns Array of teams with their leaders and members */ public getTeams(identifier: string): ITeam[] { const server = this.getServer(identifier); return server ? server.teams : []; } /** * Gets a specific team by ID * @param identifier Server identifier * @param teamId Team ID to find * @returns Team object or undefined if not found */ public getTeam(identifier: string, teamId: number): ITeam | undefined { const server = this.getServer(identifier); return server ? server.teams.find((team) => team.id === teamId) : undefined; } /** * Gets the team that a specific player belongs to * @param identifier Server identifier * @param playerName Player's IGN * @returns Team object or undefined if player is not in a team */ public getPlayerTeam( identifier: string, playerName: string ): ITeam | undefined { const server = this.getServer(identifier); if (!server) return undefined; const player = server.players.find((p) => p.ign === playerName); return player?.team || undefined; } private async updatePlayers(identifier: string): Promise<void> { const server = this.getServer(identifier); if (!server) { this.emit(RCEEvent.Error, { error: `Server With Identifier "${identifier}" Does Not Exist!`, }); return; } const rawPlayerList = await this.sendCommand(identifier, "playerlist"); if (rawPlayerList) { const parsedPlayers: any[] = JSON.parse(rawPlayerList); // Update existing players with new data, preserve team and platform references const existingPlayers = server.players; const newPlayerNames = new Set( parsedPlayers.map((p: any) => p.DisplayName) ); const joined: IPlayer[] = []; const left = existingPlayers.filter( (player) => player.isOnline && !newPlayerNames.has(player.ign) ); // Update existing players and identify new ones parsedPlayers.forEach((playerData: any) => { const playerName = playerData.DisplayName; const existingPlayer = existingPlayers.find( (p) => p.ign === playerName ); if (existingPlayer) { // Update existing player data but preserve team and platform references existingPlayer.ping = playerData.Ping; existingPlayer.timeConnected = playerData.ConnectedSeconds; existingPlayer.health = Math.round(playerData.Health); existingPlayer.isOnline = true; existingPlayer.lastSeen = new Date(); // team and platform are preserved from existing player } else { // Create new player with default values const newPlayer: IPlayer = { ign: playerName, ping: playerData.Ping, timeConnected: playerData.ConnectedSeconds, health: Math.round(playerData.Health), team: null, // Will be set by team events or connection platform: undefined, // Will be set from respawn events role: undefined, // Role information not available from playerlist state: [], isOnline: true, lastSeen: new Date(), }; joined.push(newPlayer); existingPlayers.push(newPlayer); } }); // Mark players as offline who are no longer connected left.forEach((player) => { player.isOnline = false; player.lastSeen = new Date(); }); // Emit events for joined players joined.forEach((player) => { this.emit(RCEEvent.PlayerJoined, { server, player, }); }); // Emit events for left players left.forEach((player) => { this.emit(RCEEvent.PlayerLeft, { server, player, }); }); this.updateServer(server); this.emit(RCEEvent.PlayerListUpdated, { server, players: existingPlayers, joined, left, }); } } }