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
text/typescript
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,
});
}
}
}