Add SquadJS factory and refactor existing code

This commit is contained in:
Thomas Smyth 2020-08-20 21:21:03 +01:00
parent 9f72b3792f
commit 702a717833
73 changed files with 2417 additions and 1756 deletions

View File

@ -1,6 +1,6 @@
{
"parserOptions": {
"ecmaVersion": 2018
"ecmaVersion": 2020
},
"extends": [
"standard",

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.tmp
index-test.js
config-test.json
# Dependencies
node_modules/

View File

@ -1,4 +1,5 @@
{
"singleQuote": true,
"printWidth": 100
"printWidth": 100,
"trailingComma": "none"
}

467
README.md
View File

@ -32,21 +32,464 @@ SquadJS relies on being able to access the Squad server log directory in order t
### 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.
3. Configure the `config.json` file. See below for more details.
4. Start SquadJS: `node index.js`.
### Configuring SquadJS
SquadJS can be configured via a JSON configuration file which, by default, is located in the SquadJS and is named [config.json](https://github.com/Thomas-Smyth/SquadJS/blob/master/config.json).
#### Server
The following section of the configuration contains information about your Squad server.
```json
"server": {
"id": 1,
"host": "xxx.xxx.xxx.xxx",
"queryPort": 27165,
"rconPort": 21114,
"rconPassword": "password",
"logReaderMode": "tail",
"logDir": "C:/path/to/squad/log/folder",
"ftpPort": 21,
"ftpUser": "FTP Username",
"ftpPassword": "FTP Password",
"rconVerbose": false
},
```
* `id` - An integer ID to uniquely identify the server.
* `host` - The IP of the server.
* `queryPort` - The query port of the server.
* `rconPort` - The RCON port of the server.
* `rconPassword` - The RCON password of the server.
* `logReaderMode` - `tail` will read from a local log file. `ftp` will read from a remote log file.
* `ftpPort` - The FTP port of the server. Only required for `ftp` `logReaderMode`.
* `ftpUser` - The FTP user of the server. Only required for `ftp` `logReaderMode`.
* `ftpPassword` - The FTP password of the server. Only required for `ftp` `logReaderMode`.
* `rconVerbose` - Enable verbose logging for RCON.
#### Connectors
Connectors allow SquadJS to communicate with external resources.
```json
"connectors": {
"discord": "Discord Login Token",
},
```
Connectors should be named, for example the above is named `discord`, and should have the associated config against it. Configs can be specified by name in plugin options.
See below for more details on connectors and their associated config.
##### Discord
Connects to Discord via `discord.js`.
```json
"discord": "Discord Login Token",
```
Requires a Discord bot login token.
##### Squad Layer Filter
Connects to a filtered list of Squad layers and filters them either by an "initial filter" or an "active filter" that depends on current server information, e.g. player count.
```js
"layerFilter": {
"type": "buildFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
"whitelistedMaps": null,
"blacklistedMaps": null,
"whitelistedGamemodes": null,
"blacklistedGamemodes": [
"Training"
],
"flagCountMin": null,
"flagCountMax": null,
"hasCommander": null,
"hasTanks": null,
"hasHelicopters": null
},
"activeLayerFilter": {
"historyResetTime": 18000000,
"layerHistoryTolerance": 8,
"mapHistoryTolerance": 4,
"gamemodeHistoryTolerance": {
"Invasion": 4
},
"gamemodeRepetitiveTolerance": {
"Invasion": 4
},
"playerCountComplianceEnabled": true,
"factionComplianceEnabled": true,
"factionHistoryTolerance": {
"RUS": 4
},
"factionRepetitiveTolerance": {
"RUS": 4
}
}
},
```
* `type` - The type of filter builder to use. `filter` will depend on this type.
- `buildFromFilter` - Builds the Squad layers list from a list of filters. An example `filter` with default values for this type is show above.
- `whitelistedLayers` - List of layers to consider.
- `blacklistLayers` - List of layers to not consider.
- `whitelistedMaps` - List of maps to consider.
- `blacklistedMaps` - List of maps to not consider.
- `whitelistedGamemodes` - List of gamemodes to consider.
- `blacklistedGamemodes` - List of gamemodes to not consider.
- `flagCountMin` - Minimum number of flags the layer may have.
- `flagCountMax` - Maximum number of flags the layer may have.
- `hasCommander` - Layer must/most not have a commander. `null` for either.
- `hasTanks` - Layer must/most not have a tanks. `null` for either.
- `hasHelicopters` - Layer must/most not have a helicopters. `null` for either.
- `buildFromFile` - Builds the Squad layers list from a Squad layer config file. `filter` should be the filename of the config file.
- `buildFromList` - Builds the Squad layers list from a list of layers. `filter` should be a list of layers, e.g. `"filter": ["Sumari AAS v1", "Fool's Road AAS v1"]`.
* `filter` - Described above.
* `activeLayerFilter` - Filters layers live as server information updates, e.g. if the player count exceeds a certain amount a layer may no longer be in the filter.
- `historyResetTime` - After this number of miliseconds the layer history is no longer considered.
- `layerHistoryTolerance` - A layer can only be played again after this number of layers.
- `mapHistoryTolerance` - A map can only be played again after this number of layers.
- `gamemodeHistoryTolerance` - A gamemode can only be played again after this number of layers. Gamemodes can be specified individually inside the object. If they are not listed then the filter is not applied.
- `gamemodeRepetitiveTolerance` - A gamemode can only be played this number of times in a row. Gamemodes can be specified individually inside the object. If they are not listed then the filter is not applied.
- `playerCountComplianceEnabled` - Filter layers by player count.
- `factionComplianceEnabled` - Filter layers so that a team cannot play the same faction twice in a row.
- `factionHistoryTolerance` - A faction can only be played again after this number of layers. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
- `factionRepetitiveTolerance` - A faction can only be played this number of times in a row. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
##### MySQL
Connects to a MySQL database.
```json
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"password": "password",
"database": "squadjs"
}
```
The config is a set of pool connection options as listed in the [Node.js mysql](https://www.npmjs.com/package/mysql) documentation.
#### Plugins
The `plugins` section in your config file lists all plugins built into SquadJS, e.g.:
```json
"plugins": [
{
"plugin": "auto-tk-warn",
"disabled": false,
"message": "Please apologise for ALL TKs in ALL chat!"
}
]
```
The `disabled` field can be toggled between `true`/ `false` to enabled/disable the plugin.
Plugin options are also specified. A full list of plugin options can be seen below.
## Plugins
* [Discord Admin Broadcast](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-admin-broadcast) - Log admin broadcasts to Discord.
* [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 Chat Admin Request](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-chat-admin-request) - Log `!admin` alerts to Discord.
* [Discord Teamkill](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-teamkill) - Log teamkills to Discord.
* [Discord Server Status](https://github.com/Thomas-Smyth/SquadJS/tree/master/plugins/discord-server-status) - Add a server status embed 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.
The following is a list of plugins built into SquadJS:
### auto-tk-warn
Automatically warn players who teamkill.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>message</td><td>String</td><td>false</td><td>Please apologise for ALL TKs in ALL chat!</td><td>The message to warn players with.</td></tr>
</tbody>
</table>
### discord-admin-broadcast
Log admin broadcasts to Discord.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel to log admin broadcasts to.</td></tr>
<tr><td>color</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color of the embed.</td></tr>
</tbody>
</table>
### discord-admin-cam-logs
Log admin cam usage to Discord.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel to log admin cam usage to.</td></tr>
<tr><td>color</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color of the embed.</td></tr>
</tbody>
</table>
### discord-chat
Log in game chat to Discord.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel to log admin broadcasts to.</td></tr>
<tr><td>ignoreChats</td><td>Array</td><td>false</td><td>ChatSquad</td><td>A list of chat names to ignore.</td></tr>
<tr><td>chatColors</td><td>Object</td><td>false</td><td>[object Object]</td><td>The color of the embed for each chat. Example: `{ ChatAll: 16761867 }`.</td></tr>
<tr><td>color</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color of the embed.</td></tr>
</tbody>
</table>
### discord-admin-request
Ping admins in Discord with the in game !admin command.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel to log admin broadcasts to.</td></tr>
<tr><td>ignoreChats</td><td>Array</td><td>false</td><td>ChatSquad</td><td>A list of chat names to ignore.</td></tr>
<tr><td>ignorePhrases</td><td>Array</td><td>false</td><td></td><td>A list of phrases to ignore.</td></tr>
<tr><td>adminPrefix</td><td>String</td><td>false</td><td>!admin</td><td>The command that calls an admin.</td></tr>
<tr><td>pingGroups</td><td>Array</td><td>false</td><td></td><td>A list of Discord role IDs to ping.</td></tr>
<tr><td>pingDelay</td><td>Number</td><td>false</td><td>60000</td><td>Cooldown for pings.</td></tr>
<tr><td>color</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color of the embed.</td></tr>
</tbody>
</table>
### discord-debug
Dump SquadJS events to Discord.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel to log admin broadcasts to.</td></tr>
<tr><td>events</td><td>Array</td><td>true</td><td></td><td>A list of events to dump.</td></tr>
</tbody>
</table>
### discord-rcon
This plugin turns a Discord channel into a RCON console.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel you wish to turn into a RCON console.</td></tr>
<tr><td>prependAdminNameInBroadcast</td><td>Boolean</td><td>false</td><td>false</td><td>Prepend the admin's name when he makes an announcement.</td></tr>
</tbody>
</table>
### discord-server-status
This plugin displays server status embeds in Discord.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>color</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color code of the Discord embed.</td></tr>
<tr><td>colorGradient</td><td>Boolean</td><td>false</td><td>true</td><td>Apply gradient color to Discord embed depending on the player count.</td></tr>
<tr><td>connectLink</td><td>Boolean</td><td>false</td><td>true</td><td>Display a Steam server connection link.</td></tr>
<tr><td>command</td><td>String</td><td>false</td><td>!server</td><td>The command that displays the embed.</td></tr>
<tr><td>disableStatus</td><td>Boolean</td><td>false</td><td>false</td><td>Disable the bot status.</td></tr>
</tbody>
</table>
### discord-teamkill
Log teamkills to Discord.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>discordClient</td><td>DiscordConnector</td><td>true</td><td>discord</td><td>The name of the Discord Connector to use.</td></tr>
<tr><td>channelID</td><td>Discord Channel ID</td><td>true</td><td>Discord Channel ID</td><td>The ID of the channel to log admin broadcasts to.</td></tr>
<tr><td>teamkillColor</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color of the embed for teamkills.</td></tr>
<tr><td>suicideColor</td><td>Discord Color Code</td><td>false</td><td>16761867</td><td>The color of the embed for suicides.</td></tr>
<tr><td>ignoreSuicides</td><td>Boolean</td><td>false</td><td>false</td><td>Ignore suicides.</td></tr>
<tr><td>disableSCBL</td><td>Boolean</td><td>false</td><td>false</td><td>Disable Squad Community Ban List information.</td></tr>
</tbody>
</table>
### mapvote-123
A map voting system that uses numbers to allow players to vote on layers.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>minVoteCount</td><td>Integer</td><td>false</td><td>null</td><td>The minimum number of votes required for the vote to succeed.</td></tr>
</tbody>
</table>
### mapvote-did-you-mean
A map voting system that uses a "Did you mean?" algorithm to allow players to vote on layers.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>layerFilter</td><td>SquadLayerFilterConnector</td><td>false</td><td>layerFilter</td><td>The layers players can choose from.</td></tr>
<tr><td>alwaysOn</td><td>Boolean</td><td>false</td><td>true</td><td>If true then the map voting system will always be live.</td></tr>
<tr><td>minPlayerCount</td><td>Integer</td><td>false</td><td>null</td><td>The minimum number of players required for the vote to succeed.</td></tr>
<tr><td>minVoteCount</td><td>Integer</td><td>false</td><td>null</td><td>The minimum number of votes required for the vote to succeed.</td></tr>
</tbody>
</table>
### mysql-log
Log server information and statistics to a MySQL DB.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>mysqlPool</td><td>MySQLPoolConnector</td><td>true</td><td>mysql</td><td>The name of the MySQL Pool Connector to use.</td></tr>
<tr><td>overrideServerID</td><td>Int</td><td>false</td><td>null</td><td>A overridden server ID.</td></tr>
</tbody>
</table>
### seeding-message
Display seeding messages in admin broadcasts.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>mode</td><td>`interval` or `onjoin`</td><td>false</td><td>interval</td><td>Display seeding messages at a set interval or after players join.</td></tr>
<tr><td>interval</td><td>Number</td><td>false</td><td>150000</td><td>How frequently to display the seeding messages in seconds.</td></tr>
<tr><td>delay</td><td>Number</td><td>false</td><td>45000</td><td>How long to wait after a player joins to display the announcement in seconds.</td></tr>
<tr><td>seedingThreshold</td><td>Number</td><td>false</td><td>50</td><td>Number of players before the server is considered live.</td></tr>
<tr><td>seedingMessage</td><td>String</td><td>false</td><td>Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!</td><td>The seeding message to display.</td></tr>
<tr><td>liveEnabled</td><td>String</td><td>false</td><td>true</td><td>Display a "Live" message when a certain player count is met.</td></tr>
<tr><td>liveThreshold</td><td>Number</td><td>false</td><td>2</td><td>When above the seeding threshold, but within this number "Live" messages are displayed.</td></tr>
<tr><td>liveMessage</td><td>String</td><td>false</td><td>Live</td><td>The "Live" message to display.</td></tr>
</tbody>
</table>
### team-randomizer
Randomize teams with an admin command.
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td>command</td><td>String</td><td>false</td><td>!randomize</td><td>The command used to randomize the teams.</td></tr>
</tbody>
</table>
## Creating Your Own Plugins
To create your own plugin you need a basic knowledge of JavaScript.

179
config.json Normal file
View File

@ -0,0 +1,179 @@
{
"server": {
"id": 1,
"host": "xxx.xxx.xxx.xxx",
"queryPort": 27165,
"rconPort": 21114,
"rconPassword": "password",
"logReaderMode": "tail",
"logDir": "C:/path/to/squad/log/folder",
"ftpPort": 21,
"ftpUser": "FTP Username",
"ftpPassword": "FTP Password",
"rconVerbose": false
},
"connectors": {
"discord": "Discord Login Token",
"layerFilter": {
"type": "buildFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
"whitelistedMaps": null,
"blacklistedMaps": null,
"whitelistedGamemodes": null,
"blacklistedGamemodes": [
"Training"
],
"flagCountMin": null,
"flagCountMax": null,
"hasCommander": null,
"hasTanks": null,
"hasHelicopters": null
},
"activeLayerFilter": {
"historyResetTime": 18000000,
"layerHistoryTolerance": 8,
"mapHistoryTolerance": 4,
"gamemodeHistoryTolerance": {
"Invasion": 4
},
"gamemodeRepetitiveTolerance": {
"Invasion": 4
},
"playerCountComplianceEnabled": true,
"factionComplianceEnabled": true,
"factionHistoryTolerance": {
"RUS": 4
},
"factionRepetitiveTolerance": {
"RUS": 4
}
}
},
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"password": "password",
"database": "squadjs"
}
},
"plugins": [
{
"plugin": "auto-tk-warn",
"disabled": false,
"message": "Please apologise for ALL TKs in ALL chat!"
},
{
"plugin": "discord-admin-broadcast",
"disabled": false,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"color": 16761867
},
{
"plugin": "discord-admin-cam-logs",
"disabled": false,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"color": 16761867
},
{
"plugin": "discord-chat",
"disabled": false,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"ignoreChats": [
"ChatSquad"
],
"chatColors": {},
"color": 16761867
},
{
"plugin": "discord-admin-request",
"disabled": false,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"ignoreChats": [
"ChatSquad"
],
"ignorePhrases": [],
"adminPrefix": "!admin",
"pingGroups": [],
"pingDelay": 60000,
"color": 16761867
},
{
"plugin": "discord-debug",
"disabled": true,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"events": []
},
{
"plugin": "discord-rcon",
"disabled": false,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"prependAdminNameInBroadcast": false
},
{
"plugin": "discord-server-status",
"disabled": false,
"discordClient": "discord",
"color": 16761867,
"colorGradient": true,
"connectLink": true,
"command": "!server",
"disableStatus": false
},
{
"plugin": "discord-teamkill",
"disabled": false,
"discordClient": "discord",
"channelID": "Discord Channel ID",
"teamkillColor": 16761867,
"suicideColor": 16761867,
"ignoreSuicides": false,
"disableSCBL": false
},
{
"plugin": "mapvote-123",
"disabled": true,
"minVoteCount": null
},
{
"plugin": "mapvote-did-you-mean",
"disabled": true,
"layerFilter": "layerFilter",
"alwaysOn": true,
"minPlayerCount": null,
"minVoteCount": null
},
{
"plugin": "mysql-log",
"disabled": true,
"mysqlPool": "mysql",
"overrideServerID": null
},
{
"plugin": "seeding-message",
"disabled": false,
"mode": "interval",
"interval": 150000,
"delay": 45000,
"seedingThreshold": 50,
"seedingMessage": "Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!",
"liveEnabled": true,
"liveThreshold": 2,
"liveMessage": "Live"
},
{
"plugin": "team-randomizer",
"disabled": false,
"command": "!randomize"
}
]
}

View File

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

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -1,4 +0,0 @@
/* 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 };

14
core/constants.js Normal file
View File

@ -0,0 +1,14 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SQUADJS_VERSION = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
).version;
/* As set out by the terms of the license, the following should not be modified. */
const COPYRIGHT_MESSAGE = 'SquadJS, Copyright © 2020 Thomas Smyth';
export { SQUADJS_VERSION, COPYRIGHT_MESSAGE };

View File

@ -3,8 +3,16 @@
"version": "1.0.0",
"type": "module",
"exports": {
"./config": "./config.js",
"./squad-layers": "./squad-layers/index.js",
"./utils/print-logo": "./utils/print-logo.js",
"./utils/sleep": "./utils/sleep.js"
"./utils/scbl": "./utils/scbl.js",
"./utils/sleep": "./utils/sleep.js",
"./constants": "./constants.js"
},
"dependencies": {
"didyoumean": "^1.2.1",
"graphql-request": "^1.8.2"
}
}

View File

@ -0,0 +1,4 @@
import SquadLayers from './squad-layers.js';
import SquadLayerFilter from './squad-layer-filter.js';
export { SquadLayers, SquadLayerFilter };

View File

@ -48,8 +48,8 @@ export default class SquadLayerFilter extends SquadLayersClass {
return new SquadLayerFilter(layers, activeLayerFilter);
}
static buildFromFile(filename, activeLayerFilter, delimiter = '\n') {
const lines = fs.readFileSync('./connectors/data/layers.json', 'utf8').split(delimiter);
static buildFromFile(path, activeLayerFilter, delimiter = '\n') {
const lines = fs.readFileSync(path, 'utf8').split(delimiter);
const layers = [];
const validLayerNames = SquadLayers.getLayerNames();

View File

@ -1,13 +1,17 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import didYouMean from 'didyoumean';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
class SquadLayers {
constructor(layers) {
if (Array.isArray(layers)) {
this.layers = layers;
} else {
this.layers = JSON.parse(fs.readFileSync('./connectors/data/layers.json', 'utf8'));
this.layers = JSON.parse(fs.readFileSync(path.resolve(__dirname, './layers.json'), 'utf8'));
}
for (let i = 0; i < this.layers.length; i++) {
@ -23,28 +27,28 @@ class SquadLayers {
}
getLayerNames() {
return this.layers.map(layer => layer.layer);
return this.layers.map((layer) => layer.layer);
}
getLayerByLayerName(layerName) {
const layer = this.layers.filter(layer => layer.layer === 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);
const layer = this.layers.filter((layer) => layer.layerClassname === layerClassname);
return layer.length === 1 ? layer[0] : null;
}
getLayerByDidYouMean(layerName) {
layerName = didYouMean(layerName, this.getLayerNames());
const layer = this.layers.filter(layer => layer.layer === layerName);
const layer = this.layers.filter((layer) => layer.layer === layerName);
return layer.length === 1 ? layer[0] : null;
}
getLayerByNumber(number) {
const layer = this.layers.filter(layer => layer.layerNumber === number);
const layer = this.layers.filter((layer) => layer.layerNumber === number);
return layer.length === 1 ? layer[0] : null;
}
}

View File

@ -1,4 +1,4 @@
import { COPYRIGHT_MESSAGE } from '../config.js';
import { SQUADJS_VERSION, COPYRIGHT_MESSAGE } from '../constants.js';
const LOGO = `
_____ ____ _ _ _____ _
@ -11,8 +11,9 @@ const LOGO = `
|__/
${COPYRIGHT_MESSAGE}
GitHub: https://github.com/Thomas-Smyth/SquadJS
Version: ${SQUADJS_VERSION}
`;
export default function() {
export default function () {
console.log(LOGO);
}

View File

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

View File

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

27
factory/build-config.js Normal file
View File

@ -0,0 +1,27 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import plugins from 'plugins';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const template = JSON.parse(
fs.readFileSync(path.resolve(__dirname, './templates/config-template.json'), 'utf8')
);
const pluginKeys = Object.keys(plugins).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
for (const pluginKey of pluginKeys) {
const plugin = plugins[pluginKey];
const pluginConfig = { plugin: plugin.name, disabled: plugin.defaultDisabled };
for (const option in plugin.optionsSpec) {
pluginConfig[option] = plugin.optionsSpec[option].default;
}
template.plugins.push(pluginConfig);
}
fs.writeFileSync(path.resolve(__dirname, '../config.json'), JSON.stringify(template, null, 2));

View File

@ -0,0 +1,46 @@
import Discord from 'discord.js';
import mysql from 'mysql';
import { SquadLayerFilter } from 'core/squad-layers';
import plugins from 'plugins';
const connectorTypes = {
DiscordConnector: async function (config) {
const client = new Discord.Client();
await client.login(config);
return client;
},
MySQLPoolConnector: async function (config) {
return mysql.createPool(config);
},
SquadLayerFilterConnector: async function (config) {
return SquadLayerFilter[config.type](config.filter, config.activeLayerFilter);
}
};
export default async function (config) {
const connectors = {};
for (const pluginConfig of config.plugins) {
if (pluginConfig.disabled) continue;
const plugin = plugins[pluginConfig.plugin];
for (const optionName of Object.keys(plugin.optionsSpec)) {
const option = plugin.optionsSpec[optionName];
if (!Object.keys(connectorTypes).includes(option.type)) continue;
if (!connectorTypes[option.type]) throw new Error('Connector type not supported!');
if (connectors[pluginConfig[optionName]]) continue;
if (!config.connectors[pluginConfig[optionName]])
throw new Error(`${pluginConfig[optionName]} connector config not present!`);
connectors[pluginConfig[optionName]] = await connectorTypes[option.type](
config.connectors[pluginConfig[optionName]]
);
}
}
return connectors;
}

54
factory/build-readme.js Normal file
View File

@ -0,0 +1,54 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import plugins from 'plugins';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pluginNames = Object.keys(plugins);
const sortedPluginNames = pluginNames.sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
const pluginInfo = [];
for (const pluginName of sortedPluginNames) {
const plugin = plugins[pluginName];
const optionTable = [];
for (const optionName of Object.keys(plugin.optionsSpec)) {
const option = plugin.optionsSpec[optionName];
optionTable.push(
`<tr><td>${optionName}</td><td>${option.type}</td><td>${option.required}</td><td>${option.default}</td><td>${option.description}</td></tr>`
);
}
pluginInfo.push(
`### ${plugin.name}
${plugin.description}
#### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${optionTable.join('\n')}
</tbody>
</table>`
);
}
const pluginInfoText = pluginInfo.join('\n\n');
fs.writeFileSync(
path.resolve(__dirname, '../README.md'),
fs
.readFileSync(path.resolve(__dirname, './templates/readme-template.md'), 'utf8')
.replace(/\/\/PLUGIN-INFO\/\//, pluginInfoText)
);

View File

@ -0,0 +1,5 @@
import SquadServer from 'squad-server';
export default function (config) {
return new SquadServer(config.server);
}

48
factory/index.js Normal file
View File

@ -0,0 +1,48 @@
import readConfig from './read-config.js';
import buildSquadServer from './build-squad-server.js';
import buildConnectors from './build-connectors.js';
import plugins from 'plugins';
export default async function (configPath) {
console.log('SquadJS factory commencing building...');
console.log('Getting config file...');
const config = readConfig(configPath);
console.log('Building Squad server...');
const server = buildSquadServer(config);
console.log('Initialising connectors...');
const connectors = await buildConnectors(config);
console.log('Loading plugins...');
for (const pluginConfig of config.plugins) {
if (pluginConfig.disabled) continue;
console.log(`Loading plugin ${pluginConfig.plugin}...`);
const plugin = plugins[pluginConfig.plugin];
const options = {};
for (const optionName of Object.keys(plugin.optionsSpec)) {
const option = plugin.optionsSpec[optionName];
if (option.type.match(/Connector$/)) {
options[optionName] = connectors[pluginConfig[optionName]];
} else {
if (option.required) {
if (!(optionName in pluginConfig))
throw new Error(`${plugin.name}: ${optionName} is required but missing.`);
if (option.default === pluginConfig[optionName])
throw new Error(`${plugin.name}: ${optionName} is required but is the default value.`);
}
options[optionName] = pluginConfig[optionName] || option.default;
}
}
await plugin.init(server, options);
}
return server;
}

13
factory/package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "factory",
"version": "1.0.0",
"type": "module",
"dependencies": {
"core": "1.0.0",
"discord.js": "^12.2.0",
"mysql": "^2.18.1"
},
"exports": {
".": "./index.js"
}
}

20
factory/read-config.js Normal file
View File

@ -0,0 +1,20 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default function (configPath = './config.json') {
const fullConfigPath = path.resolve(__dirname, '../', configPath);
if (!fs.existsSync(fullConfigPath)) throw new Error('Config file does not exist.');
const unparsedConfig = fs.readFileSync(fullConfigPath, 'utf8');
let parsedConfig;
try {
parsedConfig = JSON.parse(unparsedConfig);
} catch (err) {
throw new Error('Unable to parse config file.');
}
return parsedConfig;
}

View File

@ -0,0 +1,66 @@
{
"server": {
"id": 1,
"host": "xxx.xxx.xxx.xxx",
"queryPort": 27165,
"rconPort": 21114,
"rconPassword": "password",
"logReaderMode": "tail",
"logDir": "C:/path/to/squad/log/folder",
"ftpPort": 21,
"ftpUser": "FTP Username",
"ftpPassword": "FTP Password",
"rconVerbose": false
},
"connectors": {
"discord": "Discord Login Token",
"layerFilter": {
"type": "buildFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
"whitelistedMaps": null,
"blacklistedMaps": null,
"whitelistedGamemodes": null,
"blacklistedGamemodes": ["Training"],
"flagCountMin": null,
"flagCountMax": null,
"hasCommander": null,
"hasTanks": null,
"hasHelicopters": null
},
"activeLayerFilter": {
"historyResetTime": 18000000,
"layerHistoryTolerance": 8,
"mapHistoryTolerance": 4,
"gamemodeHistoryTolerance": {
"Invasion": 4
},
"gamemodeRepetitiveTolerance": {
"Invasion": 4
},
"playerCountComplianceEnabled": true,
"factionComplianceEnabled": true,
"factionHistoryTolerance": {
"RUS": 4
},
"factionRepetitiveTolerance": {
"RUS": 4
}
}
},
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"password": "password",
"database": "squadjs"
}
},
"plugins": []
}

View File

@ -0,0 +1,284 @@
<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/9F2Ng5C)
<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 `config.json` file. See below for more details.
4. Start SquadJS: `node index.js`.
### Configuring SquadJS
SquadJS can be configured via a JSON configuration file which, by default, is located in the SquadJS and is named [config.json](https://github.com/Thomas-Smyth/SquadJS/blob/master/config.json).
#### Server
The following section of the configuration contains information about your Squad server.
```json
"server": {
"id": 1,
"host": "xxx.xxx.xxx.xxx",
"queryPort": 27165,
"rconPort": 21114,
"rconPassword": "password",
"logReaderMode": "tail",
"logDir": "C:/path/to/squad/log/folder",
"ftpPort": 21,
"ftpUser": "FTP Username",
"ftpPassword": "FTP Password",
"rconVerbose": false
},
```
* `id` - An integer ID to uniquely identify the server.
* `host` - The IP of the server.
* `queryPort` - The query port of the server.
* `rconPort` - The RCON port of the server.
* `rconPassword` - The RCON password of the server.
* `logReaderMode` - `tail` will read from a local log file. `ftp` will read from a remote log file.
* `ftpPort` - The FTP port of the server. Only required for `ftp` `logReaderMode`.
* `ftpUser` - The FTP user of the server. Only required for `ftp` `logReaderMode`.
* `ftpPassword` - The FTP password of the server. Only required for `ftp` `logReaderMode`.
* `rconVerbose` - Enable verbose logging for RCON.
#### Connectors
Connectors allow SquadJS to communicate with external resources.
```json
"connectors": {
"discord": "Discord Login Token",
},
```
Connectors should be named, for example the above is named `discord`, and should have the associated config against it. Configs can be specified by name in plugin options.
See below for more details on connectors and their associated config.
##### Discord
Connects to Discord via `discord.js`.
```json
"discord": "Discord Login Token",
```
Requires a Discord bot login token.
##### Squad Layer Filter
Connects to a filtered list of Squad layers and filters them either by an "initial filter" or an "active filter" that depends on current server information, e.g. player count.
```js
"layerFilter": {
"type": "buildFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
"whitelistedMaps": null,
"blacklistedMaps": null,
"whitelistedGamemodes": null,
"blacklistedGamemodes": [
"Training"
],
"flagCountMin": null,
"flagCountMax": null,
"hasCommander": null,
"hasTanks": null,
"hasHelicopters": null
},
"activeLayerFilter": {
"historyResetTime": 18000000,
"layerHistoryTolerance": 8,
"mapHistoryTolerance": 4,
"gamemodeHistoryTolerance": {
"Invasion": 4
},
"gamemodeRepetitiveTolerance": {
"Invasion": 4
},
"playerCountComplianceEnabled": true,
"factionComplianceEnabled": true,
"factionHistoryTolerance": {
"RUS": 4
},
"factionRepetitiveTolerance": {
"RUS": 4
}
}
},
```
* `type` - The type of filter builder to use. `filter` will depend on this type.
- `buildFromFilter` - Builds the Squad layers list from a list of filters. An example `filter` with default values for this type is show above.
- `whitelistedLayers` - List of layers to consider.
- `blacklistLayers` - List of layers to not consider.
- `whitelistedMaps` - List of maps to consider.
- `blacklistedMaps` - List of maps to not consider.
- `whitelistedGamemodes` - List of gamemodes to consider.
- `blacklistedGamemodes` - List of gamemodes to not consider.
- `flagCountMin` - Minimum number of flags the layer may have.
- `flagCountMax` - Maximum number of flags the layer may have.
- `hasCommander` - Layer must/most not have a commander. `null` for either.
- `hasTanks` - Layer must/most not have a tanks. `null` for either.
- `hasHelicopters` - Layer must/most not have a helicopters. `null` for either.
- `buildFromFile` - Builds the Squad layers list from a Squad layer config file. `filter` should be the filename of the config file.
- `buildFromList` - Builds the Squad layers list from a list of layers. `filter` should be a list of layers, e.g. `"filter": ["Sumari AAS v1", "Fool's Road AAS v1"]`.
* `filter` - Described above.
* `activeLayerFilter` - Filters layers live as server information updates, e.g. if the player count exceeds a certain amount a layer may no longer be in the filter.
- `historyResetTime` - After this number of miliseconds the layer history is no longer considered.
- `layerHistoryTolerance` - A layer can only be played again after this number of layers.
- `mapHistoryTolerance` - A map can only be played again after this number of layers.
- `gamemodeHistoryTolerance` - A gamemode can only be played again after this number of layers. Gamemodes can be specified individually inside the object. If they are not listed then the filter is not applied.
- `gamemodeRepetitiveTolerance` - A gamemode can only be played this number of times in a row. Gamemodes can be specified individually inside the object. If they are not listed then the filter is not applied.
- `playerCountComplianceEnabled` - Filter layers by player count.
- `factionComplianceEnabled` - Filter layers so that a team cannot play the same faction twice in a row.
- `factionHistoryTolerance` - A faction can only be played again after this number of layers. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
- `factionRepetitiveTolerance` - A faction can only be played this number of times in a row. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
##### MySQL
Connects to a MySQL database.
```json
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"password": "password",
"database": "squadjs"
}
```
The config is a set of pool connection options as listed in the [Node.js mysql](https://www.npmjs.com/package/mysql) documentation.
#### Plugins
The `plugins` section in your config file lists all plugins built into SquadJS, e.g.:
```json
"plugins": [
{
"plugin": "auto-tk-warn",
"disabled": false,
"message": "Please apologise for ALL TKs in ALL chat!"
}
]
```
The `disabled` field can be toggled between `true`/ `false` to enabled/disable the plugin.
Plugin options are also specified. A full list of plugin options can be seen below.
## Plugins
The following is a list of plugins built into SquadJS:
//PLUGIN-INFO//
## 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 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.
```

View File

@ -1,87 +1,7 @@
import Discord from 'discord.js';
import mysql from 'mysql';
import Influx from 'influx';
import printLogo from 'core/utils/print-logo';
import buildSquadJS from 'factory';
import Server from 'squad-server';
import SquadLayerFilter from 'connectors/squad-layer-filter';
import {
discordAdminBroadcast,
discordAdminCamLogs,
discordChat,
discordChatAdminRequest,
discordRCON,
discordServerStatus,
discordTeamkill,
influxdbLog,
influxdbLogDefaultSchema,
mapvote,
mysqlLog,
teamRandomizer,
seedingMessage
} from 'plugins';
async function main() {
const server = new Server({
id: 1,
host: 'xxx.xxx.xxx.xxx',
queryPort: 27165,
rconPort: 21114,
rconPassword: 'password',
// Uncomment the following lines to read logs over FTP.
// ftpPort: 21,
// ftpUser: 'FTP Username',
// ftpPassword: 'FTP Password',
// logReaderMode: 'ftp',
logDir: 'C:/path/to/squad/log/folder'
});
// Discord Plugins
const discordClient = new Discord.Client();
await discordClient.login('Discord Login Token');
await discordAdminBroadcast(server, discordClient, 'Discord Channel ID');
await discordAdminCamLogs(server, discordClient, 'Discord Channel ID');
await discordChat(server, discordClient, 'Discord Channel ID');
await discordChatAdminRequest(server, discordClient, 'Discord Channel ID', {
pingGroups: ['discordGroupID']
});
await discordRCON(server, discordClient, 'Discord Channel ID');
await discordServerStatus(server, discordClient);
await discordTeamkill(server, discordClient, 'Discord Channel ID');
// in game features
const squadLayerFilter = SquadLayerFilter.buildFromFilter({});
mapvote(server, 'didyoumean', squadLayerFilter, {});
teamRandomizer(server);
seedingMessage(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();
printLogo();
buildSquadJS('./config-test.json')
.then((server) => server.watch())
.catch(console.log);

View File

@ -1,40 +1,38 @@
{
"name": "SquadJS",
"version": "1.2.1",
"version": "1.3.0-beta1",
"repository": "https://github.com/Thomas-Smyth/SquadJS.git",
"author": "Thomas Smyth <https://github.com/Thomas-Smyth>",
"license": "BSL-1.0",
"private": true,
"workspaces": [
"assets",
"connectors",
"core",
"factory",
"plugins",
"squad-server"
],
"scripts": {
"lint": "eslint --fix . && prettier --write \"./**/*.js\"",
"test-log-parser-coverage": "node squad-server/log-parser/test-coverage.js"
"build-config": "node factory/build-config.js",
"build-readme": "node factory/build-readme.js",
"build-all": "node factory/build-config.js && node factory/build-readme.js"
},
"type": "module",
"dependencies": {
"connectors": "1.0.0",
"discord.js": "^12.2.0",
"influx": "^5.5.1",
"mysql": "^2.18.1",
"plugins": "1.0.0",
"squad-server": "1.0.0"
"core": "1.0.0",
"factory": "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": "^7.6.0",
"eslint-config-prettier": "^6.11.0",
"eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-node": "^11.1.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"
"husky": "^4.2.5",
"lint-staged": "^10.2.11",
"prettier": "^2.0.5"
}
}

View File

@ -1,23 +0,0 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Auto Teamkill Warning
</div>
## About
Automatically sends a warning to players who teamkill to remind them to apologise in all chat.
## Installation
```js
// Place the following line at the top of your index.js file.
import { autoTKWarn } from 'plugins';
// Place the following lines after all of the above.
await autoTKWarn(
server,
{ // options - the options included below display the defaults and can be removed for simplicity.
message: 'Please apologise for ALL TKs in ALL chat!' // warning to send
}
);
```

View File

@ -1,15 +1,24 @@
import { LOG_PARSER_TEAMKILL } from 'squad-server/events/log-parser';
export default async function(server, options = {}) {
if (!server)
throw new Error('DiscordAdminCamLogs must be provided with a reference to the server.');
export default {
name: 'auto-tk-warn',
description: 'Automatically warn players who teamkill.',
defaultDisabled: false,
server.on(LOG_PARSER_TEAMKILL, info => {
// ignore suicides
if (info.attacker.steamID === info.victim.steamID) return;
server.rcon.execute(
`AdminWarn "${info.attacker.steamID}" ${options.message ||
'Please apologise for ALL TKs in ALL chat!'}`
);
});
}
optionsSpec: {
message: {
type: 'String',
required: false,
default: 'Please apologise for ALL TKs in ALL chat!',
description: 'The message to warn players with.'
}
},
init: async (server, connectors, options) => {
server.on(LOG_PARSER_TEAMKILL, (info) => {
// ignore suicides
if (info.attacker.steamID === info.victim.steamID) return;
server.rcon.execute(`AdminWarn "${info.attacker.steamID}" ${options.message}`);
});
}
};

View File

@ -1,30 +0,0 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Admin Broadcast Plugin
</div>
## About
The Discord Admin Broadcast plugin streams admin broadcasts logs to Discord.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordAdminBroadcast } 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 discordAdminBroadcast(
server,
discordClient,
'discordChannelID',
{ // options - the options included below display the defaults and can be removed for simplicity.
color: 16761867 // color of embed
}
);
```

View File

@ -1,36 +1,52 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { LOG_PARSER_ADMIN_BROADCAST } from 'squad-server/events/log-parser';
export default async function(server, discordClient, channelID, options = {}) {
if (!server) throw new Error('DiscordChat must be provided with a reference to the server.');
export default {
name: 'discord-admin-broadcast',
description: 'Log admin broadcasts to Discord.',
defaultDisabled: false,
if (!discordClient) throw new Error('DiscordChat must be provided with a Discord.js client.');
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel to log admin broadcasts to.'
},
color: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color of the embed.'
}
},
if (!channelID) throw new Error('DiscordChat must be provided with a channel ID.');
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
options = {
color: 16761867,
...options
};
const channel = await discordClient.channels.fetch(channelID);
server.on(LOG_PARSER_ADMIN_BROADCAST, async info => {
channel.send({
embed: {
title: 'Admin Broadcast',
color: options.color,
fields: [
{
name: 'Message',
value: `${info.message}`
server.on(LOG_PARSER_ADMIN_BROADCAST, async (info) => {
channel.send({
embed: {
title: 'Admin Broadcast',
color: options.color,
fields: [
{
name: 'Message',
value: `${info.message}`
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
});
}
}
};

View File

@ -1,30 +0,0 @@
<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

@ -1,88 +1,102 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import {
LOG_PARSER_PLAYER_POSSESS,
LOG_PARSER_PLAYER_UNPOSSESS
} from 'squad-server/events/log-parser';
export default async function(server, discordClient, channelID, options = {}) {
if (!server)
throw new Error('DiscordAdminCamLogs must be provided with a reference to the server.');
export default {
name: 'discord-admin-cam-logs',
description: 'Log admin cam usage to Discord.',
defaultDisabled: false,
if (!discordClient)
throw new Error('DiscordAdminCamLogs must be provided with a Discord.js client.');
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel to log admin cam usage to.'
},
color: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color of the embed.'
}
},
if (!channelID) throw new Error('DiscordAdminCamLogs must be provided with a channel ID.');
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
options = {
color: 16761867,
...options
};
const adminsInCam = {};
const channel = await discordClient.channels.fetch(channelID);
server.on(LOG_PARSER_PLAYER_POSSESS, (info) => {
if (info.player === null || info.possessClassname !== 'CameraMan') return;
const adminsInCam = {};
adminsInCam[info.player.steamID] = info.time;
server.on(LOG_PARSER_PLAYER_POSSESS, info => {
if (info.player === null || info.possessClassname !== 'CameraMan') return;
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
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
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
server.on(LOG_PARSER_PLAYER_UNPOSSESS, info => {
if (info.switchPossess === true || !(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];
});
}
server.on(LOG_PARSER_PLAYER_UNPOSSESS, (info) => {
if (info.switchPossess === true || !(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,131 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
export default {
name: 'discord-admin-request',
description: 'Ping admins in Discord with the in game !admin command.',
defaultDisabled: false,
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel to log admin broadcasts to.'
},
ignoreChats: {
type: 'Array',
required: false,
default: ['ChatSquad'],
description: 'A list of chat names to ignore.'
},
ignorePhrases: {
type: 'Array',
required: false,
default: [],
description: 'A list of phrases to ignore.'
},
adminPrefix: {
type: 'String',
required: false,
default: '!admin',
description: 'The command that calls an admin.'
},
pingGroups: {
type: 'Array',
required: false,
default: [],
description: 'A list of Discord role IDs to ping.'
},
pingDelay: {
type: 'Number',
required: false,
default: 60 * 1000,
description: 'Cooldown for pings.'
},
color: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color of the embed.'
}
},
init: async (server, options) => {
let lastPing = null;
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(RCON_CHAT_MESSAGE, async (info) => {
if (options.ignoreChats.includes(info.chat)) return;
if (!info.message.startsWith(`${options.adminPrefix}`)) return;
for (const ignorePhrase of options.ignorePhrases) {
if (info.message.includes(ignorePhrase)) return;
}
const playerInfo = await server.getPlayerBySteamID(info.steamID);
const trimmedMessage = info.message.replace(options.adminPrefix, '').trim();
if (trimmedMessage.length === 0) {
await server.rcon.warn(
info.steamID,
`Please specify what you would like help with when requesting an admin.`
);
return;
}
const message = {
embed: {
title: `${playerInfo.name} has requested admin support!`,
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: trimmedMessage
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
};
if (
options.pingGroups.length > 0 &&
(lastPing === null || Date.now() - options.pingDelay > lastPing)
) {
message.content = options.pingGroups.map((groupID) => `<@&${groupID}>`).join(' ');
lastPing = Date.now();
}
channel.send(message);
await server.rcon.warn(
info.steamID,
`An admin has been notified, please wait for us to get back to you.`
);
});
}
};

View File

@ -1,38 +0,0 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord Chat Admin Request Plugin
</div>
## About
The Discord Chat Admin Request plugin allows players to ping for an admin in discord. 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 { discordChatAdminRequest } 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 discordChatAdminRequest(
server,
discordClient,
'discordChannelID',
{ // options - the options included below display the defaults and can be removed for simplicity.
adminPrefix: '!admin', // prefix for an admin request.
pingGroups: ['729853701308678154'], // Groups to ping on a request, leave empty for no ping.
pingDelay: 60 * 1000, // number of ms between pings. other messages will still be logged just without pings.
ignoreChats: ['ChatSquad', 'ChatAdmin'], // an array of chats to not display.
ignorePhrases: [], // add any phrases you do not want pings for, e.g. "switch me".
color: '#f44336' // color of embed
}
);
```

View File

@ -1,90 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
export default async function(server, discordClient, channelID, options = {}) {
if (!server)
throw new Error('DiscordChatAdminRequest must be provided with a reference to the server.');
if (!discordClient)
throw new Error('DiscordChatAdminRequest must be provided with a Discord.js client.');
if (!channelID) throw new Error('DiscordChatAdminRequest must be provided with a channel ID.');
options = {
color: 16761867,
ignoreChats: [],
ignorePhrases: [],
adminPrefix: '!admin',
pingGroups: [],
pingDelay: 60 * 1000,
...options
};
let lastPing = null;
const channel = await discordClient.channels.fetch(channelID);
server.on(RCON_CHAT_MESSAGE, async info => {
if (options.ignoreChats.includes(info.chat)) return;
if (!info.message.startsWith(`${options.adminPrefix}`)) return;
for (const ignorePhrase of options.ignorePhrases) {
if (info.message.includes(ignorePhrase)) return;
}
const playerInfo = await server.getPlayerBySteamID(info.steamID);
const trimmedMessage = info.message.replace(options.adminPrefix, '').trim();
if (trimmedMessage.length === 0) {
await server.rcon.warn(
info.steamID,
`Please specify what you would like help with when requesting an admin.`
);
return;
}
const message = {
embed: {
title: `${playerInfo.name} has requested admin support!`,
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: trimmedMessage
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
};
if (
options.pingGroups.length > 0 &&
(lastPing === null || Date.now() - options.pingDelay > lastPing)
) {
message.content = options.pingGroups.map(groupID => `<@&${groupID}>`).join(' ');
lastPing = Date.now();
}
channel.send(message);
await server.rcon.warn(
info.steamID,
`An admin has been notified, please wait for us to get back to you.`
);
});
}

View File

@ -1,32 +0,0 @@
<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
chatColors: { 'ChatAll': 16761867 } // change the color of chat types individually. Defaults to color above if not specified.
}
);
```

View File

@ -1,59 +1,82 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
export default async function(server, discordClient, channelID, options = {}) {
if (!server) throw new Error('DiscordChat must be provided with a reference to the server.');
export default {
name: 'discord-chat',
description: 'Log in game chat to Discord.',
defaultDisabled: false,
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 = {
chatColors: {
...options.chatColors
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
color: 16761867,
...options
};
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel to log admin broadcasts to.'
},
ignoreChats: {
type: 'Array',
required: false,
default: ['ChatSquad'],
description: 'A list of chat names to ignore.'
},
chatColors: {
type: 'Object',
required: false,
default: {},
description: 'The color of the embed for each chat. Example: `{ ChatAll: 16761867 }`.'
},
color: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color of the embed.'
}
},
const channel = await discordClient.channels.fetch(channelID);
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(RCON_CHAT_MESSAGE, async info => {
if (ignoreChats.includes(info.chat)) return;
server.on(RCON_CHAT_MESSAGE, async (info) => {
if (options.ignoreChats.includes(info.chat)) return;
const playerInfo = await server.getPlayerBySteamID(info.steamID);
const playerInfo = await server.getPlayerBySteamID(info.steamID);
channel.send({
embed: {
title: info.chat,
color: options.chatColors[info.chat] || 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}`
channel.send({
embed: {
title: info.chat,
color: options.chatColors[info.chat] || 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
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
});
}
}
};

View File

@ -1,34 +0,0 @@
<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

@ -1,15 +1,36 @@
export default async function(server, discordClient, channelID, events = []) {
if (!server) throw new Error('DiscordDebug must be provided with a reference to the server.');
export default {
name: 'discord-debug',
description: 'Dump SquadJS events to Discord.',
defaultDisabled: true,
if (!discordClient) throw new Error('DiscordDebug must be provided with a Discord.js client.');
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel to log admin broadcasts to.'
},
events: {
type: 'Array',
required: true,
default: [],
description: 'A list of events to dump.'
}
},
if (!channelID) throw new Error('DicordDebug must be provided with a channel ID.');
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
const channel = await discordClient.channels.fetch(channelID);
for (const event of events) {
server.on(event, info => {
channel.send(`\`\`\`${JSON.stringify(info, null, 2)}\`\`\``);
});
for (const event of options.events) {
server.on(event, (info) => {
channel.send(`\`\`\`${JSON.stringify(info, null, 2)}\`\`\``);
});
}
}
}
};

View File

@ -1,30 +0,0 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Discord RCON
</div>
## About
The Discord RCON plugin allows you to run RCON commands through a Discord channel.
## Installation
```js
// Place the following two lines at the top of your index.js file.
import Discord from 'discord.js';
import { discordRCON } 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 discordRCON(
server,
discordClient,
'discordChannelID',
{ // options - the options included below display the defaults and can be removed for simplicity.
prependAdminNameInBroadcast: false // prepend admin names to broadcasts
}
);
```

View File

@ -1,37 +1,58 @@
export default async function(server, discordClient, channelID, options = {}) {
if (!server) throw new Error('DiscordRCON must be provided with a reference to the server.');
if (!discordClient) throw new Error('DiscordRCON must be provided with a Discord.js client.');
if (!channelID) throw new Error('DiscordRCON must be provided with a channel ID.');
export default {
name: 'discord-rcon',
description: 'This plugin turns a Discord channel into a RCON console.',
defaultDisabled: false,
options = {
prependAdminNameInBroadcast: false,
...options
};
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel you wish to turn into a RCON console.'
},
prependAdminNameInBroadcast: {
type: 'Boolean',
required: false,
default: false,
description: "Prepend the admin's name when he makes an announcement."
}
},
discordClient.on('message', async message => {
if (message.author.bot || message.channel.id !== channelID) return;
init: async (server, options) => {
options.discordClient.on('message', async (message) => {
if (message.author.bot || message.channel.id !== options.channelID) return;
let command = message.content;
let command = message.content;
if(options.prependAdminNameInBroadcast && command.toLowerCase().startsWith('adminbroadcast'))
command = command.replace(/^AdminBroadcast /i, `AdminBroadcast ${message.member.displayName}: `);
if (options.prependAdminNameInBroadcast && command.toLowerCase().startsWith('adminbroadcast'))
command = command.replace(
/^AdminBroadcast /i,
`AdminBroadcast ${message.member.displayName}: `
);
const response = await server.rcon.execute(command);
const response = await server.rcon.execute(command);
const responseMessages = [''];
const responseMessages = [''];
for (const line of response.split('\n')) {
if (responseMessages[responseMessages.length - 1].length + line.length > 1994) {
responseMessages.push(line);
} else {
responseMessages[responseMessages.length - 1] = `${
responseMessages[responseMessages.length - 1]
}\n${line}`;
for (const line of response.split('\n')) {
if (responseMessages[responseMessages.length - 1].length + line.length > 1994) {
responseMessages.push(line);
} else {
responseMessages[responseMessages.length - 1] = `${
responseMessages[responseMessages.length - 1]
}\n${line}`;
}
}
}
for (const responseMessage of responseMessages) {
await message.channel.send(`\`\`\`${responseMessage}\`\`\``);
}
});
}
for (const responseMessage of responseMessages) {
await message.channel.send(`\`\`\`${responseMessage}\`\`\``);
}
});
}
};

View File

@ -1,33 +0,0 @@
<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
colorGradient: true, // gradient color based on player count
connectLink: true, // show Steam connect link
command: '!server', // command used to send message
disableStatus: false // disable bot status as server status
}
);
```

View File

@ -1,6 +1,6 @@
import tinygradient from 'tinygradient';
import { COPYRIGHT_MESSAGE } from 'core/config';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { SERVER_A2S_UPDATED } from 'squad-server/events/server';
const gradient = tinygradient([
@ -54,52 +54,84 @@ function makeEmbed(server, options) {
};
}
export default async function(server, discordClient, options = {}) {
if (!server) throw new Error('DiscordDebug must be provided with a reference to the server.');
export default {
name: 'discord-server-status',
description: 'This plugin displays server status embeds in Discord.',
defaultDisabled: false,
if (!discordClient) throw new Error('DiscordDebug must be provided with a Discord.js client.');
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
color: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color code of the Discord embed.'
},
colorGradient: {
type: 'Boolean',
required: false,
default: true,
description: 'Apply gradient color to Discord embed depending on the player count.'
},
connectLink: {
type: 'Boolean',
required: false,
default: true,
description: 'Display a Steam server connection link.'
},
command: {
type: 'String',
required: false,
default: '!server',
description: 'The command that displays the embed.'
},
disableStatus: {
type: 'Boolean',
required: false,
default: false,
description: 'Disable the bot status.'
}
},
options = {
color: 16761867,
colorGradient: true,
connectLink: true,
command: '!server',
disableStatus: false,
...options
};
init: async (server, options) => {
options.discordClient.on('message', async (message) => {
if (message.content !== options.command) return;
discordClient.on('message', async message => {
if (message.content !== options.command) return;
const serverStatus = await message.channel.send(makeEmbed(server, options));
const serverStatus = await message.channel.send(makeEmbed(server, options));
await serverStatus.react('🔄');
});
await serverStatus.react('🔄');
});
options.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;
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;
// ignore bots reacting
if (reaction.count === 1) return;
// remove reaction and readd it
await reaction.remove();
await reaction.message.react('🔄');
// remove reaction and readd it
await reaction.remove();
await reaction.message.react('🔄');
// update the message
await reaction.message.edit(makeEmbed(server, options));
});
// update the message
await reaction.message.edit(makeEmbed(server, options));
});
server.on(SERVER_A2S_UPDATED, () => {
if (!options.disableStatus)
discordClient.user.setActivity(
`(${server.playerCount}/${server.publicSlots}) ${server.currentLayer}`,
{ type: 'WATCHING' }
);
});
}
server.on(SERVER_A2S_UPDATED, () => {
if (!options.disableStatus)
options.discordClient.user.setActivity(
`(${server.playerCount}/${server.publicSlots}) ${server.currentLayer}`,
{ type: 'WATCHING' }
);
});
}
};

View File

@ -1,32 +0,0 @@
<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.
teamkillColor: 16761867, // colour of TK embed
suicideColor: 16761867, // colour of suicide embed
ignoreSuicides: false, // ignore suicide events
}
);
```

View File

@ -1,70 +1,101 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { LOG_PARSER_TEAMKILL } from 'squad-server/events/log-parser';
export default async function(server, discordClient, channelID, options = {}) {
if (!server) throw new Error('DiscordTeamKill must be provided with a reference to the server.');
export default {
name: 'discord-teamkill',
description: 'Log teamkills to Discord.',
defaultDisabled: false,
if (!discordClient) throw new Error('DiscordTeamkill must be provided with a Discord.js client.');
optionsSpec: {
discordClient: {
type: 'DiscordConnector',
required: true,
default: 'discord',
description: 'The name of the Discord Connector to use.'
},
channelID: {
type: 'Discord Channel ID',
required: true,
default: 'Discord Channel ID',
description: 'The ID of the channel to log admin broadcasts to.'
},
teamkillColor: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color of the embed for teamkills.'
},
suicideColor: {
type: 'Discord Color Code',
required: false,
default: 16761867,
description: 'The color of the embed for suicides.'
},
ignoreSuicides: {
type: 'Boolean',
required: false,
default: false,
description: 'Ignore suicides.'
},
disableSCBL: {
type: 'Boolean',
required: false,
default: false,
description: 'Disable Squad Community Ban List information.'
}
},
if (!channelID) throw new Error('DiscordTeamkill must be provided with a channel ID.');
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
options = {
teamkillColor: 16761867,
suicideColor: 16761867,
ignoreSuicides: false,
disableSCBL: false,
...options
};
server.on(LOG_PARSER_TEAMKILL, (info) => {
if (!info.attacker) return;
if (options.ignoreSuicides && info.suicide) return;
const channel = await discordClient.channels.fetch(channelID);
server.on(LOG_PARSER_TEAMKILL, info => {
if (!info.attacker) return;
if (options.ignoreSuicides && info.suicide) return;
const 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
}
];
if (!options.disableSCBL)
fields.push({
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})`
});
channel.send({
embed: {
title: `${info.suicide ? 'Suicide' : 'Teamkill'}: ${info.attacker.name}`,
color: info.suicide ? options.suicideColor : options.teamkillColor,
fields: fields,
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
const 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
}
}
];
if (!options.disableSCBL)
fields.push({
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})`
});
channel.send({
embed: {
title: `${info.suicide ? 'Suicide' : 'Teamkill'}: ${info.attacker.name}`,
color: info.suicide ? options.suicideColor : options.teamkillColor,
fields: fields,
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
});
}
}
};

View File

@ -2,14 +2,13 @@ import autoTKWarn from './auto-tk-warn/index.js';
import discordAdminBroadcast from './discord-admin-broadcast/index.js';
import discordAdminCamLogs from './discord-admin-cam-logs/index.js';
import discordChat from './discord-chat/index.js';
import discordChatAdminRequest from './discord-chat-admin-request/index.js';
import discordChatAdminRequest from './discord-admin-request/index.js';
import discordDebug from './discord-debug/index.js';
import discordRCON from './discord-rcon/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 mapvote123 from './mapvote/mapvote-123.js';
import mapvoteDidYouMean from './mapvote/mapvote-did-you-mean.js';
import mysqlLog from './mysql-log/index.js';
import seedingMessage from './seeding-message/index.js';
import teamRandomizer from './team-randomizer/index.js';
@ -24,10 +23,32 @@ export {
discordRCON,
discordServerStatus,
discordTeamkill,
influxdbLog,
influxdbLogDefaultSchema,
mapvote,
mapvote123,
mapvoteDidYouMean,
mysqlLog,
seedingMessage,
teamRandomizer
};
const plugins = [
autoTKWarn,
discordAdminBroadcast,
discordAdminCamLogs,
discordChat,
discordChatAdminRequest,
discordDebug,
discordRCON,
discordServerStatus,
discordTeamkill,
mapvote123,
mapvoteDidYouMean,
mysqlLog,
seedingMessage,
teamRandomizer
];
const namedPlugins = {};
for (const plugin of plugins) {
namedPlugins[plugin.name] = plugin;
}
export default namedPlugins;

View File

@ -1,30 +0,0 @@
<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

@ -1,123 +0,0 @@
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(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.');
const serverID = options.overrideServerID || server.id;
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: serverID },
fields: { tick_rate: info.tickRate },
timestamp: info.time
});
});
server.on(SERVER_PLAYERS_UPDATED, players => {
points.push({
measurement: 'PlayerCount',
tags: { server: serverID },
fields: { player_count: players.length },
timestamp: new Date()
});
});
server.on(LOG_PARSER_NEW_GAME, info => {
points.push({
measurement: 'Match',
tags: { server: serverID },
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: serverID },
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: serverID },
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: 'PlayerRevived',
tags: { server: serverID },
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

@ -1,84 +0,0 @@
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']
}
];

View File

@ -1,134 +0,0 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - Map Vote
</div>
## Map Vote "Did you mean?"
### About
Map Vote "Did you mean?" is best suited for servers who wish to allow players to vote for any layer in a large pool of options as it allows players to vote by specifying the layer name in chat. It uses a "did you mean?" algorithm to correct misspelling in layer names making it easier for players to vote.
Commands:
* `!mapvote help` - Shows other commands players can use.
* `!mapvote results` - See the map vote results.
* `!mapvote <layer name>` - Vote for a specific layer. Misspelling will be corrected where possible.
* `!mapvote start` (Admin chat only) - Starts a new map vote.
* `!mapvote restart` (Admin chat only) - Restarts a map vote.
* `!mapvote end` (Admin chat only) - Ends a map vote and announces the winner.
* `!mapvote destroy` (Admin chat only) - End a map vote without announcing the winner.
### Installation
Add the following two lines at the top of your index.js file to import the required components:
```js
import SquadLayerFilter from 'connectors/squad-layer-filter';
import { mapvote } from 'plugins';
```
To control which constraints, e.g. map history and player count compliant, you need to create an active layer filter.
```js
const activeLayerFilter = {
historyResetTime: 5 * 60 * 60 * 1000, // after 5 hours the layer history is ignored. null if off
layerHistoryTolerance: 8, // a layer can be only played once every x layers. null if off
mapHistoryTolerance: 4, // a map can only be played once every x layers. null if off
gamemodeHistoryTolerance: {
Invasion: 4 // invasion can only be played once every x layers
// if not specified they will default to off
},
gamemodeRepetitiveTolerance: {
Invasion: 4 // invasion can only be played up to x times in a row
// if not specified they will default to off
},
playerCountComplianceEnabled: true, // filter layers based on suggested player counts if true
factionComplianceEnabled: true, // a team cannot play the same faction twice in a row
factionHistoryTolerance: {
RUS: 4 // rus can only be played once every x layers
// if not specified they will default to off
},
factionRepetitiveTolerance: {
RUS: 4 // rus can only be played up to x times in a row
// if not specified they will default to off
},
};
```
You can turn off all options with:
```js
const activeLayerFilter = null;
```
Create a layer pool with one of the following options:
```js
// from a list
const squadLayerFilter = SquadLayerFilter.buildFromFilter(['Layer name 1', 'layer name 2'], activeLayerFilter);
// from a file of layer anmes separated by new lines
const squadLayerFilter = SquadLayerFilter.buildFromFile('filename', activeLayerFilter);
// from a filter
const squadLayerFilter = SquadLayerFilter.buildFromFilter(
{ // these options can also be turned off by replacing the value with null
whitelistedLayers: null, // a list of layers that can be played
blacklistedLayers: null, // a list of layers that cannot be played
whitelistedMaps: null, // a list of maps that can be played
blacklistedMaps: null, // a list of maps that cannot be played
whitelistedGamemodes: null, // a list of gamemodes that can be played
blacklistedGamemodes: ['Training'], // a list of gamemodes that cannot be played
flagCountMin: null, // layers must have move than this number of flags
flagCountMax: null, // layers must have less than this number of flags
hasCommander: null, // layer must have a commander
hasTanks: null, // layer must have tanks
hasHelicopters: null // layer must have helicopters
},
activeLayerFilter
);
```
Setup the map vote plugin:
```js
mapvote(
server,
'didyoumean',
squadLayerFilter,
{
alwaysOn: true, // map vote will start without admin interaction if true
minPlayerCount: null, // this number of players must be online before they can vote. null is off
minVoteCount: null, // this number of votes must be counted before a layer is selected. null is off
}
);
```
## Map Vote "123"
### About
Map Vote "123" is best suited for servers who want to allow admins to create map votes that allow players to easily choose from a small selection of layers.
Commands:
* `!mapvote help` - Shows other commands players can use.
* `!mapvote results` - See the map vote results.
* `<layer number>` - Vote for a specific layer via it's associated number.
* `!mapvote start <layer name 1>, <layer name 2>` (Admin chat only) - Starts a new map vote.
* `!mapvote restart` (Admin chat only) - Restarts a map vote with the same layers.
* `!mapvote end` (Admin chat only) - Ends a map vote and announces the winner.
* `!mapvote destroy` (Admin chat only) - End a map vote without announcing the winner.
### Installation
Add the following two lines at the top of your index.js file to import the required components:
```js
import SquadLayerFilter from 'connectors/squad-layer-filter';
import { mapvote } from 'plugins';
```
Setup the map vote plugin:
```js
mapvote(
server,
'123',
{
minVoteCount: null, // this number of votes must be counted before a layer is selected. null is off
}
);
```

View File

@ -1,15 +0,0 @@
import mapvoteDidYouMean from './mapvote-did-you-mean.js';
import mapvote123 from './mapvote-123.js';
export default function(server, mode, ...args) {
switch (mode) {
case 'didyoumean':
mapvoteDidYouMean(server, ...args);
break;
case '123':
mapvote123(server, ...args);
break;
default:
throw new Error('Invalid mode.');
}
}

View File

@ -1,54 +1,94 @@
import SquadLayerFilter from 'connectors/squad-layer-filter';
import { COPYRIGHT_MESSAGE } from 'core/config';
import { SquadLayerFilter } from 'core/squad-layers';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { LOG_PARSER_NEW_GAME } from 'squad-server/events/log-parser';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
import MapVote from './mapvote.js';
export default function(server, options = {}) {
let mapvote = null;
export default {
name: 'mapvote-123',
description: 'A map voting system that uses numbers to allow players to vote on layers.',
defaultDisabled: true,
options = {
minVoteCount: null,
...options
};
server.on(LOG_PARSER_NEW_GAME, () => {
mapvote = null;
});
server.on(RCON_CHAT_MESSAGE, async info => {
const voteMatch = info.message.match(/^([0-9])/);
if (voteMatch) {
if (!mapvote) return;
try {
const layerName = await mapvote.makeVoteByNumber(info.steamID, parseInt(voteMatch[1]));
await server.rcon.warn(info.steamID, `You voted for ${layerName}.`);
} catch (err) {
await server.rcon.warn(info.steamID, err.message);
}
await server.rcon.warn(info.steamID, `Powered by: ${COPYRIGHT_MESSAGE}`);
optionsSpec: {
minVoteCount: {
type: 'Integer',
required: false,
default: null,
description: 'The minimum number of votes required for the vote to succeed.'
}
},
const commandMatch = info.message.match(/^!mapvote ?(.*)/);
if (commandMatch) {
if (commandMatch[1].startsWith('start')) {
if (info.chat !== 'ChatAdmin') return;
init: async (server, options) => {
let mapvote = null;
if (mapvote) {
await server.rcon.warn(info.steamID, 'A mapvote has already begun.');
} else {
mapvote = new MapVote(
server,
SquadLayerFilter.buildFromDidYouMeanList(
commandMatch[1].replace('start ', '').split(', ')
),
{ minVoteCount: options.minVoteCount }
);
server.on(LOG_PARSER_NEW_GAME, () => {
mapvote = null;
});
server.on(RCON_CHAT_MESSAGE, async (info) => {
const voteMatch = info.message.match(/^([0-9])/);
if (voteMatch) {
if (!mapvote) return;
try {
const layerName = await mapvote.makeVoteByNumber(info.steamID, parseInt(voteMatch[1]));
await server.rcon.warn(info.steamID, `You voted for ${layerName}.`);
} catch (err) {
await server.rcon.warn(info.steamID, err.message);
}
await server.rcon.warn(info.steamID, `Powered by: ${COPYRIGHT_MESSAGE}`);
}
const commandMatch = info.message.match(/^!mapvote ?(.*)/);
if (commandMatch) {
if (commandMatch[1].startsWith('start')) {
if (info.chat !== 'ChatAdmin') return;
if (mapvote) {
await server.rcon.warn(info.steamID, 'A mapvote has already begun.');
} else {
mapvote = new MapVote(
server,
SquadLayerFilter.buildFromDidYouMeanList(
commandMatch[1].replace('start ', '').split(', ')
),
{ minVoteCount: options.minVoteCount }
);
mapvote.on('NEW_WINNER', async (results) => {
await server.rcon.broadcast(
`New Map Vote Winner: ${results[0].layer.layer}. Participate in the map vote by typing "!mapvote help" in chat.`
);
});
mapvote.on('NEW_WINNER', async results => {
await server.rcon.broadcast(
`New Map Vote Winner: ${results[0].layer.layer}. Participate in the map vote by typing "!mapvote help" in chat.`
`A new map vote has started. Participate in the map vote by typing "!mapvote help" in chat. Map options to follow...`
);
await server.rcon.broadcast(
mapvote.squadLayerFilter
.getLayerNames()
.map((layerName, key) => `${key + 1} - ${layerName}`)
.join(', ')
);
}
return;
}
if (!mapvote) {
await server.rcon.warn(info.steamID, 'A map vote has not begun.');
return;
}
if (commandMatch[1] === 'restart') {
if (info.chat !== 'ChatAdmin') return;
mapvote = new MapVote(server, mapvote.squadLayerFilter, {
minVoteCount: options.minVoteCount
});
mapvote.on('NEW_WINNER', async (results) => {
await server.rcon.broadcast(
`New Map Vote Winner: ${results[0].layer}. Participate in the map vote by typing "!mapvote help" in chat.`
);
});
@ -61,94 +101,65 @@ export default function(server, options = {}) {
.map((layerName, key) => `${key + 1} - ${layerName}`)
.join(', ')
);
}
return;
}
if (!mapvote) {
await server.rcon.warn(info.steamID, 'A map vote has not begun.');
return;
}
if (commandMatch[1] === 'restart') {
if (info.chat !== 'ChatAdmin') return;
mapvote = new MapVote(server, mapvote.squadLayerFilter, {
minVoteCount: options.minVoteCount
});
mapvote.on('NEW_WINNER', async results => {
await server.rcon.broadcast(
`New Map Vote Winner: ${results[0].layer}. Participate in the map vote by typing "!mapvote help" in chat.`
);
});
await server.rcon.broadcast(
`A new map vote has started. Participate in the map vote by typing "!mapvote help" in chat. Map options to follow...`
);
await server.rcon.broadcast(
mapvote.squadLayerFilter
.getLayerNames()
.map((layerName, key) => `${key + 1} - ${layerName}`)
.join(', ')
);
return;
}
if (commandMatch[1] === 'end') {
if (info.chat !== 'ChatAdmin') return;
const results = mapvote.getResults();
if (results.length === 0)
await server.rcon.broadcast(`No layer gained enough votes to win.`);
else await server.rcon.broadcast(`${mapvote.getResults()[0].layer.layer} won the mapvote!`);
mapvote = null;
return;
}
if (commandMatch[1] === 'destroy') {
if (info.chat !== 'ChatAdmin') return;
mapvote = null;
return;
}
if (commandMatch[1] === 'help') {
await server.rcon.warn(info.steamID, 'To vote type the layer number into chat:');
for (const layer of mapvote.squadLayerFilter.getLayers()) {
await server.rcon.warn(info.steamID, `${layer.layerNumber} - ${layer.layer}`);
return;
}
if (options.minVoteCount !== null)
await server.rcon.warn(
info.steamID,
`${options.minVoteCount} votes need to be made for a winner to be selected.`
);
if (commandMatch[1] === 'end') {
if (info.chat !== 'ChatAdmin') return;
await server.rcon.warn(
info.steamID,
'To see current results type into chat: !mapvote results'
);
}
const results = mapvote.getResults();
if (commandMatch[1] === 'results') {
const results = mapvote.getResults();
if (results.length === 0)
await server.rcon.broadcast(`No layer gained enough votes to win.`);
else
await server.rcon.broadcast(`${mapvote.getResults()[0].layer.layer} won the mapvote!`);
if (results.length === 0) {
await server.rcon.warn(info.steamID, 'No one has voted yet.');
} else {
await server.rcon.warn(info.steamID, 'The current vote counts are as follows:');
for (const result of results) {
mapvote = null;
return;
}
if (commandMatch[1] === 'destroy') {
if (info.chat !== 'ChatAdmin') return;
mapvote = null;
return;
}
if (commandMatch[1] === 'help') {
await server.rcon.warn(info.steamID, 'To vote type the layer number into chat:');
for (const layer of mapvote.squadLayerFilter.getLayers()) {
await server.rcon.warn(info.steamID, `${layer.layerNumber} - ${layer.layer}`);
}
if (options.minVoteCount !== null)
await server.rcon.warn(
info.steamID,
`${result.layer.layerNumber} - ${result.layer.layer} (${result.votes} vote${
result.votes > 1 ? 's' : ''
})`
`${options.minVoteCount} votes need to be made for a winner to be selected.`
);
await server.rcon.warn(
info.steamID,
'To see current results type into chat: !mapvote results'
);
}
if (commandMatch[1] === 'results') {
const results = mapvote.getResults();
if (results.length === 0) {
await server.rcon.warn(info.steamID, 'No one has voted yet.');
} else {
await server.rcon.warn(info.steamID, 'The current vote counts are as follows:');
for (const result of results) {
await server.rcon.warn(
info.steamID,
`${result.layer.layerNumber} - ${result.layer.layer} (${result.votes} vote${
result.votes > 1 ? 's' : ''
})`
);
}
}
}
}
}
});
}
});
}
};

View File

@ -1,139 +1,170 @@
import { COPYRIGHT_MESSAGE } from 'core/config';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { LOG_PARSER_NEW_GAME } from 'squad-server/events/log-parser';
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
import MapVote from './mapvote.js';
export default function(server, squadLayerFilter, options = {}) {
options = {
alwaysOn: true,
minPlayerCount: null,
minVoteCount: null,
...options
};
export default {
name: 'mapvote-did-you-mean',
description:
'A map voting system that uses a "Did you mean?" algorithm to allow players to vote on layers.',
defaultDisabled: true,
let mapvote;
let manuallyCreated;
async function newMapvote(manuallyCreatedOption = true) {
mapvote = new MapVote(server, squadLayerFilter, {
minVoteCount: options.minVoteCount
});
manuallyCreated = manuallyCreatedOption;
mapvote.on('NEW_WINNER', async results => {
await server.rcon.broadcast(
`New Map Vote Winner: ${results[0].layer.layer}. Participate in the map vote by typing "!mapvote help" in chat.`
);
});
if (manuallyCreated)
await server.rcon.broadcast(
`A new map vote has started. Participate in the map vote by typing "!mapvote help" in chat.`
);
}
if (options.alwaysOn) newMapvote(false);
server.on(LOG_PARSER_NEW_GAME, () => {
if (options.alwaysOn) {
newMapvote(false);
} else {
mapvote = null;
optionsSpec: {
layerFilter: {
type: 'SquadLayerFilterConnector',
required: false,
default: 'layerFilter',
description: 'The layers players can choose from.'
},
alwaysOn: {
type: 'Boolean',
required: false,
default: true,
description: 'If true then the map voting system will always be live.'
},
minPlayerCount: {
type: 'Integer',
required: false,
default: null,
description: 'The minimum number of players required for the vote to succeed.'
},
minVoteCount: {
type: 'Integer',
required: false,
default: null,
description: 'The minimum number of votes required for the vote to succeed.'
}
});
},
server.on(RCON_CHAT_MESSAGE, async info => {
const match = info.message.match(/^!mapvote ?(.*)/);
if (!match) return;
init: async (server, options) => {
let mapvote;
let manuallyCreated;
if (match[1] === 'help') {
await server.rcon.warn(info.steamID, 'You may use any of the following commands in chat:');
await server.rcon.warn(info.steamID, '!mapvote results - View the current vote counts.');
await server.rcon.warn(info.steamID, '!mapvote <layer name> - Vote for the specified layer.');
await server.rcon.warn(
info.steamID,
'When inputting a layer name, we autocorrect any miss spelling.'
);
async function newMapvote(manuallyCreatedOption = true) {
mapvote = new MapVote(server, options.squadLayerFilter, {
minVoteCount: options.minVoteCount
});
if (options.minVoteCount !== null)
manuallyCreated = manuallyCreatedOption;
mapvote.on('NEW_WINNER', async (results) => {
await server.rcon.broadcast(
`New Map Vote Winner: ${results[0].layer.layer}. Participate in the map vote by typing "!mapvote help" in chat.`
);
});
if (manuallyCreated)
await server.rcon.broadcast(
`A new map vote has started. Participate in the map vote by typing "!mapvote help" in chat.`
);
}
if (options.alwaysOn) newMapvote(false);
server.on(LOG_PARSER_NEW_GAME, () => {
if (options.alwaysOn) {
newMapvote(false);
} else {
mapvote = null;
}
});
server.on(RCON_CHAT_MESSAGE, async (info) => {
const match = info.message.match(/^!mapvote ?(.*)/);
if (!match) return;
if (match[1] === 'help') {
await server.rcon.warn(info.steamID, 'You may use any of the following commands in chat:');
await server.rcon.warn(info.steamID, '!mapvote results - View the current vote counts.');
await server.rcon.warn(
info.steamID,
`${options.minVoteCount} votes need to be made for a winner to be selected.`
'!mapvote <layer name> - Vote for the specified layer.'
);
await server.rcon.warn(
info.steamID,
'When inputting a layer name, we autocorrect any miss spelling.'
);
return;
}
if (match[1] === 'start') {
if (info.chat !== 'ChatAdmin') return;
if (mapvote) {
await server.rcon.warn(info.steamID, 'A mapvote has already begun.');
} else {
await newMapvote();
}
return;
}
if (!mapvote) {
await server.rcon.warn(info.steamID, 'A map vote has not begun.');
return;
}
if (match[1] === 'restart') {
if (info.chat !== 'ChatAdmin') return;
await newMapvote();
return;
}
if (match[1] === 'end') {
if (info.chat !== 'ChatAdmin') return;
const results = mapvote.getResults(true);
if (results.length === 0) await server.rcon.broadcast(`No layer gained enough votes to win.`);
else await server.rcon.broadcast(`${mapvote.getResults()[0].layer.layer} won the mapvote!`);
mapvote = null;
return;
}
if (match[1] === 'destroy') {
if (info.chat !== 'ChatAdmin') return;
mapvote = null;
return;
}
if (match[1] === 'results') {
const results = mapvote.getResults();
if (results.length === 0) {
await server.rcon.warn(info.steamID, 'No one has voted yet.');
} else {
await server.rcon.warn(info.steamID, 'The current vote counts are as follows:');
for (const result of results) {
if (options.minVoteCount !== null)
await server.rcon.warn(
info.steamID,
`${result.layer.layer} - ${result.votes} vote${result.votes > 1 ? 's' : ''}`
`${options.minVoteCount} votes need to be made for a winner to be selected.`
);
return;
}
if (match[1] === 'start') {
if (info.chat !== 'ChatAdmin') return;
if (mapvote) {
await server.rcon.warn(info.steamID, 'A mapvote has already begun.');
} else {
await newMapvote();
}
return;
}
}
if (!manuallyCreated && server.players.length < options.minPlayerCount) {
await server.rcon.warn(info.steamID, 'Not enough players online to vote.');
return;
}
if (!mapvote) {
await server.rcon.warn(info.steamID, 'A map vote has not begun.');
return;
}
try {
const layerName = await mapvote.makeVoteByDidYouMean(info.steamID, match[1]);
await server.rcon.warn(info.steamID, `You voted for ${layerName}.`);
} catch (err) {
await server.rcon.warn(info.steamID, err.message);
}
await server.rcon.warn(info.steamID, `Powered by: ${COPYRIGHT_MESSAGE}`);
});
}
if (match[1] === 'restart') {
if (info.chat !== 'ChatAdmin') return;
await newMapvote();
return;
}
if (match[1] === 'end') {
if (info.chat !== 'ChatAdmin') return;
const results = mapvote.getResults(true);
if (results.length === 0)
await server.rcon.broadcast(`No layer gained enough votes to win.`);
else await server.rcon.broadcast(`${mapvote.getResults()[0].layer.layer} won the mapvote!`);
mapvote = null;
return;
}
if (match[1] === 'destroy') {
if (info.chat !== 'ChatAdmin') return;
mapvote = null;
return;
}
if (match[1] === 'results') {
const results = mapvote.getResults();
if (results.length === 0) {
await server.rcon.warn(info.steamID, 'No one has voted yet.');
} else {
await server.rcon.warn(info.steamID, 'The current vote counts are as follows:');
for (const result of results) {
await server.rcon.warn(
info.steamID,
`${result.layer.layer} - ${result.votes} vote${result.votes > 1 ? 's' : ''}`
);
}
return;
}
}
if (!manuallyCreated && server.players.length < options.minPlayerCount) {
await server.rcon.warn(info.steamID, 'Not enough players online to vote.');
return;
}
try {
const layerName = await mapvote.makeVoteByDidYouMean(info.steamID, match[1]);
await server.rcon.warn(info.steamID, `You voted for ${layerName}.`);
} catch (err) {
await server.rcon.warn(info.steamID, err.message);
}
await server.rcon.warn(info.steamID, `Powered by: ${COPYRIGHT_MESSAGE}`);
});
}
};

View File

@ -1,6 +1,6 @@
import EventEmitter from 'events';
import SquadLayers from 'connectors/squad-layers';
import { SquadLayers } from 'core/squad-layers';
export default class MapVote extends EventEmitter {
constructor(server, squadLayerFilter, options = {}) {
@ -47,7 +47,7 @@ export default class MapVote extends EventEmitter {
Object.keys(this.playerVotes).length >= this.minVoteCount
) {
return Object.keys(this.layerVotes)
.map(layerName => ({
.map((layerName) => ({
layer: this.squadLayerFilter.getLayerByLayerName(layerName),
votes: this.layerVotes[layerName]
}))

View File

@ -1,6 +1,6 @@
<div align="center">
<img src="../../assets/squadjs-logo.png" alt="Logo" width="500"/>
<img src="../../core/assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS - MySQL Log
</div>

View File

@ -7,97 +7,113 @@ import {
} from 'squad-server/events/log-parser';
import { SERVER_PLAYERS_UPDATED } from 'squad-server/events/server';
export default function(server, mysqlPool, options = {}) {
if (!server) throw new Error('MySQLLog must be provided with a reference to the server.');
export default {
name: 'mysql-log',
description: 'Log server information and statistics to a MySQL DB.',
defaultDisabled: true,
if (!mysqlPool) throw new Error('MySQLLog must be provided with a mysql Pool.');
optionsSpec: {
mysqlPool: {
type: 'MySQLPoolConnector',
required: true,
default: 'mysql',
description: 'The name of the MySQL Pool Connector to use.'
},
overrideServerID: {
type: 'Int',
required: false,
default: null,
description: 'A overridden server ID.'
}
},
const serverID = options.overrideServerID || server.id;
init: async (server, options) => {
const serverID = options.overrideServerID === null ? server.id : options.overrideServerID;
server.on(LOG_PARSER_SERVER_TICK_RATE, info => {
mysqlPool.query('INSERT INTO ServerTickRate(time, server, tick_rate) VALUES (?,?,?)', [
info.time,
serverID,
info.tickRate
]);
});
server.on(LOG_PARSER_SERVER_TICK_RATE, (info) => {
options.mysqlPool.query(
'INSERT INTO ServerTickRate(time, server, tick_rate) VALUES (?,?,?)',
[info.time, serverID, info.tickRate]
);
});
server.on(SERVER_PLAYERS_UPDATED, players => {
mysqlPool.query('INSERT INTO PlayerCount(time, server, player_count) VALUES (NOW(),?,?)', [
serverID,
players.length
]);
});
server.on(SERVER_PLAYERS_UPDATED, (players) => {
options.mysqlPool.query(
'INSERT INTO PlayerCount(time, server, player_count) VALUES (NOW(),?,?)',
[serverID, players.length]
);
});
server.on(LOG_PARSER_NEW_GAME, info => {
mysqlPool.query('call NewMatch(?,?,?,?,?,?,?)', [
serverID,
info.time,
info.dlc,
info.mapClassname,
info.layerClassname,
info.map,
info.layer
]);
});
server.on(LOG_PARSER_NEW_GAME, (info) => {
options.mysqlPool.query('call NewMatch(?,?,?,?,?,?,?)', [
serverID,
info.time,
info.dlc,
info.mapClassname,
info.layerClassname,
info.map,
info.layer
]);
});
server.on(LOG_PARSER_PLAYER_WOUNDED, info => {
mysqlPool.query('call InsertPlayerWounded(?,?,?,?,?,?,?,?,?,?,?,?,?)', [
serverID,
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_WOUNDED, (info) => {
options.mysqlPool.query('call InsertPlayerWounded(?,?,?,?,?,?,?,?,?,?,?,?,?)', [
serverID,
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
serverID,
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_DIED, (info) => {
options.mysqlPool.query('call InsertPlayerDied(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
serverID,
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
serverID,
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
]);
});
}
server.on(LOG_PARSER_PLAYER_REVIVED, (info) => {
options.mysqlPool.query('call InsertPlayerRevived(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
serverID,
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

@ -6,7 +6,6 @@
".": "./index.js"
},
"dependencies": {
"connectors": "1.0.0",
"didyoumean": "^1.2.1",
"influx": "^5.5.1",
"squad-server": "1.0.0",

View File

@ -1,27 +0,0 @@
<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

@ -1,59 +1,102 @@
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.');
export default {
name: 'seeding-message',
description: 'Display seeding messages in admin broadcasts.',
defaultDisabled: false,
const mode = options.mode || 'interval';
const interval = options.interval || 150 * 1000;
const delay = options.delay || 45 * 1000;
optionsSpec: {
mode: {
type: '`interval` or `onjoin`',
required: false,
default: 'interval',
description: 'Display seeding messages at a set interval or after players join.'
},
interval: {
type: 'Number',
required: false,
default: 150 * 1000,
description: 'How frequently to display the seeding messages in seconds.'
},
delay: {
type: 'Number',
required: false,
default: 45 * 1000,
description: 'How long to wait after a player joins to display the announcement in seconds.'
},
seedingThreshold: {
type: 'Number',
required: false,
default: 50,
description: 'Number of players before the server is considered live.'
},
seedingMessage: {
type: 'String',
required: false,
default: 'Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!',
description: 'The seeding message to display.'
},
liveEnabled: {
type: 'String',
required: false,
default: true,
description: 'Display a "Live" message when a certain player count is met.'
},
liveThreshold: {
type: 'Number',
required: false,
default: 2,
description:
'When above the seeding threshold, but within this number "Live" messages are displayed.'
},
liveMessage: {
type: 'String',
required: false,
default: 'Live',
description: 'The "Live" message to display.'
}
},
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(() => {
init: async (server, options) => {
switch (options.mode) {
case 'interval':
setInterval(() => {
const playerCount = server.players.length;
if (playerCount === 0) return;
if (playerCount < seedingThreshold) {
server.rcon.execute(`AdminBroadcast ${seedingMessage}`);
if (playerCount < options.seedingThreshold) {
server.rcon.execute(`AdminBroadcast ${options.seedingMessage}`);
return;
}
if (liveEnabled && playerCount < liveThreshold) {
server.rcon.execute(`AdminBroadcast ${liveMessage}`);
if (options.liveEnabled && playerCount < options.liveThreshold) {
server.rcon.execute(`AdminBroadcast ${options.liveMessage}`);
}
}, delay);
});
}, options.interval);
break;
default:
throw new Error('Invalid SeedingMessage mode.');
break;
case 'onjoin':
server.on(LOG_PARSER_PLAYER_CONNECTED, () => {
setTimeout(() => {
const playerCount = server.players.length;
if (playerCount === 0) return;
if (playerCount < options.seedingThreshold) {
server.rcon.execute(`AdminBroadcast ${options.seedingMessage}`);
return;
}
if (options.liveEnabled && playerCount < options.liveThreshold) {
server.rcon.execute(`AdminBroadcast ${options.liveMessage}`);
}
}, options.delay);
});
break;
default:
throw new Error('Invalid SeedingMessage mode.');
}
}
}
};

View File

@ -1,20 +0,0 @@
<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

@ -1,48 +1,55 @@
import { RCON_CHAT_MESSAGE } from 'squad-server/events/rcon';
function shuffle(array) {
let currentIndex = array.length;
let temporaryValue;
let randomIndex;
export default {
name: 'team-randomizer',
description: 'Randomize teams with an admin command.',
defaultDisabled: false,
// While there remain elements to shuffle...
while (currentIndex !== 0) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
optionsSpec: {
command: {
type: 'String',
required: false,
default: '!randomize',
description: 'The command used to randomize the teams.'
}
},
// And swap it with the current element.
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
init: async (server, options) => {
const commandRegex = new RegExp(`^${options.command}`, 'i');
return array;
}
server.on(RCON_CHAT_MESSAGE, (info) => {
if (info.chat !== 'ChatAdmin') return;
export default function(server, options = {}) {
if (!server) throw new Error('TeamRandomizer must be provided with a reference to the server.');
const match = info.message.match(commandRegex);
if (!match) return;
const command = options.command || '!randomize';
const commandRegex = new RegExp(`^${command}`, 'i');
const players = server.players.slice(0);
server.on(RCON_CHAT_MESSAGE, info => {
if (info.chat !== 'ChatAdmin') return;
let currentIndex = players.length;
let temporaryValue;
let randomIndex;
const match = info.message.match(commandRegex);
if (!match) return;
// While there remain elements to shuffle...
while (currentIndex !== 0) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
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}"`);
// And swap it with the current element.
temporaryValue = players[currentIndex];
players[currentIndex] = players[randomIndex];
players[randomIndex] = temporaryValue;
}
team = team === '1' ? '2' : '1';
}
});
}
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

@ -2,8 +2,6 @@ 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';
@ -94,7 +92,6 @@ export default class Server extends EventEmitter {
}
async watch() {
printLogo();
console.log(`Watching server ${this.id}...`);
if (this.logParser) await this.logParser.watch();
if (this.rcon) await this.rcon.watch();

View File

@ -1,4 +1,4 @@
import SquadLayers from 'connectors/squad-layers';
import { SquadLayers } from 'core/squad-layers';
import { LOG_PARSER_NEW_GAME } from '../../events/log-parser.js';

View File

@ -1,74 +0,0 @@
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);
});
}

View File

@ -4,7 +4,6 @@
"type": "module",
"dependencies": {
"async": "^3.2.0",
"cli-progress": "^3.8.2",
"core": "1.0.0",
"ftp-tail": "^1.0.2",
"gamedig": "^2.0.20",

View File

@ -105,12 +105,12 @@ export default class Rcon {
this.client.on('data', this.onData);
this.client.on('error', err => {
this.client.on('error', (err) => {
this.verbose(`Socket Error: ${err.message}`);
this.emitter.emit(RCON_ERROR, err);
});
this.client.on('close', async hadError => {
this.client.on('close', async (hadError) => {
this.verbose(`Socket Closed. AutoReconnect: ${this.autoReconnect}`);
this.connected = false;
this.client.removeListener('data', this.onData);
@ -138,7 +138,7 @@ export default class Rcon {
resolve();
};
const onError = err => {
const onError = (err) => {
this.verbose(`Error Opening Socket: ${err.message}`);
this.client.removeListener('connect', onConnect);
reject(err);
@ -162,7 +162,7 @@ export default class Rcon {
resolve();
};
const onError = err => {
const onError = (err) => {
this.verbose(`Error disconnecting: ${err.message}`);
this.client.removeListener('close', onClose);
reject(err);