SquadJS v1

This commit is contained in:
Thomas Smyth 2020-05-15 18:42:39 +01:00
commit 3eb53c2ada
69 changed files with 8440 additions and 0 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
# General
**/node_modules/*
index-test.js

9
.eslintrc Normal file
View File

@ -0,0 +1,9 @@
{
"parserOptions": {
"ecmaVersion": 2018
},
"extends": [
"standard",
"prettier"
]
}

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Project Files
squad-server/log-parser/test-data/
index-test.js
# Dependencies
node_modules/
package-lock.json
yarn.lock
# IDEs
.idea/

5
.huskyrc Normal file
View File

@ -0,0 +1,5 @@
{
"hooks": {
"pre-commit": "lint-staged"
}
}

7
.lintstagedrc Normal file
View File

@ -0,0 +1,7 @@
{
"*.js": [
"eslint --fix .",
"prettier --write \"./**/*.js\"",
"git add"
]
}

3
.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"singleQuote": true
}

25
LICENSE Normal file
View File

@ -0,0 +1,25 @@
Boost Software License - Version 1.0 - August 17th, 2003
Copyright (c) 2020 Thomas Smyth
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

142
README.md Normal file
View File

@ -0,0 +1,142 @@
<div align="center">
<img src="assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS
[![GitHub release](https://img.shields.io/github/release/Thomas-Smyth/SquadJS.svg?style=flat-square)](https://github.com/Thomas-Smyth/SquadJS/releases)
[![GitHub contributors](https://img.shields.io/github/contributors/Thomas-Smyth/SquadJS.svg?style=flat-square)](https://github.com/Thomas-Smyth/SquadJS/graphs/contributors)
[![GitHub release](https://img.shields.io/github/license/Thomas-Smyth/SquadJS.svg?style=flat-square)](https://github.com/Thomas-Smyth/SquadJS/blob/master/LICENSE)
<br>
[![GitHub issues](https://img.shields.io/github/issues/Thomas-Smyth/SquadJS.svg?style=flat-square)](https://github.com/Thomas-Smyth/SquadJS/issues)
[![GitHub pull requests](https://img.shields.io/github/issues-pr-raw/Thomas-Smyth/SquadJS.svg?style=flat-square)](https://github.com/Thomas-Smyth/SquadJS/pulls)
[![GitHub issues](https://img.shields.io/github/stars/Thomas-Smyth/SquadJS.svg?style=flat-square)](https://github.com/Thomas-Smyth/SquadJS/stargazers)
[![Discord](https://img.shields.io/discord/266210223406972928.svg?style=flat-square&logo=discord)](https://discord.gg/P7uUp5Y)
<br><br>
</div>
## About
SquadJS is a scripting framework, designed for Squad servers, that aims to handle all communication and data collection to and from the servers. Using SquadJS as the base to any of your scripting projects allows you to easily write complex plugins without having to worry about the hassle of RCON or log parsing. However, for your convenience SquadJS comes shipped with multiple plugins already built for you allowing you to experience the power of SquadJS right away.
## Using SquadJS
SquadJS relies on being able to access the Squad server log directory in order to parse logs live to collect information. Thus, SquadJS must be hosted on the same server box as your Squad server.
### Prerequisites
* [Node.js](https://nodejs.org/en/) (Current) - [Download](https://nodejs.org/en/)
* [Yarn](https://yarnpkg.com/) (Version 1.22.0+) - [Download](https://classic.yarnpkg.com/en/docs/install)
* Some plugins may have additional requirements.
### Installation
1. Clone the repository: `git clone https://github.com/Thomas-Smyth/SquadJS`
2. Install the dependencies: `yarn install`
3. Configure the `index.js` file with your server information and the required plugins. Documentation for each plugin can be found in the [`plugins`](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins) folder.
4. Start SquadJS: `node index.js`.
## Plugins
* [Discord Admin Cam Logs](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-admin-cam-logs) - Log admin cam usage to Discord.
* [Discord Chat](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-chat) - Log in game chat to Discord.
* [Discord Teamkill](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-teamkill) - Log teamkills to Discord.
* [Map Vote](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/mapvote) - In-game chat map voting system.
* [InfluxDB Log](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/influxdb-log) - Log server and player stats to InfluxDB.
* [MySQL Log](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/mysql-log) - Log more in-depth server and player stats to MySQL.
* [Seeding Message](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/seeding-message) - Display seeding messages for seeding mode.
* [Team Randomizer](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/team-randomizer) - Randomize teams to help with team balance.
## Creating Your Own Plugins
To create your own plugin you need a basic knowledge of JavaScript.
Typical plugins are functions that take the server as an argument in order to allow the plugin to access information about the server or manipulate it in some way:
```js
function aPluginToLogServerID(server){
console.log(server.id);
}
```
Stored in the server object are a range of different properties that store information about the server.
* `id` - ID of the server.
* `serverName` - Name of the server.
* `maxPlayers` - Maximum number of players on the server.
* `publicSlots` - Maximum number of public slots.
* `reserveSlots` - Maximum number of reserved slots.
* `publicQueue` - Length of the public queue.
* `reserveQueue` - Length of the reserved queue.
* `matchTimeout` - Time until match ends?
* `gameVersion` - Game version.
* `layerHistory` - Array history of layers used with most recent at the start. Each entry is an object with layer info in.
* `currentLayer` - The current layer.
* `nextLayer` - The next layer.
* `players` - Array of players. Each entry is a PlayerObject with various bits of info in.
One approach to making a plugin would be to run an action periodically, in the style of the original SquadJS:
```js
function aPluginToLogPlayerCountEvery60Seconds(server){
setInterval(() => {
console.log(server.players.length);
}, 60 * 1000);
}
```
A more common approach in this version of SquadJS is to react to an event happening:
```js
function aPluginToLogTeamkills(server){
server.on(LOG_PARSER_TEAMKILL, info => {
console.log(info);
});
}
```
A complete list of events that you can listen for and the information included within each is found [here](https://github.com/Thomas-Smyth/SquadJS/blob/master/squad-server/events/log-parser.js), [here](https://github.com/Thomas-Smyth/SquadJS/blob/master/squad-server/events/rcon.js) and [here](https://github.com/Thomas-Smyth/SquadJS/blob/master/squad-server/events/server.js).
Various actions can be completed in a plugin. Most of these will involve outside system, e.g. Discord.js to run a Discord bot, so they are not documented here. However, you may run RCON commands using `server.rcon.execute("Command");`.
If you're struggling to create a plugin, the existing [`plugins`](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins) are a good place to go for examples or feel free to ask for help in the Squad RCON Discord.
## Statement on Accuracy
Some of the information SquadJS collects from Squad servers was never intended or designed to be collected. As a result, it is impossible for any framework to collect the same information with 100% accuracy. SquadJS aims to get as close as possible to that figure, however, it acknowledges that this is not possible in some specific scenarios.
Below is a list of scenarios we know may cause some information to be inaccurate:
* Use of Realtime Server and Player Information - We update server and player information periodically every 30 seconds (by default) or when we know that it requires an update. As a result, some information about the server or players may be up to 30 seconds out of date.
* SquadJS Restarts - If SquadJS is started during an active Squad game some information will not be lost or not collected correctly:
- The current state of players will be lost. For example, if a player was wounded prior to the bot starting and then is revived/gives up after the bot is started information regarding who originally wounded them will not be known.
- The accurate collection of some server log events will not occur. SquadJS collects players' "suffix" name, i.e. their Steam name without the clan tag added via the game settings, when they join the server and uses this to identify them in certain logs that do not include their full name. As a result, for players connecting prior to SquadJS starting some log events associated with their actions will show the player as `null`. We aim to implement a solution to attempt to recover players' suffix names when this occurs, but the accuracy of correctly identifying players will be decreased.
* Duplicated Player Names - If two or more players have the same name or suffix name (see above) then SquadJS will be unable to identify them in the logs. When this occurs event logs will show the player as `null`. Be on the watch for groups of players who try to abuse this in order to TK or complete other malicious actions without being detected by SquadJS plugins.
## Credits
SquadJS would not be possible without the support of so many individuals and organisations. My thanks goes out to:
* subtlerod for proposing the initial log parsing idea, helping to design the log parsing process and for providing multiple servers to test with.
* Fourleaf, Mex and various other members of ToG / ToG-L for helping to stage logs and participate in small scale tests.
* The Coalition community, including those that participate in Wednesday Fight Night, for participating in larger scale tests and for providing feedback on plugins.
* iDronee for providing Linux Squad server logs to ensure log parsing regexes support the OS.
* Everyone in the Squad RCON Discord and others who have submitted bug reports, suggestions and feedback.
## License
```
Boost Software License - Version 1.0 - August 17th, 2003
Copyright (c) 2020 Thomas Smyth
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
```

4
assets/package.json Normal file
View File

@ -0,0 +1,4 @@
{
"name": "assets",
"version": "1.0.0"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
assets/squadjs-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

12
connectors/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "connectors",
"version": "1.0.0",
"type": "module",
"exports": {
"./squad-layers": "./squad-layers/index.js",
"./scbl": "./scbl.js"
},
"dependencies": {
"graphql-request": "^1.8.2"
}
}

7
connectors/scbl.js Normal file
View File

@ -0,0 +1,7 @@
import { request } from 'graphql-request';
const API_ENDPOINT = 'https://squad-community-ban-list.com/graphql';
export default function(query, variables) {
return request(API_ENDPOINT, query, variables);
}

View File

@ -0,0 +1,117 @@
import fs from 'fs';
class SquadLayers {
constructor() {
this.layers = JSON.parse(
fs.readFileSync('./connectors/squad-layers/layers.json', 'utf8')
);
}
getLayerByLayerName(layerName) {
const layer = this.layers.filter(layer => layer.layer === layerName);
return layer.length === 1 ? layer[0] : null;
}
getLayerByLayerClassname(layerClassname) {
const layer = this.layers.filter(
layer => layer.layerClassname === layerClassname
);
return layer.length === 1 ? layer[0] : null;
}
getLayerNames() {
return this.layers.map(layer => layer.layer);
}
getFilteredLayers(filter = {}) {
const whitelistedLayers = filter.whitelistedLayers || null;
const blacklistedLayers = filter.blacklistedLayers || null;
const whitelistedMaps = filter.whitelistedMaps || null;
const blacklistedMaps = filter.blacklistedMaps || null;
const whitelistedGamemodes = filter.whitelistedGamemodes || null;
const blacklistedGamemodes = filter.blacklistedGamemodes || ['Training'];
const flagCountMin = filter.flagCountMin || null;
const flagCountMax = filter.flagCountMax || null;
const hasCommander = filter.hasCommander || null;
const hasTanks = filter.hasTanks || null;
const hasHelicopters = filter.hasHelicopters || null;
const layers = [];
for (const layer of this.layers) {
// Whitelist / Blacklist Layers
if (
whitelistedLayers !== null &&
!whitelistedLayers.includes(layer.layer)
)
continue;
if (blacklistedLayers !== null && blacklistedLayers.includes(layer.layer))
continue;
// Whitelist / Blacklist Maps
if (whitelistedMaps !== null && !whitelistedMaps.includes(layer.map))
continue;
if (blacklistedMaps !== null && blacklistedMaps.includes(layer.map))
continue;
// Whitelist / Blacklist Gamemodes
if (
whitelistedGamemodes !== null &&
!whitelistedGamemodes.includes(layer.gamemode)
)
continue;
if (
blacklistedGamemodes !== null &&
blacklistedGamemodes.includes(layer.gamemode)
)
continue;
// Flag Count
if (flagCountMin !== null && layer.flagCount < flagCountMin) continue;
if (flagCountMax !== null && layer.flagCount > flagCountMax) continue;
// Other Properties
if (hasCommander !== null && layer.commander !== hasCommander) continue;
if (hasTanks !== null && (layer.tanks !== 'N/A') !== hasTanks) continue;
if (
hasHelicopters !== null &&
(layer.helicopters !== 'N/A') !== hasHelicopters
)
continue;
layers.push(layer.layer);
}
return layers;
}
isHistoryCompliant(layerHistory, layer, options = {}) {
const layerTolerance = options.layerTolerance || 4;
const mapTolerance = options.mapTolerance || 2;
const timeTolerance = options.timeTolerance || 5 * 60 * 60 * 1000;
for (let i = 0; i < layerHistory.length; i++) {
if (i >= Math.max(layerHistory, mapTolerance)) return true;
if (new Date() - layerHistory[i].time > timeTolerance) return true;
if (
i < layerTolerance &&
layerHistory[i].map === this.getLayerByLayerName(layer).map
)
return false;
if (i < layerTolerance && layerHistory[i].layer === layer) return false;
}
return true;
}
isPlayerCountCompliant(playerCount, layer) {
return !(
playerCount >
this.getLayerByLayerName(layer).estimatedSuitablePlayerCount.max ||
playerCount <
this.getLayerByLayerName(layer).estimatedSuitablePlayerCount.min
);
}
}
export default new SquadLayers();

File diff suppressed because it is too large Load Diff

4
core/config.js Normal file
View File

@ -0,0 +1,4 @@
/* As set out by the terms of the license, the following should not be modified. */
const COPYRIGHT_MESSAGE = 'SquadJS, Copyright © 2020 Thomas Smyth';
export { COPYRIGHT_MESSAGE };

10
core/package.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "core",
"version": "1.0.0",
"type": "module",
"exports": {
"./config": "./config.js",
"./utils/print-logo": "./utils/print-logo.js",
"./utils/sleep": "./utils/sleep.js"
}
}

18
core/utils/print-logo.js Normal file
View File

@ -0,0 +1,18 @@
import { COPYRIGHT_MESSAGE } from '../config.js';
const LOGO = `
_____ ____ _ _ _____ _
/ ____|/ __ \\| | | | /\\ | __ \\ (_)
| (___ | | | | | | | / \\ | | | | _ ___
\\___ \\| | | | | | |/ /\\ \\ | | | || / __|
____) | |__| | |__| / ____ \\| |__| || \\__ \\
|_____/ \\___\\_\\\\____/_/ \\_\\_____(_) |___/
_/ |
|__/
${COPYRIGHT_MESSAGE}
GitHub: https://github.com/Thomas-Smyth/SquadJS
`;
export default function() {
console.log(LOGO);
}

5
core/utils/sleep.js Normal file
View File

@ -0,0 +1,5 @@
export default function(time) {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}

67
index.js Normal file
View File

@ -0,0 +1,67 @@
import Discord from 'discord.js';
import mysql from 'mysql';
import Influx from 'influx';
import Server from 'squad-server';
import {
discordAdminCamLogs,
discordChat,
discordServerStatus,
discordTeamkill,
influxdbLog,
influxdbLogDefaultSchema,
mapvote,
mysqlLog,
teamRandomizer
} from 'plugins';
async function main() {
const server = new Server({
id: 1,
host: 'xxx.xxx.xxx.xxx',
queryPort: 27165,
rconPort: 21114,
rconPassword: 'password',
logDir: 'C:/path/to/squad/log/folder'
});
// Discord Plugins
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token');
await discordAdminCamLogs(server, discordClient, 'discordChannelID');
await discordChat(server, discordClient, 'discordChannelID');
await discordServerStatus(server, discordClient);
await discordTeamkill(server, discordClient, 'discordChannelID');
// in game features
mapvote(server);
teamRandomizer(server);
// MySQL Plugins
const mysqlPool = mysql.createPool({
connectionLimit: 10,
host: 'host',
port: 3306,
user: 'squadjs',
password: 'password',
database: 'squadjs'
});
mysqlLog(server, mysqlPool);
// Influx Plugins
const influxDB = new Influx.InfluxDB({
host: 'host',
port: 8086,
username: 'squadjs',
password: 'password',
database: 'squadjs',
schema: influxdbLogDefaultSchema
});
influxdbLog(server, influxDB);
await server.watch();
}
main();

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "SquadJS",
"version": "0.0.1",
"repository": "https://github.com/Thomas-Smyth/SquadJS.git",
"author": "Thomas Smyth <https://github.com/Thomas-Smyth>",
"license": "MIT",
"private": true,
"workspaces": [
"assets",
"connectors",
"core",
"plugins",
"squad-server"
],
"scripts": {
"lint": "eslint --fix . && prettier --write \"./**/*.js\"",
"test-log-parser-coverage": "node squad-server/log-parser/test-coverage.js"
},
"type": "module",
"dependencies": {
"discord.js": "^12.2.0",
"influx": "^5.5.1",
"mysql": "^2.18.1",
"plugins": "1.0.0",
"squad-server": "1.0.0"
},
"devDependencies": {
"eslint": "5.12.0",
"eslint-config-prettier": "^6.6.0",
"eslint-config-standard": "^14.1.0",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-node": "^10.0.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"husky": "^3.1.0",
"lint-staged": "^9.4.3",
"prettier": "^1.19.1"
}
}

View File

@ -0,0 +1,30 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Admin Cam Logs
</div>
## About
The Discord Admin Cam Logs plugin logs admin cam usage to a Discord channel.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordAdminCamLogs } from 'plugins';
// Place the following two lines in your index.js file before using a Discord plugins.
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token'); // insert your Discord bot's login token here.
// Place the following lines after all of the above.
await discordAdminCamLogs(
server,
discordClient,
'discordChannelID',
{ // options - the options included below display the defaults and can be removed for simplicity.
color: 16761867 // color of embed
}
);
```

View File

@ -0,0 +1,97 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { LOG_PARSER_PLAYER_POSSESS } from 'squad-server/events/log-parser';
export default async function plugin(
server,
discordClient,
channelID,
options = {}
) {
if (!server)
throw new Error(
'DiscordAdminCamLogs must be provided with a reference to the server.'
);
if (!discordClient)
throw new Error(
'DiscordAdminCamLogs must be provided with a Discord.js client.'
);
if (!channelID)
throw new Error('DiscordAdminCamLogs must be provided with a channel ID.');
options = {
color: 16761867,
...options
};
const channel = await discordClient.channels.fetch(channelID);
const adminsInCam = {};
server.on(LOG_PARSER_PLAYER_POSSESS, info => {
if (info.player === null) return;
if (info.possessClassname === 'CameraMan') {
adminsInCam[info.player.steamID] = info.time;
channel.send({
embed: {
title: `Admin Entered Admin Camera`,
color: options.color,
fields: [
{
name: "Admin's Name",
value: info.player.name,
inline: true
},
{
name: "Admin's SteamID",
value: `[${info.player.steamID}](https://steamcommunity.com/profiles/${info.player.steamID})`,
inline: true
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
} else {
if (!(info.player.steamID in adminsInCam)) return;
channel.send({
embed: {
title: `Admin Left Admin Camera`,
color: options.color,
fields: [
{
name: "Admin's Name",
value: info.player.name,
inline: true
},
{
name: "Admin's SteamID",
value: `[${info.player.steamID}](https://steamcommunity.com/profiles/${info.player.steamID})`,
inline: true
},
{
name: 'Time in Admin Camera',
value: `${Math.round(
(info.time.getTime() -
adminsInCam[info.player.steamID].getTime()) /
60000
)} mins`
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
delete adminsInCam[info.player.steamID];
}
});
}

View File

@ -0,0 +1,31 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Chat Plugin
</div>
## About
The Discord Chat plugin streams in-game chat to a Discord channel. It is useful to allow those out of game to monitor in-game chat as well as to log to permanent form. It can be configured to limit access to specific chats.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordChat } from 'plugins';
// Place the following two lines in your index.js file before using an Discord plugins.
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token'); // insert your Discord bot's login token here.
// Place the following lines after all of the above.
await discordChat(
server,
discordClient,
'discordChannelID',
{ // options - the options included below display the defaults and can be removed for simplicity.
ignoreChats: ['ChatSquad', 'ChatAdmin'], // an array of chats to not display.
color: 16761867 // color of embed
}
);
```

View File

@ -0,0 +1,67 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
export default async function plugin(
server,
discordClient,
channelID,
options = {}
) {
if (!server)
throw new Error(
'DiscordChat must be provided with a reference to the server.'
);
if (!discordClient)
throw new Error('DiscordChat must be provided with a Discord.js client.');
if (!channelID)
throw new Error('DiscordChat must be provided with a channel ID.');
const ignoreChats = options.ignoreChats || ['ChatSquad', 'ChatAdmin'];
options = {
color: 16761867,
...options
};
const channel = await discordClient.channels.fetch(channelID);
server.on(RCON_CHAT_MESSAGE, async info => {
if (ignoreChats.includes(info.chat)) return;
const playerInfo = await server.getPlayerBySteamID(info.steamID);
channel.send({
embed: {
title: info.chat,
color: options.color,
fields: [
{
name: 'Player',
value: playerInfo.name,
inline: true
},
{
name: 'SteamID',
value: `[${playerInfo.steamID}](https://steamcommunity.com/profiles/${info.steamID})`,
inline: true
},
{
name: 'Team & Squad',
value: `Team: ${playerInfo.teamID}, Squad: ${playerInfo.squadID ||
'Unassigned'}`
},
{
name: 'Message',
value: `${info.message}`
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
}

View File

@ -0,0 +1,34 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Debug
</div>
## About
The Discord Debug plugin logs all server events in a raw format for monitoring/debugging/testing purposes.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordDebug } from 'plugins';
// Import the events you wish to log in your index.js file. A full list can be found in the directory specified below.
import {
LOG_PARSER_PLAYER_CONNECTED,
LOG_PARSER_PLAYER_WOUNDED,
} from 'squad-server/events/log-parser';
// Place the following two lines in your index.js file before using an Discord plugins.
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token'); // insert your Discord bot's login token here.
// Place the following lines after all of the above.
await discordDebug(
server,
discordClient,
'discordChannelID',
[LOG_PARSER_PLAYER_CONNECTED, LOG_PARSER_PLAYER_WOUNDED] // List the events you wish to log.
);
```

View File

@ -0,0 +1,25 @@
export default async function plugin(
server,
discordClient,
channelID,
events = []
) {
if (!server)
throw new Error(
'DiscordDebug must be provided with a reference to the server.'
);
if (!discordClient)
throw new Error('DiscordDebug must be provided with a Discord.js client.');
if (!channelID)
throw new Error('DicordDebug must be provided with a channel ID.');
const channel = await discordClient.channels.fetch(channelID);
for (const event of events) {
server.on(event, info => {
channel.send(`\`\`\`${JSON.stringify(info, null, 2)}\`\`\``);
});
}
}

View File

@ -0,0 +1,30 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Server Status
</div>
## About
Display a server status embed that can be updated by clicking the refresh react.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordServerStatus } from 'plugins';
// Place the following two lines in your index.js file before using an Discord plugins.
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token'); // insert your Discord bot's login token here.
// Place the following lines after all of the above.
await discordServerStatus(
server,
discordClient,
{ // options - the options included below display the defaults and can be removed for simplicity.
color: 16761867, // color of embed
command: '!server' // command used to send message
}
);
```

View File

@ -0,0 +1,80 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
function makeEmbed(server, options) {
let players = `${server.a2sPlayerCount}`;
if (server.publicQueue + server.reserveQueue > 0)
players += ` (+${server.publicQueue + server.reserveQueue})`;
players += ` / ${server.publicSlots}`;
if (server.reserveSlots > 0) players += ` (+${server.reserveSlots})`;
return {
embed: {
title: server.serverName,
color: options.color,
fields: [
{
name: 'Players',
value: `\`\`\`${players}\`\`\``
},
{
name: 'Current Layer',
value: `\`\`\`${server.currentLayer}\`\`\``,
inline: true
},
{
name: 'Next Layer',
value: `\`\`\`${server.nextLayer || 'Unknown'}\`\`\``,
inline: true
}
],
timestamp: new Date().toISOString(),
footer: {
text: `Server Status by ${COPYRIGHT_MESSAGE}`
}
}
};
}
export default async function plugin(server, discordClient, options = {}) {
if (!server)
throw new Error(
'DiscordDebug must be provided with a reference to the server.'
);
if (!discordClient)
throw new Error('DiscordDebug must be provided with a Discord.js client.');
options = {
color: 16761867,
command: '!server',
...options
};
discordClient.on('message', async message => {
if (message.content !== options.command) return;
const serverStatus = await message.channel.send(makeEmbed(server, options));
await serverStatus.react('🔄');
});
discordClient.on('messageReactionAdd', async reaction => {
// confirm it's a status message
if (
reaction.message.embeds.length !== 1 ||
reaction.message.embeds[0].footer.text !==
`Server Status by ${COPYRIGHT_MESSAGE}`
)
return;
// ignore bots reacting
if (reaction.count === 1) return;
// remove reaction and readd it
await reaction.remove();
await reaction.message.react('🔄');
// update the message
await reaction.message.edit(makeEmbed(server, options));
});
}

View File

@ -0,0 +1,30 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Teamkill
</div>
## About
The Discord Teamkill plugin logs teamkill information to a Discord channel.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordTeamkill } from 'plugins';
// Place the following two lines in your index.js file before using a Discord plugins.
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token'); // insert your Discord bot's login token here.
// Place the following lines after all of the above.
await discordTeamkill(
server,
discordClient,
'discordChannelID',
{ // options - the options included below display the defaults and can be removed for simplicity.
color: 16761867 // color of embed
}
);
```

View File

@ -0,0 +1,74 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { LOG_PARSER_TEAMKILL } from 'squad-server/events/log-parser';
export default async function plugin(
server,
discordClient,
channelID,
options = {}
) {
if (!server)
throw new Error(
'DiscordTeamKill must be provided with a reference to the server.'
);
if (!discordClient)
throw new Error(
'DiscordTeamkill must be provided with a Discord.js client.'
);
if (!channelID)
throw new Error('DiscordTeamkill must be provided with a channel ID.');
options = {
color: 16761867,
...options
};
const channel = await discordClient.channels.fetch(channelID);
server.on(LOG_PARSER_TEAMKILL, info => {
if (!info.attacker) return;
channel.send({
embed: {
title: `Teamkill: ${info.attacker.name}`,
color: options.color,
fields: [
{
name: "Attacker's Name",
value: info.attacker.name,
inline: true
},
{
name: "Attacker's SteamID",
value: `[${info.attacker.steamID}](https://steamcommunity.com/profiles/${info.attacker.steamID})`,
inline: true
},
{
name: 'Weapon',
value: info.weapon
},
{
name: "Victim's Name",
value: info.victim.name,
inline: true
},
{
name: "Victim's SteamID",
value: `[${info.victim.steamID}](https://steamcommunity.com/profiles/${info.victim.steamID})`,
inline: true
},
{
name: 'Squad Community Ban List',
value: `[Attacker's Bans](https://squad-community-ban-list.com/search/${info.attacker.steamID})\n[Victims's Bans](https://squad-community-ban-list.com/search/${info.victim.steamID})`
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
}

25
plugins/index.js Normal file
View File

@ -0,0 +1,25 @@
import discordAdminCamLogs from './discord-admin-cam-logs/index.js';
import discordChat from './discord-chat/index.js';
import discordDebug from './discord-debug/index.js';
import discordServerStatus from './discord-server-status/index.js';
import discordTeamkill from './discord-teamkill/index.js';
import influxdbLog from './influxdb-log/index.js';
import influxdbLogDefaultSchema from './influxdb-log/schema.js';
import mapvote from './mapvote/index.js';
import mysqlLog from './mysql-log/index.js';
import seedingMessage from './seeding-message/index.js';
import teamRandomizer from './team-randomizer/index.js';
export {
discordAdminCamLogs,
discordChat,
discordDebug,
discordServerStatus,
discordTeamkill,
influxdbLog,
influxdbLogDefaultSchema,
mapvote,
mysqlLog,
seedingMessage,
teamRandomizer
};

View File

@ -0,0 +1,30 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - InfluxDB Log
</div>
## About
The InfluxDB log plugin logs event information into InfluxDB to allow it to be queried for analysis, monitoring, or stat tracking.. Works well with Grafana.
## Requirements
* InfluxDB database.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Influx from 'influx';
import { influxdbLog, influxdbLogDefaultSchema } from 'plugins';
// Place the following lines in your index.js file. Replace the credentials with the credentials of your InfluxDB database.
const influxDB = new Influx.InfluxDB({
host: 'host',
port: 8086,
username: 'squadjs',
password: 'password',
database: 'squadjs',
schema: influxdbLogDefaultSchema
});
influxdbLog(server, influxDB);
```

View File

@ -0,0 +1,125 @@
import {
LOG_PARSER_NEW_GAME,
LOG_PARSER_PLAYER_DIED,
LOG_PARSER_PLAYER_WOUNDED,
LOG_PARSER_PLAYER_REVIVED,
LOG_PARSER_SERVER_TICK_RATE
} from 'squad-server/events/log-parser';
import { SERVER_PLAYERS_UPDATED } from 'squad-server/events/server';
export default function influxdbLog(server, influxDB, options = {}) {
if (!server)
throw new Error(
'InfluxDBLog must be provided with a reference to the server.'
);
if (!influxDB)
throw new Error('InfluxDBLog must be provided with a InfluxDB connection.');
let points = [];
setInterval(() => {
influxDB.writePoints(points);
points = [];
}, options.writeInterval || 30 * 1000);
server.on(LOG_PARSER_SERVER_TICK_RATE, info => {
points.push({
measurement: 'ServerTickRate',
tags: { server: server.id },
fields: { tick_rate: info.tickRate },
timestamp: info.time
});
});
server.on(SERVER_PLAYERS_UPDATED, players => {
points.push({
measurement: 'PlayerCount',
tags: { server: server.id },
fields: { player_count: players.length },
timestamp: new Date()
});
});
server.on(LOG_PARSER_NEW_GAME, info => {
points.push({
measurement: 'Match',
tags: { server: server.id },
fields: {
dlc: info.dlc,
mapClassname: info.mapClassname,
layerClassname: info.layerClassname,
map: info.map,
layer: info.layer
},
timestamp: info.time
});
});
server.on(LOG_PARSER_PLAYER_WOUNDED, info => {
points.push({
measurement: 'PlayerWounded',
tags: { server: server.id },
fields: {
victim: info.victim ? info.victim.steamID : null,
victimName: info.victim ? info.victim.name : null,
victimTeamID: info.victim ? info.victim.teamID : null,
victimSquadID: info.victim ? info.victim.squadID : null,
attacker: info.attacker ? info.attacker.steamID : null,
attackerName: info.attacker ? info.attacker.name : null,
attackerTeamID: info.attacker ? info.attacker.teamID : null,
attackerSquadID: info.attacker ? info.attacker.squadID : null,
damage: info.damage,
weapon: info.weapon,
teamkill: info.teamkill
},
timestamp: info.time
});
});
server.on(LOG_PARSER_PLAYER_DIED, info => {
points.push({
measurement: 'PlayerDied',
tags: { server: server.id },
fields: {
victim: info.victim ? info.victim.steamID : null,
victimName: info.victim ? info.victim.name : null,
victimTeamID: info.victim ? info.victim.teamID : null,
victimSquadID: info.victim ? info.victim.squadID : null,
attacker: info.attacker ? info.attacker.steamID : null,
attackerName: info.attacker ? info.attacker.name : null,
attackerTeamID: info.attacker ? info.attacker.teamID : null,
attackerSquadID: info.attacker ? info.attacker.squadID : null,
damage: info.damage,
weapon: info.weapon,
teamkill: info.teamkill
},
timestamp: info.time
});
});
server.on(LOG_PARSER_PLAYER_REVIVED, info => {
points.push({
measurement: 'Revived',
tags: { server: server.id },
fields: {
victim: info.victim ? info.victim.steamID : null,
victimName: info.victim ? info.victim.name : null,
victimTeamID: info.victim ? info.victim.teamID : null,
victimSquadID: info.victim ? info.victim.squadID : null,
attacker: info.attacker ? info.attacker.steamID : null,
attackerName: info.attacker ? info.attacker.name : null,
attackerTeamID: info.attacker ? info.attacker.teamID : null,
attackerSquadID: info.attacker ? info.attacker.squadID : null,
damage: info.damage,
weapon: info.weapon,
teamkill: info.teamkill,
reviver: info.reviver ? info.reviver.steamID : null,
reviverName: info.reviver ? info.reviver.name : null,
reviverTeamID: info.reviver ? info.reviver.teamID : null,
reviverSquadID: info.reviver ? info.reviver.squadID : null
},
timestamp: info.time
});
});
}

View File

@ -0,0 +1,84 @@
import Influx from 'influx';
export default [
{
measurement: 'ServerTickRate',
fields: {
tick_rate: Influx.FieldType.FLOAT
},
tags: ['server']
},
{
measurement: 'PlayerCount',
fields: {
player_count: Influx.FieldType.INTEGER
},
tags: ['server']
},
{
measurement: 'Match',
fields: {
dlc: Influx.FieldType.STRING,
mapClassname: Influx.FieldType.STRING,
layerClassname: Influx.FieldType.STRING,
map: Influx.FieldType.STRING,
layer: Influx.FieldType.STRING
},
tags: ['server']
},
{
measurement: 'PlayerWounded',
fields: {
victim: Influx.FieldType.STRING,
victimName: Influx.FieldType.STRING,
victimTeamID: Influx.FieldType.INTEGER,
victimSquadID: Influx.FieldType.INTEGER,
attacker: Influx.FieldType.STRING,
attackerName: Influx.FieldType.STRING,
attackerTeamID: Influx.FieldType.INTEGER,
attackerSquadID: Influx.FieldType.INTEGER,
damage: Influx.FieldType.STRING,
weapon: Influx.FieldType.STRING,
teamkill: Influx.FieldType.BOOLEAN
},
tags: ['server']
},
{
measurement: 'PlayerDied',
fields: {
victim: Influx.FieldType.STRING,
victimName: Influx.FieldType.STRING,
victimTeamID: Influx.FieldType.INTEGER,
victimSquadID: Influx.FieldType.INTEGER,
attacker: Influx.FieldType.STRING,
attackerName: Influx.FieldType.STRING,
attackerTeamID: Influx.FieldType.INTEGER,
attackerSquadID: Influx.FieldType.INTEGER,
damage: Influx.FieldType.STRING,
weapon: Influx.FieldType.STRING,
teamkill: Influx.FieldType.BOOLEAN
},
tags: ['server']
},
{
measurement: 'PlayerRevived',
fields: {
victim: Influx.FieldType.STRING,
victimName: Influx.FieldType.STRING,
victimTeamID: Influx.FieldType.INTEGER,
victimSquadID: Influx.FieldType.INTEGER,
attacker: Influx.FieldType.STRING,
attackerName: Influx.FieldType.STRING,
attackerTeamID: Influx.FieldType.INTEGER,
attackerSquadID: Influx.FieldType.INTEGER,
damage: Influx.FieldType.STRING,
weapon: Influx.FieldType.STRING,
teamkill: Influx.FieldType.BOOLEAN,
reviver: Influx.FieldType.STRING,
reviverName: Influx.FieldType.STRING,
reviverTeamID: Influx.FieldType.INTEGER,
reviverSquadID: Influx.FieldType.INTEGER
},
tags: ['server']
}
];

36
plugins/mapvote/README.md Normal file
View File

@ -0,0 +1,36 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Mapvote
</div>
## About
The mapvote plugin uses a "did you mean?" system to allow for players to vote for a wide range of layers. Command information for using the plugin in-game can be accessed by typing `!mapvote help` into in-game chat.
## Installation
Place the following into your `index.js` file. The filters / options below are optional and can be removed without affecting functionality, however, the default options are shown for reference.
```js
mapvote(
server,
{ // layer filter to limit layers - remove or edit the below options to adjust the filter. Leaving this blank will remove all training layers as a default.
whitelistedLayers: ['layer name'], // an array of layers that can be played
blacklistedLayers: ['layer name'], // an array of layers that cannot be played
whitelistedMaps: ['map name'], // an array of maps that can be played
blacklistedMaps: ['map name'], // an array of maps that cannot be played - default removes training maps
whitelistedGamemodes: ['gamemode name'], // an array of gamemodes that can be played
blacklistedGamemodes: ['gamemode name'], // an array of gamemodes that cannot be played
flagCountMin: 4, // the minimum number of flags the layer must have
flagCountMax: 7, // the maximum number of flags the layer must have
hasCommander: true, // has commander enabled
hasTanks: true, // has tanks
hasHelicopters: true // has helicopters
},
{ // options - remove or edit the below options. The defaults are shown.
command: '!mapvote', // the command name used to access the vote
layerTolerance: 4, // the number of other layers that must be played before the layer can be revoted for
mapTolerance: 2, // the number of other maps that must be played before the layer can be revoted for
timeTolerance: 5 * 60 * 60 * 1000 // the time that must pass before the above are ignored
}
);
```

145
plugins/mapvote/index.js Normal file
View File

@ -0,0 +1,145 @@
import didYouMean from 'didyoumean';
import { COPYRIGHT_MESSAGE } from 'core/config';
import SquadLayers from 'connectors/squad-layers';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
import { SERVER_LAYER_CHANGE } from 'squad-server/events/server';
export default function(server, layerFilter = {}, options = {}) {
if (!server)
throw new Error('Mapvote must be provided with a reference to the server.');
const command = options.command || '!mapvote';
const commandRegex = new RegExp(`^${command} ([A-z0-9'_ ]*)`, 'i');
const rotation = SquadLayers.getFilteredLayers(layerFilter);
let voteCounts = {};
let votes = {};
let currentWinner = null;
function getResults() {
let results;
results = Object.keys(voteCounts).map(layer => {
return {
layer: layer,
voteCount: voteCounts[layer]
};
});
results = results.sort((a, b) => {
if (a.voteCount > b.voteCount) return -1;
if (a.voteCount < b.voteCount) return 1;
else return Math.random() < 0.5 ? 1 : -1;
});
return results;
}
server.on(SERVER_LAYER_CHANGE, () => {
voteCounts = {};
votes = {};
currentWinner = null;
});
server.on(RCON_CHAT_MESSAGE, info => {
const match = info.message.match(commandRegex);
if (!match) return;
if (match[1] === 'help') {
// show help options
server.rcon.execute(
`AdminWarn "${info.steamID}" You may use any of the following commands in chat:`
);
server.rcon.execute(
`AdminWarn "${info.steamID}" !mapvote results - View the current vote counts.`
);
server.rcon.execute(
`AdminWarn "${info.steamID}" !mapvote <layer name> - Vote for the specified layer.`
);
server.rcon.execute(
`AdminWarn "${info.steamID}" When inputting a layer name, we autocorrect any miss spelling.`
);
} else if (match[1] === 'results') {
// display results to player
const results = getResults();
if (results.length === 0) {
server.rcon.execute(
`AdminWarn "${info.steamID}" No one has voted yet.`
);
} else {
server.rcon.execute(
`AdminWarn "${info.steamID}" The current vote counts are as follows:`
);
for (const result of results) {
if (result.voteCount === 0) continue;
server.rcon.execute(
`AdminWarn "${info.steamID}" ${result.layer} - ${
result.voteCount
} vote${result.voteCount > 1 ? 's' : ''}.`
);
}
}
} else {
const layer = didYouMean(match[1], SquadLayers.getLayerNames());
// check layer is valid
if (layer === null) {
server.rcon.execute(
`AdminWarn "${info.steamID}" ${match[1]} is not a valid layer name.`
);
return;
}
if (!rotation.includes(layer)) {
server.rcon.execute(
`AdminWarn "${info.steamID}" ${layer} is not in the rotation.`
);
return;
}
if (
!SquadLayers.isHistoryCompliant(server.layerHistory, layer, options)
) {
server.rcon.execute(
`AdminWarn "${info.steamID}" ${layer} has been played too recently.`
);
return;
}
// remove existing votes
if (info.steamID in votes) voteCounts[votes[info.steamID]]--;
// add new vote
if (layer in voteCounts) voteCounts[layer]++;
else voteCounts[layer] = 1;
// save what layer they votes for
votes[info.steamID] = layer;
// info them of their vote
server.rcon.execute(
`AdminWarn "${info.steamID}" You voted for ${layer}.`
);
server.rcon.execute(
`AdminWarn "${info.steamID}" Powered by: ${COPYRIGHT_MESSAGE}`
);
// check for new winner
const newWinner = getResults()[0].layer;
if (currentWinner !== newWinner) {
server.rcon.execute(`AdminSetNextMap ${newWinner}`);
server.rcon.execute(`AdminBroadcast New Map Vote Winner: ${newWinner}`);
server.rcon.execute(
`AdminBroadcast Participate in the map vote by typing "!mapvote help" in chat.`
);
currentWinner = newWinner;
}
}
});
}

View File

@ -0,0 +1,32 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - MySQL Log
</div>
## About
The MySQL log plugin logs event information into a MySQL database to allow it to be queried for analysis, monitoring, or stat tracking. Works well with Grafana.
## Requirements
* MySQL database.
* Execute the [`mysql-schema.sql`](https://github.com/Thomas-Smyth/SquadJS/blob/master/plugins/mysql-log/mysql-schema.sql) in the database to setup the tables, etc.
* Add your server to the database... `INSERT INTO Server (name) VALUES ("[EU] The Coalition");` Please make sure the inserted ID is the same as that of the server in the `index.js` file.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import mysql from 'mysql';
import { mysqlLog } from 'plugins';
// Place the following lines in your index.js file. Replace the credentials with the credentials of your MySQL database.
const mysqlPool = mysql.createPool({
connectionLimit: 10,
host: 'host',
port: 3306,
user: 'squadjs',
password: 'password',
database: 'squadjs'
});
mysqlLog(server, mysqlPool);
```

107
plugins/mysql-log/index.js Normal file
View File

@ -0,0 +1,107 @@
import {
LOG_PARSER_NEW_GAME,
LOG_PARSER_PLAYER_WOUNDED,
LOG_PARSER_PLAYER_DIED,
LOG_PARSER_PLAYER_REVIVED,
LOG_PARSER_SERVER_TICK_RATE
} from 'squad-server/events/log-parser';
import { SERVER_PLAYERS_UPDATED } from 'squad-server/events/server';
export default function mysqlLog(server, mysqlPool) {
if (!server)
throw new Error(
'MySQLLog must be provided with a reference to the server.'
);
if (!mysqlPool)
throw new Error('MySQLLog must be provided with a mysql Pool.');
server.on(LOG_PARSER_SERVER_TICK_RATE, info => {
mysqlPool.query(
'INSERT INTO ServerTickRate(time, server, tick_rate) VALUES (?,?,?)',
[info.time, server.id, info.tickRate]
);
});
server.on(SERVER_PLAYERS_UPDATED, players => {
mysqlPool.query(
'INSERT INTO PlayerCount(time, server, player_count) VALUES (NOW(),?,?)',
[server.id, players.length]
);
});
server.on(LOG_PARSER_NEW_GAME, info => {
mysqlPool.query('call NewMatch(?,?,?,?,?,?,?)', [
server.id,
info.time,
info.dlc,
info.mapClassname,
info.layerClassname,
info.map,
info.layer
]);
});
server.on(LOG_PARSER_PLAYER_WOUNDED, info => {
mysqlPool.query('call InsertPlayerWounded(?,?,?,?,?,?,?,?,?,?,?,?,?)', [
server.id,
info.time,
info.victim ? info.victim.steamID : null,
info.victim ? info.victim.name : null,
info.victim ? info.victim.teamID : null,
info.victim ? info.victim.squadID : null,
info.attacker ? info.attacker.steamID : null,
info.attacker ? info.attacker.name : null,
info.attacker ? info.attacker.teamID : null,
info.attacker ? info.attacker.squadID : null,
info.damage,
info.weapon,
info.teamkill
]);
});
server.on(LOG_PARSER_PLAYER_DIED, info => {
mysqlPool.query('call InsertPlayerDied(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
server.id,
info.time,
info.woundTime,
info.victim ? info.victim.steamID : null,
info.victim ? info.victim.name : null,
info.victim ? info.victim.teamID : null,
info.victim ? info.victim.squadID : null,
info.attacker ? info.attacker.steamID : null,
info.attacker ? info.attacker.name : null,
info.attacker ? info.attacker.teamID : null,
info.attacker ? info.attacker.squadID : null,
info.damage,
info.weapon,
info.teamkill
]);
});
server.on(LOG_PARSER_PLAYER_REVIVED, info => {
mysqlPool.query(
'call InsertPlayerRevived(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
[
server.id,
info.time,
info.woundTime,
info.victim ? info.victim.steamID : null,
info.victim ? info.victim.name : null,
info.victim ? info.victim.teamID : null,
info.victim ? info.victim.squadID : null,
info.attacker ? info.attacker.steamID : null,
info.attacker ? info.attacker.name : null,
info.attacker ? info.attacker.teamID : null,
info.attacker ? info.attacker.squadID : null,
info.damage,
info.weapon,
info.teamkill,
info.reviver ? info.reviver.steamID : null,
info.reviver ? info.reviver.name : null,
info.reviver ? info.reviver.teamID : null,
info.reviver ? info.reviver.squadID : null
]
);
});
}

View File

@ -0,0 +1,361 @@
DROP DATABASE squadjs;
CREATE DATABASE IF NOT EXISTS squadjs;
USE squadjs;
CREATE USER IF NOT EXISTS squadjs IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON squadjs.* TO squadjs;
CREATE TABLE IF NOT EXISTS `Server` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS `ServerTickRate` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`server` INT NOT NULL,
`time` TIMESTAMP NOT NULL,
`tick_rate` FLOAT NOT NULL,
FOREIGN KEY (`server`) REFERENCES `Server`(`id`)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `PlayerCount` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`server` INT NOT NULL,
`time` TIMESTAMP NOT NULL,
`player_count` FLOAT NOT NULL,
FOREIGN KEY (`server`) REFERENCES `Server`(`id`)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `Match` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`server` INT NOT NULL,
`dlc` VARCHAR(255),
`mapClassname` VARCHAR(255),
`layerClassname` VARCHAR(255),
`map` VARCHAR(255),
`layer` VARCHAR(255),
`startTime` TIMESTAMP NOT NULL,
`endTime` TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (`server`) REFERENCES `Server`(`id`)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `SteamUser` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`steamID` VARCHAR(17) NOT NULL UNIQUE,
`lastName` VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS `PlayerWounded` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`server` INT NOT NULL,
`time` TIMESTAMP NOT NULL,
`victim` VARCHAR(255),
`victimName` VARCHAR(255),
`victimTeamID` INT,
`victimSquadID` INT,
`attacker` VARCHAR(255),
`attackerName` VARCHAR(255),
`attackerTeamID` INT,
`attackerSquadID` INT,
`damage` FLOAT,
`weapon` VARCHAR(255),
`teamkill` BOOLEAN,
FOREIGN KEY (`server`) REFERENCES `Server`(`id`)
ON DELETE CASCADE,
FOREIGN KEY (`victim`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE,
FOREIGN KEY (`attacker`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `PlayerDied` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`server` INT NOT NULL,
`time` TIMESTAMP NOT NULL,
`woundTime` TIMESTAMP NOT NULL,
`victim` VARCHAR(255),
`victimName` VARCHAR(255),
`victimTeamID` INT,
`victimSquadID` INT,
`attacker` VARCHAR(255),
`attackerName` VARCHAR(255),
`attackerTeamID` INT,
`attackerSquadID` INT,
`damage` FLOAT,
`weapon` VARCHAR(255),
`teamkill` BOOLEAN,
FOREIGN KEY (`server`) REFERENCES `Server`(`id`)
ON DELETE CASCADE,
FOREIGN KEY (`victim`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE,
FOREIGN KEY (`attacker`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `PlayerRevived` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`server` INT NOT NULL,
`time` TIMESTAMP NOT NULL,
`woundTime` TIMESTAMP NOT NULL,
`victim` VARCHAR(255),
`victimName` VARCHAR(255),
`victimTeamID` INT,
`victimSquadID` INT,
`attacker` VARCHAR(255),
`attackerName` VARCHAR(255),
`attackerTeamID` INT,
`attackerSquadID` INT,
`damage` FLOAT,
`weapon` VARCHAR(255),
`teamkill` BOOLEAN,
`reviver` VARCHAR(255),
`reviverName` VARCHAR(255),
`reviverTeamID` INT,
`reviverSquadID` INT,
FOREIGN KEY (`server`) REFERENCES `Server`(`id`)
ON DELETE CASCADE,
FOREIGN KEY (`victim`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE,
FOREIGN KEY (`attacker`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE,
FOREIGN KEY (`reviver`) REFERENCES `SteamUser`(`steamID`)
ON DELETE CASCADE
);
DROP PROCEDURE IF EXISTS `NewMatch`;
DELIMITER #
CREATE PROCEDURE `NewMatch` (
IN `p_server` INT,
IN `p_time` TIMESTAMP,
IN `p_dlc` VARCHAR(255),
IN `p_mapClassname` VARCHAR(255),
IN `p_layerClassname` VARCHAR(255),
IN `p_map` VARCHAR(255),
IN `p_layer` VARCHAR(255)
)
BEGIN
UPDATE `Match` SET `endTime` = `p_time` WHERE `server` = `p_server` AND `endTime` IS NULL;
INSERT INTO `Match` (
`server`,
`startTime`,
`dlc`,
`mapClassname`,
`layerClassname`,
`map`,
`layer`
) VALUES (
`p_server`,
`p_time`,
`p_dlc`,
`p_mapClassname`,
`p_layerClassname`,
`p_map`,
`p_layer`
);
END#
DELIMITER ;
DROP PROCEDURE IF EXISTS `InsertPlayerWounded`;
DELIMITER #
CREATE PROCEDURE `InsertPlayerWounded` (
IN `p_server` INT,
IN `p_time` TIMESTAMP,
IN `P_victim` VARCHAR(255),
IN `p_victimName` VARCHAR(255),
IN `p_victimTeamID` INT,
IN `p_victimSquadID` INT,
IN `p_attacker` VARCHAR(255),
IN `p_attackerName` VARCHAR(255),
IN `p_attackerTeamID` INT,
IN `p_attackerSquadID` INT,
IN `p_damage` FLOAT,
IN `p_weapon` VARCHAR(255),
IN `p_teamkill` BOOLEAN
)
BEGIN
-- insert players into SteamUsers table
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_victim`, `p_victimName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_victimName`;
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_attacker`, `p_attackerName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_attackerName`;
-- create wound record
INSERT INTO `PlayerWounded` (
`server`,
`time`,
`victim`,
`victimName`,
`victimTeamID`,
`victimSquadID`,
`attacker`,
`attackerName`,
`attackerTeamID`,
`attackerSquadID`,
`damage`,
`weapon`,
`teamkill`
) VALUES (
`p_server`,
`p_time`,
`p_victim`,
`p_victimName`,
`p_victimTeamID`,
`p_victimSquadID`,
`p_attacker`,
`p_attackerName`,
`p_attackerTeamID`,
`p_attackerSquadID`,
`p_damage`,
`p_weapon`,
`p_teamkill`
);
END#
DELIMITER ;
DROP PROCEDURE IF EXISTS `InsertPlayerDied`;
DELIMITER #
CREATE PROCEDURE `InsertPlayerDied` (
IN `p_server` INT,
IN `p_time` TIMESTAMP,
IN `p_woundTime` TIMESTAMP,
IN `P_victim` VARCHAR(255),
IN `p_victimName` VARCHAR(255),
IN `p_victimTeamID` INT,
IN `p_victimSquadID` INT,
IN `p_attacker` VARCHAR(255),
IN `p_attackerName` VARCHAR(255),
IN `p_attackerTeamID` INT,
IN `p_attackerSquadID` INT,
IN `p_damage` FLOAT,
IN `p_weapon` VARCHAR(255),
IN `p_teamkill` BOOLEAN
)
BEGIN
-- insert players into SteamUsers table
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_victim`, `p_victimName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_victimName`;
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_attacker`, `p_attackerName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_attackerName`;
-- create die record
INSERT INTO `PlayerDied` (
`server`,
`time`,
`woundTime`,
`victim`,
`victimName`,
`victimTeamID`,
`victimSquadID`,
`attacker`,
`attackerName`,
`attackerTeamID`,
`attackerSquadID`,
`damage`,
`weapon`,
`teamkill`
) VALUES (
`p_server`,
`p_time`,
`p_woundTime`,
`p_victim`,
`p_victimName`,
`p_victimTeamID`,
`p_victimSquadID`,
`p_attacker`,
`p_attackerName`,
`p_attackerTeamID`,
`p_attackerSquadID`,
`p_damage`,
`p_weapon`,
`p_teamkill`
);
END#
DELIMITER ;
DROP PROCEDURE IF EXISTS `InsertPlayerRevived`;
DELIMITER #
CREATE PROCEDURE `InsertPlayerRevived` (
IN `p_server` INT,
IN `p_time` TIMESTAMP,
IN `p_woundTime` TIMESTAMP,
IN `P_victim` VARCHAR(255),
IN `p_victimName` VARCHAR(255),
IN `p_victimTeamID` INT,
IN `p_victimSquadID` INT,
IN `p_attacker` VARCHAR(255),
IN `p_attackerName` VARCHAR(255),
IN `p_attackerTeamID` INT,
IN `p_attackerSquadID` INT,
IN `p_damage` FLOAT,
IN `p_weapon` VARCHAR(255),
IN `p_teamkill` BOOLEAN,
IN `p_reviver` VARCHAR(255),
IN `p_reviverName` VARCHAR(255),
IN `p_reviverTeamID` VARCHAR(255),
IN `p_reviverSquadID` VARCHAR(255)
)
BEGIN
-- insert players into SteamUsers table
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_victim`, `p_victimName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_victimName`;
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_attacker`, `p_attackerName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_attackerName`;
INSERT INTO `SteamUser` (`steamID`, `lastName`) VALUES (`p_reviver`, `p_reviverName`)
ON DUPLICATE KEY UPDATE `lastName` = `p_reviverName`;
-- create revive record
INSERT INTO `PlayerRevived` (
`server`,
`time`,
`woundTime`,
`victim`,
`victimName`,
`victimTeamID`,
`victimSquadID`,
`attacker`,
`attackerName`,
`attackerTeamID`,
`attackerSquadID`,
`damage`,
`weapon`,
`teamkill`,
`reviver`,
`reviverName`,
`reviverTeamID`,
`reviverSquadID`
) VALUES (
`p_server`,
`p_time`,
`p_woundTime`,
`p_victim`,
`p_victimName`,
`p_victimTeamID`,
`p_victimSquadID`,
`p_attacker`,
`p_attackerName`,
`p_attackerTeamID`,
`p_attackerSquadID`,
`p_damage`,
`p_weapon`,
`p_teamkill`,
`p_reviver`,
`p_reviverName`,
`p_reviverTeamID`,
`p_reviverSquadID`
);
END#
DELIMITER ;

14
plugins/package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "plugins",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./index.js"
},
"dependencies": {
"connectors": "1.0.0",
"didyoumean": "^1.2.1",
"influx": "^5.5.1",
"squad-server": "1.0.0"
}
}

View File

@ -0,0 +1,27 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Seeding Message
</div>
## About
Displays a seeding message when player count is below a certain level and displays a live message when player count is slightly above that level.
## Installation
Place the following into your `index.js` file. The options below are optional and can be removed without affecting functionality, however, the default options are shown for reference.
```js
seedingMessage(
server,
{ // options - remove or edit the below options. The defaults are shown.
mode: 'interval', // interval displays every x seconds, onjoin displays x seconds after a player joins the server
interval: 150 * 1000, // how often the seeding message is displayed in milliseconds in interval mode
delay: 45 * 1000, // delay between player connecting and seeding message in onjoin mode
seedingThreshold: 50, // seeding messages are displayed when player count is below this number
seedingMessage: 'Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!', // message to display in seeding mode
liveEnabled: true, // display live message
liveThreshold: 2, // live message will display when player count exceeds seedingThreshold by up to this amount
liveMessage: 'Live!' // message to display when server is live
}
);
```

View File

@ -0,0 +1,62 @@
import { LOG_PARSER_PLAYER_CONNECTED } from 'squad-server/events/log-parser';
export default function(server, options = {}) {
if (!server)
throw new Error(
'SeedingMessage must be provided with a reference to the server.'
);
const mode = options.mode || 'interval';
const interval = options.interval || 150 * 1000;
const delay = options.delay || 45 * 1000;
const seedingThreshold = options.seedingThreshold || 50;
const seedingMessage =
options.seedingMessage ||
'Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!';
const liveEnabled = options.liveEnabled || true;
const liveThreshold = seedingThreshold + (options.liveThreshold || 2);
const liveMessage = options.liveMessage || 'Live!';
switch (mode) {
case 'interval':
setInterval(() => {
const playerCount = server.players.length;
if (playerCount === 0) return;
if (playerCount < seedingThreshold) {
server.rcon.execute(`AdminBroadcast ${seedingMessage}`);
return;
}
if (liveEnabled && playerCount < liveThreshold) {
server.rcon.execute(`AdminBroadcast ${liveMessage}`);
}
}, interval);
break;
case 'onjoin':
server.on(LOG_PARSER_PLAYER_CONNECTED, () => {
setTimeout(() => {
const playerCount = server.players.length;
if (playerCount === 0) return;
if (playerCount < seedingThreshold) {
server.rcon.execute(`AdminBroadcast ${seedingMessage}`);
return;
}
if (liveEnabled && playerCount < liveThreshold) {
server.rcon.execute(`AdminBroadcast ${liveMessage}`);
}
}, delay);
});
break;
default:
throw new Error('Invalid SeedingMessage mode.');
}
}

View File

@ -0,0 +1,20 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Team Randomizer
</div>
## About
The team randomizer randomly moves players to a team when `!randomize` is called in admin chat.
## Installation
Place the following into your `index.js` file. The options below are optional and can be removed without affecting functionality, however, the default options are shown for reference.
```js
teamRandomizer(
server,
{ // options - remove or edit the below options. The defaults are shown.
command: '!randomize', // the command name used to access the feature
}
);
```

View File

@ -0,0 +1,51 @@
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
function shuffle(array) {
let currentIndex = array.length;
let temporaryValue;
let randomIndex;
// While there remain elements to shuffle...
while (currentIndex !== 0) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
return array;
}
export default function(server, options = {}) {
if (!server)
throw new Error(
'TeamRandomizer must be provided with a reference to the server.'
);
const command = options.command || '!randomize';
const commandRegex = new RegExp(`^${command}`, 'i');
server.on(RCON_CHAT_MESSAGE, info => {
if (info.chat !== 'ChatAdmin') return;
const match = info.message.match(commandRegex);
if (!match) return;
const players = server.players.slice(0);
shuffle(players);
let team = '1';
for (const player of players) {
if (player.teamID !== team) {
server.rcon.execute(`AdminForceTeamChange "${player.steamID}"`);
}
team = team === '1' ? '2' : '1';
}
});
}

View File

@ -0,0 +1,123 @@
/** Occurs when an admin enters admin camera.
*
* Data:
* - time - Date object of when the event occurred.
* - player - PlayerObject of the admin.
*/
const LOG_PARSER_ADMIN_POSSESS_CAMERA = 'LOG_PARSER_ADMIN_POSSESS_CAMERA';
/** Occurs when a new layer is loaded.
*
* Data:
* - time - Date object of when the event occurred.
* - dlc - DLC / Mod the layer was loaded from.
* - mapClassname - Classname of the map.
* - layerClassname - Classname of the layer.
* - map - Map name (if known).
* - layer - Layer name (if known).
*/
const LOG_PARSER_NEW_GAME = 'LOG_PARSER_NEW_GAME';
/** Occurs when a new player connects.
*
* Data:
* - time - Date object of when the event occurred.
* - player - PlayerObject of the player.
*/
const LOG_PARSER_PLAYER_CONNECTED = 'LOG_PARSER_PLAYER_CONNECTED';
/** Occurs when a player is damaged.
*
* Data:
* - time - Date object of when the event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - weapon - The classname of the weapon used.
*/
const LOG_PARSER_PLAYER_DAMAGED = 'LOG_PARSER_PLAYER_DAMAGED';
/** Occurs when a player dies.
*
* Data:
* - time - Date object of when the event occurred.
* - woundTime - Date object of when the wound event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
*/
const LOG_PARSER_PLAYER_DIED = 'LOG_PARSER_PLAYER_DIED';
/** Occurs when a player possess a new object.
*
* Data:
* - time - Date object of when the event occurred.
* - player - PlayerObject of the admin.
* - possessClassname - Classname of the object.
*/
const LOG_PARSER_PLAYER_POSSESS = 'LOG_PARSER_PLAYER_POSSESS';
/** Occurs when a player is revived.
*
* Data:
* - time - Date object of when the event occurred.
* - woundTime - Date object of when the wound event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
* - reviver - PlayerObject of the reviving player.
*/
const LOG_PARSER_PLAYER_REVIVED = 'LOG_PARSER_PLAYER_REVIVED';
/** Occurs when a player is teamkilled.
*
* Data:
* - time - Date object of when the event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
*/
const LOG_PARSER_TEAMKILL = 'LOG_PARSER_TEAMKILL';
/** Occurs when a player is wounded.
*
* Data:
* - time - Date object of when the event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
*/
const LOG_PARSER_PLAYER_WOUNDED = 'LOG_PARSER_PLAYER_WOUNDED';
/** Occurs when the server tick rate is updated.
*
* Data:
* - time - Date object of when the event occurred.
* - tickRate - Tick rate of the server.
*/
const LOG_PARSER_SERVER_TICK_RATE = 'LOG_PARSER_SERVER_TICK_RATE';
export {
LOG_PARSER_ADMIN_POSSESS_CAMERA,
LOG_PARSER_NEW_GAME,
LOG_PARSER_PLAYER_CONNECTED,
LOG_PARSER_PLAYER_DAMAGED,
LOG_PARSER_PLAYER_DIED,
LOG_PARSER_PLAYER_POSSESS,
LOG_PARSER_PLAYER_REVIVED,
LOG_PARSER_TEAMKILL,
LOG_PARSER_PLAYER_WOUNDED,
LOG_PARSER_SERVER_TICK_RATE
};

View File

@ -0,0 +1,19 @@
/** Occurs when an RCON error occurs.
*
* Data:
* - ErrorObject
*/
const RCON_ERROR = 'RCON_ERROR';
/** Occurs when an admin enters admin camera.
*
* Data:
* - chat - Chat the message was sent to.
* - steamID - Steam ID of the player.
* - player - Name of the player.
* - message - Message sent.
* - time - Time message was sent, AKA now.
*/
const RCON_CHAT_MESSAGE = 'RCON_CHAT_MESSAGE';
export { RCON_ERROR, RCON_CHAT_MESSAGE };

View File

@ -0,0 +1,47 @@
/** Occurs when a new layer is loaded.
*
* Data:
* - time - Date object of when the event occurred.
* - dlc - DLC / Mod the layer was loaded from.
* - mapClassname - Classname of the map.
* - layerClassname - Classname of the layer.
* - map - Map name (if known).
* - layer - Layer name (if known).
*/
const SERVER_LAYER_CHANGE = 'SERVER_LAYER_CHANGE';
/** Occurs when the player list is updated via RCON.
*
* Data:
* - Array of PlayerObjects
*/
const SERVER_PLAYERS_UPDATED = 'SERVER_PLAYERS_UPDATED';
/** Occurs when the layer info is updated via RCON.
*
* Data:
* - currentLayer - Current layer.
* - nextLayer - Next layer.
*/
const SERVER_LAYERS_UPDATED = 'SERVER_LAYERS_UPDATED';
/** Occurs when the server info is updated via A2S.
*
* Data:
* - serverName - Name of the server.
* - maxPlayers - Maximum number of players on the server.
* - publicSlots - Maximum number of public slots.
* - reserveSlots - Maximum number of reserved slots.
* - publicQueue - Length of the public queue.
* - reserveQueue - Length of the reserved queue.
* - matchTimeout - Time until match ends?
* - gameVersion - Game version.
*/
const SERVER_A2S_UPDATED = 'SERVER_A2S_UPDATED';
export {
SERVER_LAYER_CHANGE,
SERVER_PLAYERS_UPDATED,
SERVER_LAYERS_UPDATED,
SERVER_A2S_UPDATED
};

181
squad-server/index.js Normal file
View File

@ -0,0 +1,181 @@
import EventEmitter from 'events';
import Gamedig from 'gamedig';
import printLogo from 'core/utils/print-logo';
import LogParser from './log-parser/index.js';
import Rcon from './rcon/index.js';
import {
SERVER_LAYER_CHANGE,
SERVER_PLAYERS_UPDATED,
SERVER_LAYERS_UPDATED,
SERVER_A2S_UPDATED
} from './events/server.js';
import { LOG_PARSER_NEW_GAME } from './events/log-parser.js';
export default class Server extends EventEmitter {
constructor(options = {}) {
super();
// store options
if (!('id' in options)) throw new Error('Server must have an ID.');
this.id = options.id;
if (!('host' in options)) throw new Error('Server must have a host.');
this.host = options.host;
if (!('queryPort' in options))
throw new Error('Server must have a queryPort.');
this.queryPort = options.queryPort;
this.updateInterval = options.updateInterval || 30 * 1000;
// setup additional classes
this.rcon = new Rcon(options, this);
this.logParser = new LogParser(options, this);
// setup internal data storage
this.layerHistory = options.layerHistory || [];
this.layerHistoryMaxLength = options.layerHistoryMaxLength || 20;
this.players = [];
// store additional information about players by SteamID
this.suffixStore = {};
// setup internal listeners
this.on(LOG_PARSER_NEW_GAME, this.onLayerChange.bind(this));
// setup period updaters
this.updatePlayers = this.updatePlayers.bind(this);
this.updatePlayerTimeout = setTimeout(
this.updatePlayers,
this.updateInterval
);
setTimeout(async () => {
const data = await this.rcon.getMapInfo();
this.currentLayer = data.currentLayer;
this.nextLayer = data.nextLayer;
this.emit(SERVER_LAYERS_UPDATED, data);
}, this.updateInterval);
setTimeout(async () => {
const data = await Gamedig.query({
type: 'squad',
host: this.host,
port: this.queryPort
});
this.serverName = data.name;
this.maxPlayers = parseInt(data.maxplayers);
this.publicSlots = parseInt(data.raw.rules.NUMPUBCONN);
this.reserveSlots = parseInt(data.raw.rules.NUMPRIVCONN);
this.a2sPlayerCount = Math.min(data.players.length, this.maxPlayers);
this.publicQueue = parseInt(data.raw.rules.PublicQueue_i);
this.reserveQueue = parseInt(data.raw.rules.ReservedQueue_i);
this.matchTimeout = parseFloat(data.raw.rules.MatchTimeout_f);
this.gameVersion = data.raw.version;
this.emit(SERVER_A2S_UPDATED, {
serverName: this.serverName,
maxPlayers: this.maxPlayers,
publicSlots: this.publicSlots,
reserveSlots: this.reserveSlots,
publicQueue: this.publicQueue,
reserveQueue: this.reserveQueue,
matchTimeout: this.matchTimeout,
gameVersion: this.gameVersion
});
}, this.updateInterval);
}
async watch() {
printLogo();
console.log(`Watching server ${this.id}...`);
if (this.logParser) await this.logParser.watch();
if (this.rcon) await this.rcon.watch();
}
async unwatch() {
if (this.logParser) await this.logParser.unwatch();
if (this.rcon) await this.rcon.unwatch();
console.log('Stopped watching.');
}
async updatePlayers() {
clearTimeout(this.updatePlayerTimeout);
this.players = await this.rcon.listPlayers();
// readd additional information about the player we have collected
for (let i = 0; i < this.players.length; i++) {
this.players[i].suffix = this.suffixStore[this.players[i].steamID];
}
// delay another update
this.updatePlayerTimeout = setTimeout(
this.updatePlayers,
this.updateInterval
);
this.emit(SERVER_PLAYERS_UPDATED, this.players);
}
async getPlayerByName(name, suffix = false) {
let matchingPlayers;
matchingPlayers = [];
for (const player of this.players) {
if (player[suffix ? 'suffix' : 'name'] !== name) continue;
matchingPlayers.push(player);
}
if (matchingPlayers.length === 0) {
await this.updatePlayers();
matchingPlayers = [];
for (const player of this.players) {
if (player[suffix ? 'suffix' : 'name'] !== name) continue;
matchingPlayers.push(player);
}
}
if (matchingPlayers.length === 1) return matchingPlayers[0];
else return null;
}
async getPlayerBySteamID(steamID) {
let matchingPlayers;
matchingPlayers = [];
for (const player of this.players) {
if (player.steamID !== steamID) continue;
matchingPlayers.push(player);
}
if (matchingPlayers.length === 0) {
await this.updatePlayers();
matchingPlayers = [];
for (const player of this.players) {
if (player.steamID !== steamID) continue;
matchingPlayers.push(player);
}
}
return matchingPlayers[0];
}
onLayerChange(info) {
this.layerHistory.unshift(info);
this.layerHistory = this.layerHistory.slice(0, this.layerHistoryMaxLength);
this.emit(SERVER_LAYER_CHANGE, info);
}
}

View File

@ -0,0 +1,57 @@
import async from 'async';
import moment from 'moment';
import Server from '../index.js';
import TailLogReader from './log-readers/tail.js';
import FTPLogReader from './log-readers/ftp.js';
import rules from './rules/index.js';
export default class LogParser {
constructor(options = {}, server) {
if (!(server instanceof Server))
throw new Error('Server not an instance of a SquadJS server.');
this.server = server;
this.eventStore = {};
this.queueLine = this.queueLine.bind(this);
this.handleLine = this.handleLine.bind(this);
this.queue = async.queue(this.handleLine);
switch (options.logReaderMode || 'tail') {
case 'tail':
this.logReader = new TailLogReader(this.queueLine, options);
break;
case 'ftp':
this.logReader = new FTPLogReader(this.queueLine, options);
break;
default:
throw new Error('Invalid mode.');
}
}
async watch() {
await this.logReader.watch();
}
async unwatch() {
await this.logReader.unwatch();
}
queueLine(line) {
this.queue.push(line);
}
async handleLine(line) {
for (const rule of rules) {
const match = line.match(rule.regex);
if (!match) continue;
match[1] = moment.utc(match[1], 'YYYY.MM.DD-hh.mm.ss:SSS').toDate();
match[2] = parseInt(match[2]);
await rule.onMatch(match, this);
break;
}
}
}

View File

@ -0,0 +1,121 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import ftp from 'basic-ftp';
import sleep from 'core/utils/sleep';
// THIS LOG READER IS CURRENTLY UNDER DEVELOPMENT. IT IS ADVISED THAT YOU DO NOT USE IT.
export default class FTPLogReader {
constructor(queueLine, options = {}) {
if (typeof queueLine !== 'function')
throw new Error(
'queueLine argument must be specified and be a function.'
);
if (!options.host) throw new Error('Host must be specified.');
if (!options.ftpUser) throw new Error('FTP user must be specified.');
if (!options.ftpPassword)
throw new Error('FTP password must be specified.');
if (!options.remotePath) throw new Error('Remote path must be specified.');
this.queueLine = queueLine;
this.host = options.host;
this.port = options.ftpPort || 21;
this.user = options.ftpUser;
this.password = options.ftpPassword;
this.remotePath = options.logDir;
this.timeout = options.ftpTimeout || 3000;
this.encoding = 'utf8';
this.defaultInterval = options.ftpPullInterval || 500;
this.interval = this.defaultInterval;
this.tempFilePath = path.join(
process.cwd(),
'temp',
crypto
.createHash('md5')
.update(this.host.replace(/\./g, '-') + this.port + this.remotePath)
.digest('hex') + '.tmp'
);
this.maxTempFileSize = 5 * 1000 * 1000; // 5 MB
this.tailLastBytes = 100 * 1000;
}
async watch() {
this.client = new ftp.Client(this.timeout);
this.client.ftp.encoding = this.encoding;
await this.client.access({
host: this.host,
port: this.port,
user: this.user,
password: this.password
});
this.interval = this.defaultInterval;
this.runLoop();
}
async unwatch() {
this.client.close();
this.interval = -1;
if (fs.existsSync(this.tempFilePath)) {
fs.unlinkSync(this.tempFilePath);
}
}
async runLoop() {
while (this.interval !== -1) {
const runStartTime = Date.now();
if (fs.existsSync(this.tempFilePath)) {
const { size } = fs.statSync(this.tempFilePath);
if (size > this.maxTempFileSize || !this.lastByteReceived) {
fs.unlinkSync(this.tempFilePath);
}
}
// If we haven't received any data yet, tail the end of the file; else download all data since last pull
if (this.lastByteReceived == null) {
const fileSize = await this.client.size(this.remotePath);
this.lastByteReceived =
fileSize -
(this.tailLastBytes < fileSize ? this.tailLastBytes : fileSize);
}
// Download the data to a temp file, overwrite any previous data
// we overwrite previous data to calculate how much data we've received
await this.client.downloadTo(
fs.createWriteStream(this.tempFilePath, { flags: 'w' }),
this.remotePath,
this.lastByteReceived
);
const downloadSize = fs.statSync(this.tempFilePath).size;
this.lastByteReceived += downloadSize; // update the last byte marker - this is so we can get data since this position on the ftp download
const fileData = await new Promise((resolve, reject) => {
fs.readFile(this.tempFilePath, (err, data) => {
if (err) reject(err);
resolve(data);
});
});
fileData
.toString('utf8')
.split('\r\n')
.forEach(this.queueLine);
const ftpDataTime = Date.now();
const ftpDataTimeMs = ftpDataTime - runStartTime;
console.log('FTP Retrieve took: ' + ftpDataTimeMs + 'ms');
const waitTime = this.interval - ftpDataTimeMs;
if (waitTime > 0) {
await sleep(waitTime);
}
const runEndTime = Date.now();
console.log('Run time: ' + (runEndTime - runStartTime) + 'ms');
}
}
}

View File

@ -0,0 +1,29 @@
import path from 'path';
import TailModule from 'tail';
export default class TailLogReader {
constructor(queueLine, options = {}) {
if (typeof queueLine !== 'function')
throw new Error(
'queueLine argument must be specified and be a function.'
);
if (!options.logDir) throw new Error('Log directory must be specified.');
this.reader = new TailModule.Tail(
path.join(options.logDir, 'SquadGame.log'),
{
useWatchFile: true
}
);
this.reader.on('line', queueLine);
}
async watch() {
this.reader.watch();
}
async unwatch() {
this.reader.unwatch();
}
}

View File

@ -0,0 +1,21 @@
import NewGame from './new-game.js';
import PlayerConnected from './player-connected.js';
import PlayerDamaged from './player-damaged.js';
import PlayerDied from './player-died.js';
import PlayerPossess from './player-possess.js';
import PlayerRevived from './player-revived.js';
import PlayerWounded from './player-wounded.js';
import ServerTickRate from './server-tick-rate.js';
import SteamIDConnected from './steamid-connected.js';
export default [
NewGame,
PlayerConnected,
PlayerDamaged,
PlayerDied,
PlayerPossess,
PlayerRevived,
PlayerWounded,
ServerTickRate,
SteamIDConnected
];

View File

@ -0,0 +1,24 @@
import SquadLayers from 'connectors/squad-layers';
import { LOG_PARSER_NEW_GAME } from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogWorld: Bringing World \/([A-z]+)\/Maps\/([A-z]+)\/(?:Gameplay_Layers\/)?([A-z0-9_]+)/,
onMatch: (args, logParser) => {
const layer = SquadLayers.getLayerByLayerClassname(args[5]);
const data = {
raw: args[0],
time: args[1],
chainID: args[2],
dlc: args[3],
mapClassname: args[4],
layerClassname: args[5],
map: layer ? layer.map : null,
layer: layer ? layer.layer : null
};
/* Emit new game event */
logParser.server.emit(LOG_PARSER_NEW_GAME, data);
}
};

View File

@ -0,0 +1,18 @@
import { LOG_PARSER_PLAYER_CONNECTED } from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogNet: Join succeeded: (.+)/,
onMatch: async (args, logParser) => {
logParser.server.suffixStore[logParser.eventStore['steamid-connected']] =
args[3];
const data = {
raw: args[0],
time: args[1],
chainID: args[2],
player: await logParser.server.getPlayerByName(args[3], true)
};
logParser.server.emit(LOG_PARSER_PLAYER_CONNECTED, data);
}
};

View File

@ -0,0 +1,22 @@
import { LOG_PARSER_PLAYER_DAMAGED } from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquad: Player:(.+) ActualDamage=([0-9.]+) from (.+) caused by ([A-z_0-9]+)_C/,
onMatch: async (args, logParser) => {
const data = {
raw: args[0],
time: args[1],
chainID: args[2],
victim: await logParser.server.getPlayerByName(args[3]),
damage: parseFloat(args[4]),
attacker: await logParser.server.getPlayerByName(args[5]),
weapon: args[6]
};
data.teamkill = data.victim.teamID === data.attacker.teamID;
logParser.eventStore[args[3]] = data;
logParser.server.emit(LOG_PARSER_PLAYER_DAMAGED, data);
}
};

View File

@ -0,0 +1,24 @@
import { LOG_PARSER_PLAYER_DIED } from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquadTrace: \[DedicatedServer](?:ASQSoldier::)?Die\(\): Player:(.+) KillingDamage=(?:-)*([0-9.]+) from ([A-z_0-9]+) caused by ([A-z_0-9]+)_C/,
onMatch: async (args, logParser) => {
const data = {
...logParser.eventStore[args[3]],
raw: args[0],
time: args[1],
woundTime: args[1],
chainID: args[2],
victim: await logParser.server.getPlayerByName(args[3]),
damage: parseFloat(args[4]),
attackerPlayerController: args[5],
weapon: args[6]
};
data.teamkill = data.victim.teamID === data.attacker.teamID;
logParser.eventStore[args[3]] = data;
logParser.server.emit(LOG_PARSER_PLAYER_DIED, data);
}
};

View File

@ -0,0 +1,22 @@
import {
LOG_PARSER_PLAYER_POSSESS,
LOG_PARSER_ADMIN_POSSESS_CAMERA
} from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquadTrace: \[DedicatedServer](?:ASQPlayerController::)?OnPossess\(\): PC=(.+) Pawn=([A-z0-9_]+)_C/,
onMatch: async (args, logParser) => {
const data = {
raw: args[0],
time: args[1],
chainID: args[2],
player: await logParser.server.getPlayerByName(args[3], true),
possessClassname: args[4]
};
logParser.server.emit(LOG_PARSER_PLAYER_POSSESS, data);
if (data.possessClassname === 'CameraMan')
logParser.server.emit(LOG_PARSER_ADMIN_POSSESS_CAMERA, data);
}
};

View File

@ -0,0 +1,18 @@
import { LOG_PARSER_PLAYER_REVIVED } from '../../events/log-parser.js';
export default {
// the names are currently the wrong way around in these logs
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquad: (.+) has revived (.+)\./,
onMatch: async (args, logParser) => {
const data = {
...logParser.eventStore[args[3]],
raw: args[0],
time: args[1],
chainID: args[2],
victim: await logParser.server.getPlayerByName(args[5]),
reviver: await logParser.server.getPlayerByName(args[3])
};
logParser.server.emit(LOG_PARSER_PLAYER_REVIVED, data);
}
};

View File

@ -0,0 +1,25 @@
import {
LOG_PARSER_PLAYER_WOUNDED,
LOG_PARSER_TEAMKILL
} from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquadTrace: \[DedicatedServer]ASQSoldier::Wound\(\): Player:(.+) KillingDamage=(?:-)*([0-9.]+) from ([A-z_0-9]+) caused by ([A-z_0-9]+)_C/,
onMatch: async (args, logParser) => {
const data = {
...logParser.eventStore[args[3]],
raw: args[0],
time: args[1],
chainID: args[2],
victim: await logParser.server.getPlayerByName(args[3]),
damage: parseFloat(args[4]),
attackerPlayerController: args[5],
weapon: args[6]
};
logParser.eventStore[args[3]] = data;
logParser.server.emit(LOG_PARSER_PLAYER_WOUNDED, data);
if (data.teamkill) logParser.server.emit(LOG_PARSER_TEAMKILL, data);
}
};

View File

@ -0,0 +1,15 @@
import { LOG_PARSER_SERVER_TICK_RATE } from '../../events/log-parser.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquad: USQGameState: Server Tick Rate: ([0-9.]+)/,
onMatch: (args, logParser) => {
const data = {
raw: args[0],
time: args[1],
chainID: args[2],
tickRate: parseFloat(args[3])
};
logParser.server.emit(LOG_PARSER_SERVER_TICK_RATE, data);
}
};

View File

@ -0,0 +1,6 @@
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogEasyAntiCheatServer: \[[0-9:]+]\[[A-z]+]\[EAC Server] \[Info]\[RegisterClient] Client: ([A-z0-9]+) PlayerGUID: ([0-9]{17}) PlayerIP: [0-9]{17} OwnerGUID: [0-9]{17} PlayerName: (.+)/,
onMatch: async (args, logParser) => {
logParser.eventStore['steamid-connected'] = args[4];
}
};

View File

@ -0,0 +1,76 @@
import fs from 'fs';
import readLine from 'readline';
import CLIProgress from 'cli-progress';
import printLogo from 'core/utils/print-logo';
import rules from './rules/index.js';
const TEST_FILE = './squad-server/log-parser/test-data/SquadGame.log';
const EXAMPLES = 10;
async function main() {
printLogo();
const progressBar = new CLIProgress.SingleBar(
{ format: 'Coverage Test | {bar} | {value}/{total} ({percentage}%) Lines' },
CLIProgress.Presets.shades_classic
);
progressBar.start(await getTestFileLength(), 0);
let total = 0;
let matched = 0;
const unmatchedLogs = [];
const testFile = readLine.createInterface({
input: fs.createReadStream(TEST_FILE)
});
for await (const line of testFile) {
total += 1;
let matchedLine = false;
for (const rule of rules) {
if (!line.match(rule.regex)) continue;
matchedLine = true;
break;
}
if (matchedLine) matched += 1;
else if (unmatchedLogs.length <= EXAMPLES) unmatchedLogs.push(line);
progressBar.update(total);
}
progressBar.stop();
console.log('Done.');
console.log();
console.log(
`Matched ${matched} / ${total} (${(matched / total) * 100}%) log lines.`
);
console.log();
}
main();
function getTestFileLength() {
return new Promise((resolve, reject) => {
let lineCount = 0;
fs.createReadStream(TEST_FILE)
.on('data', buffer => {
let idx = -1;
lineCount--; // Because the loop will run once for idx=-1
do {
idx = buffer.indexOf(10, idx + 1);
lineCount++;
} while (idx !== -1);
})
.on('end', () => {
resolve(lineCount);
})
.on('error', reject);
});
}

23
squad-server/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "squad-server",
"version": "1.0.0",
"type": "module",
"dependencies": {
"async": "^3.2.0",
"basic-ftp": "^4.5.4",
"cli-progress": "^3.8.2",
"core": "1.0.0",
"gamedig": "^2.0.20",
"moment": "^2.24.0",
"tail": "^2.0.3"
},
"exports": {
".": "./index.js",
"./events/log-parser": "./events/log-parser.js",
"./events/rcon": "./events/rcon.js",
"./events/server": "./events/server.js",
"./log-parser": "./log-parser/index.js",
"./rcon": "./rcon/index.js",
"./plugins": "./plugins/index.js"
}
}

308
squad-server/rcon/index.js Normal file
View File

@ -0,0 +1,308 @@
import EventEmitter from 'events';
import net from 'net';
import moment from 'moment';
import RCONProtocol from './protocol.js';
import { RCON_CHAT_MESSAGE, RCON_ERROR } from '../events/rcon.js';
export default class Rcon {
constructor(options = {}, emitter) {
if (!options.host) throw new Error('Host must be specified.');
this.host = options.host;
if (!options.rconPort) throw new Error('RCON port must be specified.');
this.port = options.rconPort;
if (!options.rconPassword)
throw new Error('RCON password must be specified.');
this.password = options.rconPassword;
this.verboseEnabled = options.rconVerbose || false;
this.emitter = emitter || new EventEmitter();
this.reconnectInterval = null;
this.rconAutoReconnectInterval = options.rconAutoReconnectInterval || 5000;
this.maximumPacketSize = 4096;
this.client = null;
this.connected = false;
this.autoReconnect = true;
this.requestQueue = [];
this.currentMultiPacket = [];
this.ignoreNextEndPacket = false;
}
/* RCON functionality */
watch() {
this.verbose('Method Exec: watch()');
return this.connect();
}
unwatch() {
this.verbose('Method Exec: unwatch()');
return this.disconnect();
}
execute(command) {
this.verbose(`Method Exec: execute(${command})`);
return this.write(RCONProtocol.SERVERDATA_EXECCOMMAND, command);
}
async getMapInfo() {
const response = await this.execute('ShowNextMap');
const match = response.match(
/^Current map is ([A-z0-9 ]+), Next map is ([A-z0-9 ]*)/
);
return {
currentLayer: match[1],
nextLayer: match[2].length === 0 ? null : match[2]
};
}
async listPlayers() {
const response = await this.execute('ListPlayers');
const players = [];
for (const line of response.split('\n')) {
const match = line.match(
/ID: ([0-9]+) \| SteamID: ([0-9]{17}) \| Name: (.+) \| Team ID: ([0-9]+) \| Squad ID: ([0-9]+|N\/A)/
);
if (!match) continue;
players.push({
playerID: match[1],
steamID: match[2],
name: match[3],
teamID: match[4],
squadID: match[5] !== 'N/A' ? match[5] : null
});
}
return players;
}
/* Core socket functionality */
connect() {
this.verbose('Method Exec: connect()');
return new Promise((resolve, reject) => {
this.autoReconnect = true;
// setup socket
this.client = new net.Socket();
this.client.on('data', this.onData.bind(this));
this.client.on('error', err => {
this.verbose(`Socket Error: ${err.message}`);
this.emitter.emit(RCON_ERROR, err);
});
this.client.on('close', async hadError => {
this.verbose(`Socket Closed. AutoReconnect: ${this.autoReconnect}`);
this.connected = false;
if (!this.autoReconnect) return;
if (this.reconnectInterval !== null) return;
this.reconnectInterval = setInterval(async () => {
this.verbose('Attempting AutoReconnect.');
try {
await this.connect();
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
this.verbose('Cleaned AutoReconnect.');
} catch (err) {
this.verbose('AutoReconnect Failed.');
}
}, this.rconAutoReconnectInterval);
});
const onConnect = async () => {
this.verbose('Socket Opened.');
this.client.removeListener('error', onError);
this.connected = true;
this.verbose('Sending auth packet...');
await this.write(RCONProtocol.SERVERDATA_AUTH, this.password);
resolve();
};
const onError = err => {
this.verbose(`Error Opening Socket: ${err.message}`);
this.client.removeListener('connect', onConnect);
reject(err);
};
this.client.once('connect', onConnect);
this.client.once('error', onError);
this.client.connect(this.port, this.host);
});
}
async disconnect(disableAutoReconnect = true) {
this.verbose(`Method Exec: disconnect(${disableAutoReconnect})`);
return new Promise((resolve, reject) => {
if (disableAutoReconnect) this.autoReconnect = false;
const onClose = () => {
this.verbose('Disconnect successful.');
this.client.removeListener('error', onError);
resolve();
};
const onError = err => {
this.verbose(`Error disconnecting: ${err.message}`);
this.client.removeListener('close', onClose);
reject(err);
};
this.client.once('close', onClose);
this.client.once('error', onError);
this.client.disconnect();
});
}
write(type, body) {
return new Promise((resolve, reject) => {
if (!this.client.writable) {
reject(new Error('Unable to write to socket'));
return;
}
if (!this.connected) {
reject(new Error('Not connected.'));
return;
}
// prepare packets to send
const encodedPacket = this.encodePacket(type, RCONProtocol.ID_MID, body);
const encodedEmptyPacket = this.encodePacket(
RCONProtocol.SERVERDATA_EXECCOMMAND,
RCONProtocol.ID_END,
''
);
if (
this.maximumPacketSize > 0 &&
encodedPacket.length > this.maximumPacketSize
)
reject(new Error('Packet too long.'));
// prepare to handle response.
const handleAuthMultiPacket = async () => {
this.client.removeListener('error', reject);
for (const packet of this.currentMultiPacket) {
if (packet.type === RCONProtocol.SERVERDATA_RESPONSE_VALUE) continue;
if (packet.id !== RCONProtocol.ID_MID) {
this.verbose('Unable to authenticate.');
await this.disconnect(false);
reject(new Error('Unable to authenticate.'));
}
this.currentMultiPacket = [];
this.verbose('Authenticated.');
resolve();
}
};
const handleMultiPacket = () => {
this.client.removeListener('error', reject);
let response = '';
for (const packet of this.currentMultiPacket) {
response += packet.body;
}
this.currentMultiPacket = [];
resolve(response);
};
if (type === RCONProtocol.SERVERDATA_AUTH)
this.requestQueue.push(handleAuthMultiPacket);
else this.requestQueue.push(handleMultiPacket);
this.client.on('error', reject);
// send packets
this.client.write(encodedPacket);
this.client.write(encodedEmptyPacket);
});
}
onData(inputBuf) {
let offset = 0;
while (offset < inputBuf.byteLength) {
const endOfPacket = offset + inputBuf.readInt32LE(offset) + 4;
const packetBuf = inputBuf.slice(offset, endOfPacket);
offset = endOfPacket;
const decodedPacket = this.decodePacket(packetBuf);
if (decodedPacket.type === RCONProtocol.SERVERDATA_CHAT_VALUE) {
// emit chat messages to own event
const message = decodedPacket.body.match(
/\[(ChatAll|ChatTeam|ChatSquad|ChatAdmin)] \[SteamID:([0-9]{17})] (.+?) : (.*)/
);
this.emitter.emit(RCON_CHAT_MESSAGE, {
raw: decodedPacket.body,
chat: message[1],
steamID: message[2],
player: message[3],
message: message[4],
time: moment.utc().toDate()
});
} else if (decodedPacket.id === RCONProtocol.ID_END) {
if (this.ignoreNextEndPacket) {
this.ignoreNextEndPacket = false;
// boost the offset as the length seems wrong for this response
offset += 7;
continue;
}
this.ignoreNextEndPacket = true;
// at end of multipacket resolve request queue
const func = this.requestQueue.shift();
func();
} else {
// push packet to multipacket queue
this.currentMultiPacket.push(decodedPacket);
}
}
}
encodePacket(type, id, body, encoding = 'utf8') {
const size = Buffer.byteLength(body) + 14;
const buffer = Buffer.alloc(size);
buffer.writeInt32LE(size - 4, 0);
buffer.writeInt32LE(id, 4);
buffer.writeInt32LE(type, 8);
buffer.write(body, 12, size - 2, encoding);
buffer.writeInt16LE(0, size - 2);
return buffer;
}
decodePacket(buf) {
return {
size: buf.readInt32LE(0),
id: buf.readInt32LE(4),
type: buf.readInt32LE(8),
body: buf.toString('utf8', 12, buf.byteLength - 2)
};
}
verbose(msg) {
if (this.verboseEnabled)
console.log(`[${Date.now()}] RCON (Verbose): ${msg}`);
}
}

View File

@ -0,0 +1,12 @@
export default {
SERVERDATA_EXECCOMMAND: 0x02,
SERVERDATA_RESPONSE_VALUE: 0x00,
SERVERDATA_AUTH: 0x03,
SERVERDATA_AUTH_RESPONSE: 0x02,
SERVERDATA_CHAT_VALUE: 0x01,
ID_MID: 0x01,
ID_END: 0x02
};