SquadJS v2.0 Initial Framework

This commit is contained in:
Thomas Smyth 2020-10-05 18:52:01 +01:00
parent 45f089c94b
commit 39efc08a78
80 changed files with 944 additions and 13993 deletions

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
# Project Files
*.tmp
index-test.js
config-test.json
# Dependencies

554
README.md
View File

@ -1,6 +1,6 @@
<div align="center">
<img src="core/assets/squadjs-logo.png" alt="Logo" width="500"/>
<img src="assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS
@ -91,7 +91,7 @@ Requires a Discord bot login token.
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",
"type": "buildPoolFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
@ -129,7 +129,7 @@ Connects to a filtered list of Squad layers and filters them either by an "initi
},
```
* `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.
- `buildPoolFromFilter` - 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.
@ -141,8 +141,8 @@ Connects to a filtered list of Squad layers and filters them either by an "initi
- `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"]`.
- `buildPoolFromFile` - Builds the Squad layers list from a Squad layer config file. `filter` should be the filename of the config file.
- `buildPoolFromLayerNames` - 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.
@ -189,547 +189,21 @@ Plugin options are also specified. A full list of plugin options can be seen bel
The following is a list of plugins built into SquadJS, you can click their title for more information:
<details>
<summary>auto-tk-warn</summary>
<h2>auto-tk-warn</h2>
<p>The <code>auto-tk-warn</code> plugin will automatically warn players in game to apologise for teamkills when they teamkill another player.</p>
<summary>ExamplePlugin</summary>
<h2>ExamplePlugin</h2>
<p>An example plugin that shows how to implement a basic plugin.</p>
<h3>Options</h3>
<h4>message</h4>
<h4>exampleOption</h4>
<h6>Description</h6>
<p>The message to warn players with.</p>
<p>An example option.</p>
<h6>Default</h6>
<pre><code>Please apologise for ALL TKs in ALL chat!</code></pre><h6>Example</h6>
<pre><code>Test</code></pre>
</details>
<details>
<summary>chat-commands</summary>
<h2>chat-commands</h2>
<p>The <code>chat-command</code> plugin can be configured to make chat commands that broadcast or warn the caller with present messages.</p>
<h3>Options</h3>
<h4>commands</h4>
<pre><code>A default value.</code></pre><h6>Example</h6>
<pre><code>An example value.</code></pre>
<h4>exampleConnector (Required)</h4>
<h6>Description</h6>
<p>An array of objects containing the following properties: <ul><li><code>command</code> - The command that initiates the message.</li><li><code>type</code> - Either <code>warn</code> or <code>broadcast</code>.</li><li><code>response</code> - The message to respond with.</li><li><code>ignoreChats</code> - A list of chats to ignore the commands in. Use this to limit it to admins.</li></ul></p>
<h6>Default</h6>
<pre><code>[
{
"command": "!squadjs",
"type": "warn",
"response": "This server is powered by SquadJS.",
"ignoreChats": []
}
]</code></pre>
</details>
<details>
<summary>discord-admin-broadcast</summary>
<h2>discord-admin-broadcast</h2>
<p>The <code>discord-admin-broadcast</code> plugin will send a copy of admin broadcasts made in game to a Discord channel.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<p>An example Discord connector.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin broadcasts to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>color</h4>
<h6>Description</h6>
<p>The color of the embed.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
</details>
<details>
<summary>discord-admin-cam-logs</summary>
<h2>discord-admin-cam-logs</h2>
<p>The <code>discord-admin-cam-logs</code> plugin will log in game admin camera usage to a Discord channel.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin cam usage to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>color</h4>
<h6>Description</h6>
<p>The color of the embed.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
</details>
<details>
<summary>discord-chat</summary>
<h2>discord-chat</h2>
<p>The <code>discord-chat</code> plugin will log in-game chat to a Discord channel.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin broadcasts to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>ignoreChats</h4>
<h6>Description</h6>
<p>A list of chat names to ignore.</p>
<h6>Default</h6>
<pre><code>[
"ChatSquad"
]</code></pre>
<h4>chatColors</h4>
<h6>Description</h6>
<p>The color of the embed for each chat.</p>
<h6>Default</h6>
<pre><code>{}</code></pre><h6>Example</h6>
<pre><code>{
"ChatAll": 16761867
}</code></pre>
<h4>color</h4>
<h6>Description</h6>
<p>The color of the embed.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
</details>
<details>
<summary>discord-admin-request</summary>
<h2>discord-admin-request</h2>
<p>The <code>discord-admin-request</code> plugin will ping admins in a Discord channel when a player requests an admin via the <code>!admin</code> command in in-game chat.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin broadcasts to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>ignoreChats</h4>
<h6>Description</h6>
<p>A list of chat names to ignore.</p>
<h6>Default</h6>
<pre><code>[]</code></pre><h6>Example</h6>
<pre><code>[
"ChatSquad"
]</code></pre>
<h4>ignorePhrases</h4>
<h6>Description</h6>
<p>A list of phrases to ignore.</p>
<h6>Default</h6>
<pre><code>[]</code></pre><h6>Example</h6>
<pre><code>[
"switch"
]</code></pre>
<h4>adminPrefix</h4>
<h6>Description</h6>
<p>The command that calls an admin.</p>
<h6>Default</h6>
<pre><code>!admin</code></pre>
<h4>pingGroups</h4>
<h6>Description</h6>
<p>A list of Discord role IDs to ping.</p>
<h6>Default</h6>
<pre><code>[]</code></pre><h6>Example</h6>
<pre><code>[
"500455137626554379"
]</code></pre>
<h4>pingDelay</h4>
<h6>Description</h6>
<p>Cooldown for pings in milliseconds.</p>
<h6>Default</h6>
<pre><code>60000</code></pre>
<h4>color</h4>
<h6>Description</h6>
<p>The color of the embed.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
</details>
<details>
<summary>discord-debug</summary>
<h2>discord-debug</h2>
<p>The <code>discord-debug</code> plugin can be used to help debug SquadJS by dumping SquadJS events to a Discord channel.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin broadcasts to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>events (Required)</h4>
<h6>Description</h6>
<p>A list of events to dump.</p>
<h6>Default</h6>
<pre><code>[]</code></pre><h6>Example</h6>
<pre><code>[
"PLAYER_DIED"
]</code></pre>
</details>
<details>
<summary>discord-rcon</summary>
<h2>discord-rcon</h2>
<p>The <code>discord-rcon</code> plugin allows a specified Discord channel to be used as a RCON console to run RCON commands.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel you wish to turn into a RCON console.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>prependAdminNameInBroadcast</h4>
<h6>Description</h6>
<p>Prepend the admin's name when he makes an announcement.</p>
<h6>Default</h6>
<pre><code>false</code></pre>
</details>
<details>
<summary>discord-round-winner</summary>
<h2>discord-round-winner</h2>
<p>The `discord-round-winner` plugin will send the round winner to a Discord channel.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin broadcasts to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>color</h4>
<h6>Description</h6>
<p>The color of the embed.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
</details>
<details>
<summary>discord-server-status</summary>
<h2>discord-server-status</h2>
<p>The <code>discord-server-status</code> plugin displays a server status embed to Discord when someone uses the <code>!server</code> command in a Discord channel.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>color</h4>
<h6>Description</h6>
<p>The color code of the Discord embed.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
<h4>colorGradient</h4>
<h6>Description</h6>
<p>Apply gradient color to Discord embed depending on the player count.</p>
<h6>Default</h6>
<pre><code>true</code></pre>
<h4>connectLink</h4>
<h6>Description</h6>
<p>Display a Steam server connection link.</p>
<h6>Default</h6>
<pre><code>true</code></pre>
<h4>command</h4>
<h6>Description</h6>
<p>The command that displays the embed.</p>
<h6>Default</h6>
<pre><code>!server</code></pre>
<h4>disableStatus</h4>
<h6>Description</h6>
<p>Disable the bot status.</p>
<h6>Default</h6>
<pre><code>false</code></pre>
</details>
<details>
<summary>discord-teamkill</summary>
<h2>discord-teamkill</h2>
<p>The <code>discord-teamkill</code> plugin logs teamkills and related information to a Discord channel for admin to review.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>The name of the Discord Connector to use.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>channelID (Required)</h4>
<h6>Description</h6>
<p>The ID of the channel to log admin broadcasts to.</p>
<h6>Default</h6>
<pre><code></code></pre><h6>Example</h6>
<pre><code>667741905228136459</code></pre>
<h4>teamkillColor</h4>
<h6>Description</h6>
<p>The color of the embed for teamkills.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
<h4>suicideColor</h4>
<h6>Description</h6>
<p>The color of the embed for suicides.</p>
<h6>Default</h6>
<pre><code>16761867</code></pre>
<h4>ignoreSuicides</h4>
<h6>Description</h6>
<p>Ignore suicides.</p>
<h6>Default</h6>
<pre><code>false</code></pre>
<h4>disableSCBL</h4>
<h6>Description</h6>
<p>Disable Squad Community Ban List information.</p>
<h6>Default</h6>
<pre><code>false</code></pre>
</details>
<details>
<summary>intervalled-broadcasts</summary>
<h2>intervalled-broadcasts</h2>
<p>The `intervalled-broadcasts` plugin allows you to set broadcasts, which will be broadcasted at preset intervals</p>
<h3>Options</h3>
<h4>broadcasts</h4>
<h6>Description</h6>
<p>The broadcasted messages.</p>
<h6>Default</h6>
<pre><code>[
"Server powered by SquadJS."
]</code></pre>
<h4>interval</h4>
<h6>Description</h6>
<p>How frequently to broadcast in seconds.</p>
<h6>Default</h6>
<pre><code>300000</code></pre>
</details>
<details>
<summary>mapvote-123</summary>
<h2>mapvote-123</h2>
<p>The <code>mapvote-123</code> plugin provides map voting functionality. This variant of map voting allows admins to specify a small number of maps which are numbered and announced in admin broadcasts. Players can then vote for the map their choice by typing the corresponding map number into chat.
Player Commands:
* <code>!mapvote help</code> - Show other commands players can use.
* <code>!mapvote results</code> - Show the results of the current map vote.
* <code><layer number></code> - Vote for a layer using the layer number.
Admin Commands (Admin Chat Only):
* <code>!mapvote start <layer name 1>, <layer name 2>, ...</code> - Start a new map vote with the specified maps.
* <code>!mapvote restart</code> - Restarts the map vote with the same layers.
* <code>!mapvote end</code> - End the map vote and announce the winner.
* <code>!mapvote destroy</code> - End the map vote without announcing the winner.
</p>
<h3>Options</h3>
<h4>minVoteCount</h4>
<h6>Description</h6>
<p>The minimum number of votes required for the vote to succeed.</p>
<h6>Default</h6>
<pre><code>null</code></pre><h6>Example</h6>
<pre><code>3</code></pre>
</details>
<details>
<summary>mapvote-did-you-mean</summary>
<h2>mapvote-did-you-mean</h2>
<p>The <code>mapvote-did-you-mean</code> plugin provides map voting functionality. This variant of map voting uses a "Did you mean?" algorithm to allow players to easily select one of a large pool of layers by typing it's name into the in-game chat.
Player Commands:
* <code>!mapvote help</code> - Show other commands players can use.
* <code>!mapvote results</code> - Show the results of the current map vote.
* <code>!mapvote <layer name></code> - Vote for the specified layer. Misspelling will be corrected where possible.
Admin Commands (Admin Chat Only):
* <code>!mapvote start</code> - Start a new map vote
* <code>!mapvote restart</code> - Restarts the map vote.
* <code>!mapvote end</code> - End the map vote and announce the winner.
* <code>!mapvote destroy</code> - End the map vote without announcing the winner.
</p>
<h3>Options</h3>
<h4>layerFilter</h4>
<h6>Description</h6>
<p>The layers players can choose from.</p>
<h6>Default</h6>
<pre><code>layerFilter</code></pre>
<h4>alwaysOn</h4>
<h6>Description</h6>
<p>If true then the map voting system will always be live.</p>
<h6>Default</h6>
<pre><code>true</code></pre>
<h4>minPlayerCount</h4>
<h6>Description</h6>
<p>The minimum number of players required for the vote to succeed.</p>
<h6>Default</h6>
<pre><code>null</code></pre><h6>Example</h6>
<pre><code>10</code></pre>
<h4>minVoteCount</h4>
<h6>Description</h6>
<p>The minimum number of votes required for the vote to succeed.</p>
<h6>Default</h6>
<pre><code>null</code></pre><h6>Example</h6>
<pre><code>5</code></pre>
</details>
<details>
<summary>mysql-log</summary>
<h2>mysql-log</h2>
<p>The <code>mysql-log</code> plugin will log various server statistics and events to a MySQL database. This is great for server performance monitoring and/or player stat tracking.
Installation:
* Obtain/Install MySQL. MySQL v8.x.x has been tested with this plugin and is recommended.
* Enable legacy authentication in your database using [this guide](https://stackoverflow.com/questions/50093144/mysql-8-0-client-does-not-support-authentication-protocol-requested-by-server).
* Execute the [schema](https://github.com/Thomas-Smyth/SquadJS/blob/master/plugins/mysql-log/mysql-schema.sql) to setup the database.
* Add a server to the database with <code>INSERT INTO Server (name) VALUES ("Your Server Name");</code>.
* Find the ID of the server you just inserted with <code>SELECT * FROM Server;</code>.
* Replace the server ID in your config with the ID from the inserted record in the database.
If you encounter any issues you can enable <code>"debug": true</code> in your MySQL connector to get more error logs in the console.
Grafana:
* [Grafana](https://grafana.com/) is a cool way of viewing server statistics stored in the database.
* Install Grafana.
* Add your MySQL database as a datasource named <code>SquadJS - MySQL</code>.
* Import the [SquadJS Dashboard](https://github.com/Thomas-Smyth/SquadJS/blob/master/plugins/mysql-log/SquadJS-Dashboard.json) to get a preconfigured MySQL only Grafana dashboard.
* Install any missing Grafana plugins.</p>
<h3>Options</h3>
<h4>mysqlPool (Required)</h4>
<h6>Description</h6>
<p>The name of the MySQL Pool Connector to use.</p>
<h6>Default</h6>
<pre><code>mysql</code></pre>
<h4>overrideServerID</h4>
<h6>Description</h6>
<p>A overridden server ID.</p>
<h6>Default</h6>
<pre><code>null</code></pre>
</details>
<details>
<summary>seeding-message</summary>
<h2>seeding-message</h2>
<p>The <code>seeding-message</code> plugin broadcasts seeding rule messages to players at regular intervals or after a new player has connected to the server. It can also be configured to display live messages when the server goes live.</p>
<h3>Options</h3>
<h4>mode</h4>
<h6>Description</h6>
<p>Display seeding messages at a set interval or after players join. Either <code>interval</code> or <code>onjoin</code>.</p>
<h6>Default</h6>
<pre><code>interval</code></pre>
<h4>interval</h4>
<h6>Description</h6>
<p>How frequently to display the seeding messages in seconds.</p>
<h6>Default</h6>
<pre><code>150000</code></pre>
<h4>delay</h4>
<h6>Description</h6>
<p>How long to wait after a player joins to display the announcement in seconds.</p>
<h6>Default</h6>
<pre><code>45000</code></pre>
<h4>seedingThreshold</h4>
<h6>Description</h6>
<p>Number of players before the server is considered live.</p>
<h6>Default</h6>
<pre><code>50</code></pre>
<h4>seedingMessage</h4>
<h6>Description</h6>
<p>The seeding message to display.</p>
<h6>Default</h6>
<pre><code>Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!</code></pre>
<h4>liveEnabled</h4>
<h6>Description</h6>
<p>Display a "Live" message when a certain player count is met.</p>
<h6>Default</h6>
<pre><code>true</code></pre>
<h4>liveThreshold</h4>
<h6>Description</h6>
<p>When above the seeding threshold, but within this number "Live" messages are displayed.</p>
<h6>Default</h6>
<pre><code>2</code></pre>
<h4>liveMessage</h4>
<h6>Description</h6>
<p>The "Live" message to display.</p>
<h6>Default</h6>
<pre><code>Live</code></pre>
</details>
<details>
<summary>skipmap</summary>
<h2>skipmap</h2>
<p>The <code>skipmap</code> plugin will allow players to vote via <code>+</code>/<code>-</code> if they wish to skip the current map</p>
<h3>Options</h3>
<h4>command</h4>
<h6>Description</h6>
<p>The name of the command to be used in chat.</p>
<h6>Default</h6>
<pre><code>!skipmap</code></pre>
<h4>voteDuration</h4>
<h6>Description</h6>
<p>How long the vote should go on for.</p>
<h6>Default</h6>
<pre><code>300000</code></pre>
<h4>startTimer</h4>
<h6>Description</h6>
<p>Time before voting is allowed.</p>
<h6>Default</h6>
<pre><code>900000</code></pre>
<h4>endTimer</h4>
<h6>Description</h6>
<p>Time before voting is no longer allowed.</p>
<h6>Default</h6>
<pre><code>1800000</code></pre>
<h4>pastVoteTimer</h4>
<h6>Description</h6>
<p>Time that needs to have passed since the last vote.</p>
<h6>Default</h6>
<pre><code>600000</code></pre>
<h4>minimumVotes</h4>
<h6>Description</h6>
<p>The minimum percentage of people required to vote for the vote to go through.</p>
<h6>Default</h6>
<pre><code>20</code></pre>
<h4>reminderInterval</h4>
<h6>Description</h6>
<p>The time between individual reminders.</p>
<h6>Default</h6>
<pre><code>120000</code></pre>
</details>
<details>
<summary>team-randomizer</summary>
<h2>team-randomizer</h2>
<p>The <code>team-randomizer</code> plugin can be used to randomize teams. It's great for destroying clan stacks or for social events. It can be run by typing <code>!randomize</code> into in-game admin chat.</p>
<h3>Options</h3>
<h4>command</h4>
<h6>Description</h6>
<p>The command used to randomize the teams.</p>
<h6>Default</h6>
<pre><code>!randomize</code></pre>
</details>
## Creating Your Own Plugins

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

@ -15,7 +15,7 @@
"connectors": {
"discord": "Discord Login Token",
"layerFilter": {
"type": "buildFromFilter",
"type": "buildPoolFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
@ -62,154 +62,10 @@
},
"plugins": [
{
"plugin": "auto-tk-warn",
"enabled": true,
"message": "Please apologise for ALL TKs in ALL chat!"
},
{
"plugin": "chat-commands",
"enabled": true,
"commands": [
{
"command": "!squadjs",
"type": "warn",
"response": "This server is powered by SquadJS.",
"ignoreChats": []
}
]
},
{
"plugin": "discord-admin-broadcast",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"color": 16761867
},
{
"plugin": "discord-admin-cam-logs",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"color": 16761867
},
{
"plugin": "discord-chat",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"ignoreChats": [
"ChatSquad"
],
"chatColors": {},
"color": 16761867
},
{
"plugin": "discord-admin-request",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"ignoreChats": [],
"ignorePhrases": [],
"adminPrefix": "!admin",
"pingGroups": [],
"pingDelay": 60000,
"color": 16761867
},
{
"plugin": "discord-debug",
"plugin": "ExamplePlugin",
"enabled": false,
"discordClient": "discord",
"channelID": "",
"events": []
},
{
"plugin": "discord-rcon",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"prependAdminNameInBroadcast": false
},
{
"plugin": "discord-round-winner",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"color": 16761867
},
{
"plugin": "discord-server-status",
"enabled": true,
"discordClient": "discord",
"color": 16761867,
"colorGradient": true,
"connectLink": true,
"command": "!server",
"disableStatus": false
},
{
"plugin": "discord-teamkill",
"enabled": true,
"discordClient": "discord",
"channelID": "",
"teamkillColor": 16761867,
"suicideColor": 16761867,
"ignoreSuicides": false,
"disableSCBL": false
},
{
"plugin": "intervalled-broadcasts",
"enabled": false,
"broadcasts": [
"Server powered by SquadJS."
],
"interval": 300000
},
{
"plugin": "mapvote-123",
"enabled": false,
"minVoteCount": null
},
{
"plugin": "mapvote-did-you-mean",
"enabled": false,
"layerFilter": "layerFilter",
"alwaysOn": true,
"minPlayerCount": null,
"minVoteCount": null
},
{
"plugin": "mysql-log",
"enabled": false,
"mysqlPool": "mysql",
"overrideServerID": null
},
{
"plugin": "seeding-message",
"enabled": true,
"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": "skipmap",
"enabled": false,
"command": "!skipmap",
"voteDuration": 300000,
"startTimer": 900000,
"endTimer": 1800000,
"pastVoteTimer": 600000,
"minimumVotes": 20,
"reminderInterval": 120000
},
{
"plugin": "team-randomizer",
"enabled": true,
"command": "!randomize"
"exampleOption": "A default value.",
"exampleConnector": "discord"
}
]
}

View File

@ -1,18 +0,0 @@
{
"name": "core",
"version": "1.0.0",
"type": "module",
"exports": {
"./squad-layers": "./squad-layers/index.js",
"./utils/print-logo": "./utils/print-logo.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

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

File diff suppressed because it is too large Load Diff

View File

@ -1,281 +0,0 @@
import fs from 'fs';
import SquadLayers, { SquadLayers as SquadLayersClass } from './squad-layers.js';
export default class SquadLayerFilter extends SquadLayersClass {
constructor(layers, activeLayerFilter = null) {
super(layers);
if (activeLayerFilter === null) {
this.activeLayerFilter = null;
} else {
this.activeLayerFilter = {
historyResetTime: 5 * 60 * 60 * 1000,
layerHistoryTolerance: 8,
mapHistoryTolerance: 4,
gamemodeHistoryTolerance: {
// defaults as off
...activeLayerFilter.gamemodeHistoryTolerance
},
gamemodeRepetitiveTolerance: {
// defaults as off
...activeLayerFilter.gamemodeRepetitiveTolerance
},
playerCountComplianceEnabled: true,
factionComplianceEnabled: true,
factionHistoryTolerance: {
// defaults as off
...activeLayerFilter.factionHistoryTolerance
},
factionRepetitiveTolerance: {
// defaults as off
...activeLayerFilter.factionRepetitiveTolerance
},
...activeLayerFilter
};
}
}
static buildFromList(layerNames, activeLayerFilter) {
return new SquadLayerFilter(layerNames, activeLayerFilter);
}
static buildFromDidYouMeanList(layerNames, activeLayerFilter) {
const layers = [];
for (const layerName of layerNames) {
const layer = SquadLayers.getLayerByDidYouMean(layerName, SquadLayers.getLayerNames());
if (layer) layers.push(layer);
}
return new SquadLayerFilter(layers, activeLayerFilter);
}
static buildFromFile(path, activeLayerFilter, delimiter = '\n') {
const lines = fs.readFileSync(path, 'utf8').split(delimiter);
const layers = [];
const validLayerNames = SquadLayers.getLayerNames();
for (const line of lines) {
if (validLayerNames.contains(line)) layers.push(SquadLayers.getLayerByLayerName(line));
}
return new SquadLayerFilter(layers, activeLayerFilter);
}
static buildFromFilter(filter = {}, activeLayerFilter) {
const whitelistedLayers = filter.whitelistedLayers || null;
const blacklistedLayers = filter.blacklistedLayers || null;
const whitelistedMaps = filter.whitelistedMaps || null;
const blacklistedMaps = filter.blacklistedMaps || null;
const whitelistedGamemodes = filter.whitelistedGamemodes || null;
const blacklistedGamemodes = filter.blacklistedGamemodes || ['Training'];
const flagCountMin = filter.flagCountMin || null;
const flagCountMax = filter.flagCountMax || null;
const hasCommander = filter.hasCommander || null;
const hasTanks = filter.hasTanks || null;
const hasHelicopters = filter.hasHelicopters || null;
const layers = [];
for (const layer of SquadLayers.getLayers()) {
// Whitelist / Blacklist Layers
if (whitelistedLayers !== null && !whitelistedLayers.includes(layer.layer)) continue;
if (blacklistedLayers !== null && blacklistedLayers.includes(layer.layer)) continue;
// Whitelist / Blacklist Maps
if (whitelistedMaps !== null && !whitelistedMaps.includes(layer.map)) continue;
if (blacklistedMaps !== null && blacklistedMaps.includes(layer.map)) continue;
// Whitelist / Blacklist Gamemodes
if (whitelistedGamemodes !== null && !whitelistedGamemodes.includes(layer.gamemode)) continue;
if (blacklistedGamemodes !== null && blacklistedGamemodes.includes(layer.gamemode)) continue;
// Flag Count
if (flagCountMin !== null && layer.flagCount < flagCountMin) continue;
if (flagCountMax !== null && layer.flagCount > flagCountMax) continue;
// Other Properties
if (hasCommander !== null && layer.commander !== hasCommander) continue;
if (hasTanks !== null && (layer.tanks !== 'N/A') !== hasTanks) continue;
if (hasHelicopters !== null && (layer.helicopters !== 'N/A') !== hasHelicopters) continue;
layers.push(layer);
}
return new SquadLayerFilter(layers, activeLayerFilter);
}
inLayerPool(layer) {
if (typeof layer === 'object') layer = layer.layer;
return super.getLayerNames().includes(layer);
}
isLayerHistoryCompliant(server, layer) {
if (this.activeLayerFilter === null) return true;
if (typeof layer === 'object') layer = layer.layer;
for (
let i = 0;
i < Math.min(server.layerHistory.length, this.activeLayerFilter.layerHistoryTolerance);
i++
) {
if (new Date() - server.layerHistory[i].time > this.activeLayerFilter.historyResetTime)
return true;
if (server.layerHistory[i].layer === layer) return false;
}
return true;
}
isMapHistoryCompliant(server, layer) {
if (this.activeLayerFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
for (
let i = 0;
i < Math.min(server.layerHistory.length, this.activeLayerFilter.mapHistoryTolerance);
i++
) {
if (new Date() - server.layerHistory[i].time > this.activeLayerFilter.historyResetTime)
return true;
if (server.layerHistory[i].map === layer.map) return false;
}
return true;
}
isGamemodeHistoryCompliant(server, layer) {
if (this.activeLayerFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
const gamemodeHistoryTolerance = this.activeLayerFilter.gamemodeHistoryTolerance[
layer.gamemode
];
if (!gamemodeHistoryTolerance) return true;
for (let i = 0; i < Math.min(server.layerHistory.length, gamemodeHistoryTolerance); i++) {
if (new Date() - server.layerHistory[i].time > this.activeLayerFilter.historyResetTime)
return true;
const historyLayer = SquadLayers.getLayerByLayerName(server.layerHistory[i].layer);
if (!historyLayer) continue;
if (historyLayer.gamemode === layer.gamemode) return false;
}
return true;
}
isGamemodeRepetitiveCompliant(server, layer) {
if (this.activeLayerFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
const gamemodeRepetitiveTolerance = this.activeLayerFilter.gamemodeRepetitiveTolerance[
layer.gamemode
];
if (!gamemodeRepetitiveTolerance) return true;
for (let i = 0; i < Math.min(server.layerHistory.length, gamemodeRepetitiveTolerance); i++) {
if (new Date() - server.layerHistory[i].time > this.activeLayerFilter.historyResetTime)
return true;
const historyLayer = SquadLayers.getLayerByLayerName(server.layerHistory[i].layer);
if (!historyLayer) return true;
if (historyLayer.gamemode !== layer.gamemode) return true;
}
return false;
}
isFactionCompliant(server, layer) {
if (
this.activeLayerFilter === null ||
this.activeLayerFilter.factionComplianceEnabled === false
)
return true;
if (server.layerHistory.length === 0) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
const historyLayer = SquadLayers.getLayerByLayerName(server.layerHistory[0].layer);
return (
!historyLayer ||
(historyLayer.teamOne.faction !== layer.teamTwo.faction &&
historyLayer.teamTwo.faction !== layer.teamOne.faction)
);
}
isFactionHistoryCompliant(server, layer, faction = null) {
if (this.activeLayerFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
if (faction === null) {
return (
this.isFactionHistoryCompliant(server, layer, layer.teamOne.faction) &&
this.isFactionHistoryCompliant(server, layer, layer.teamTwo.faction)
);
} else {
const factionThreshold = this.activeLayerFilter.factionHistoryTolerance[faction];
if (!factionThreshold) return true;
for (let i = 0; i < Math.min(server.layerHistory.length, factionThreshold); i++) {
if (new Date() - server.layerHistory[i].time > this.activeLayerFilter.historyResetTime)
return true;
const historyLayer = SquadLayers.getLayerByLayerName(server.layerHistory[i].layer);
if (!historyLayer) continue;
if (historyLayer.teamOne.faction === faction || historyLayer.teamTwo.faction === faction)
return false;
}
return true;
}
}
isFactionRepetitiveCompliant(server, layer, faction = null) {
if (this.activeLayerFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
if (faction === null) {
return (
this.isFactionRepetitiveCompliant(server, layer, layer.teamOne.faction) &&
this.isFactionRepetitiveCompliant(server, layer, layer.teamTwo.faction)
);
} else {
const factionThreshold = this.activeLayerFilter.factionRepetitiveTolerance[faction];
if (!factionThreshold) return true;
for (let i = 0; i < Math.min(server.layerHistory.length, factionThreshold); i++) {
if (new Date() - server.layerHistory[i].time > this.activeLayerFilter.historyResetTime)
return true;
const historyLayer = SquadLayers.getLayerByLayerName(server.layerHistory[i].layer);
if (!historyLayer) return true;
if (historyLayer.teamOne.faction !== faction && historyLayer.teamTwo.faction !== faction)
return true;
}
return false;
}
}
isPlayerCountCompliant(server, layer) {
if (
this.activeLayerFilter === null ||
this.activeLayerFilter.playerCountComplianceEnabled === false
)
return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
return !(
server.players.length > layer.estimatedSuitablePlayerCount.max ||
server.players.length < layer.estimatedSuitablePlayerCount.min
);
}
}

View File

@ -1,57 +0,0 @@
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(path.resolve(__dirname, './layers.json'), 'utf8'));
}
for (let i = 0; i < this.layers.length; i++) {
this.layers[i] = {
...this.layers[i],
layerNumber: i + 1
};
}
}
getLayers() {
return this.layers;
}
getLayerNames() {
return this.layers.map((layer) => layer.layer);
}
getLayerByLayerName(layerName) {
const layer = this.layers.filter((layer) => layer.layer === layerName);
return layer.length === 1 ? layer[0] : null;
}
getLayerByLayerClassname(layerClassname) {
const layer = this.layers.filter((layer) => layer.layerClassname === layerClassname);
return layer.length === 1 ? layer[0] : null;
}
getLayerByDidYouMean(layerName) {
layerName = didYouMean(layerName, this.getLayerNames());
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);
return layer.length === 1 ? layer[0] : null;
}
}
export { SquadLayers };
export default new SquadLayers();

View File

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

View File

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

View File

@ -1,51 +0,0 @@
import Discord from 'discord.js';
import mysql from 'mysql';
import { SquadLayerFilter } from 'core/squad-layers';
import plugins from 'plugins';
const connectorTypes = {
discordClient: async function (config) {
console.log('Starting discordClient connector...');
const client = new Discord.Client();
await client.login(config);
return client;
},
mysqlPool: async function (config) {
console.log('Starting mysqlPool connector...');
return mysql.createPool(config);
},
layerFilter: async function (config) {
console.log('Starting layerFilter connector...');
return SquadLayerFilter[config.type](config.filter, config.activeLayerFilter);
}
};
export default async function (config) {
const connectors = {};
for (const pluginConfig of config.plugins) {
if (!pluginConfig.enabled) continue;
const plugin = plugins[pluginConfig.plugin];
for (const optionName of Object.keys(plugin.optionsSpec)) {
// check it's a connector
if (!Object.keys(connectorTypes).includes(optionName)) continue;
// check if connector is already setup
if (connectors[pluginConfig[optionName]]) continue;
// check config for connector is present
if (!config.connectors[pluginConfig[optionName]])
throw new Error(`${pluginConfig[optionName]} connector config not present!`);
// initiate connector
connectors[pluginConfig[optionName]] = await connectorTypes[optionName](
config.connectors[pluginConfig[optionName]]
);
}
}
return connectors;
}

View File

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

View File

@ -1,48 +0,0 @@
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.enabled) 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 (['discordClient', 'mysqlPool', 'layerFilter'].includes(optionName)) {
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;
}

View File

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

@ -1,7 +1,13 @@
import printLogo from 'core/utils/print-logo';
import buildSquadJS from 'factory';
import SquadServer from 'squad-server';
import printLogo from 'squad-server/logo';
const configPath = process.argv[2] || './config.json';
printLogo();
buildSquadJS()
.then((server) => server.watch())
.catch(console.log);
SquadServer.buildFromConfig(configPath)
.then((server) => {
return server.watch();
})
.then(() => {
console.log('Watching...');
});

View File

@ -1,24 +1,24 @@
import EventEmitter from 'events';
import async from 'async';
import moment from 'moment';
import Server from '../index.js';
import TailLogReader from './log-readers/tail.js';
import FTPLogReader from './log-readers/ftp.js';
import rules from './rules/index.js';
export default class LogParser {
constructor(options = {}, server) {
if (!(server instanceof Server)) throw new Error('Server not an instance of a SquadJS server.');
this.server = server;
export default class LogParser extends EventEmitter {
constructor(options = {}) {
super();
this.eventStore = {};
this.queueLine = this.queueLine.bind(this);
this.handleLine = this.handleLine.bind(this);
this.queue = async.queue(this.handleLine);
switch (options.logReaderMode || 'tail') {
switch (options.mode || 'tail') {
case 'tail':
this.logReader = new TailLogReader(this.queueLine, options);
break;

View File

@ -0,0 +1,37 @@
import path from 'path';
import FTPTail from 'ftp-tail';
export default class TailLogReader {
constructor(queueLine, options = {}) {
for (const option of ['host', 'user', 'password', 'logDir'])
if (!(option in options)) throw new Error(`${option} must be specified.`);
this.reader = new FTPTail({
host: options.host,
port: options.port || 21,
user: options.user,
password: options.password,
secure: options.secure || false,
timeout: options.timeout || 2000,
encoding: 'utf8',
verbose: options.verbose,
path: path.join(options.logDir, 'SquadGame.log'),
fetchInterval: options.fetchInterval || 0,
maxTempFileSize: options.maxTempFileSize || 5 * 1000 * 1000 // 5 MB
});
if (typeof queueLine !== 'function')
throw new Error('queueLine argument must be specified and be a function.');
this.reader.on('line', queueLine);
}
async watch() {
await this.reader.watch();
}
async unwatch() {
await this.reader.unwatch();
}
}

View File

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

View File

@ -1,13 +1,13 @@
{
"name": "factory",
"name": "log-parser",
"version": "1.0.0",
"type": "module",
"dependencies": {
"core": "1.0.0",
"discord.js": "^12.2.0",
"mysql": "^2.18.1"
},
"exports": {
".": "./index.js"
},
"dependencies": {
"ftp-tail": "^1.0.2",
"moment": "^2.29.0",
"tail": "^2.0.4"
}
}

View File

@ -1,5 +1,3 @@
import { ADMIN_BROADCAST } from '../../events.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquad: ADMIN COMMAND: Message broadcasted <(.+)> from (.+)/,
onMatch: (args, logParser) => {
@ -11,6 +9,6 @@ export default {
from: args[4]
};
logParser.server.emit(ADMIN_BROADCAST, data);
logParser.emit('ADMIN_BROADCAST', data);
}
};

View File

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

View File

@ -0,0 +1,14 @@
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogNet: Join succeeded: (.+)/,
onMatch: async (args, logParser) => {
const data = {
raw: args[0],
time: args[1],
chainID: args[2],
playerSuffix: args[3],
steamID: logParser.eventStore['steamid-connected']
};
logParser.emit('PLAYER_CONNECTED', data);
}
};

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { PLAYER_REVIVED } from '../../events.js';
export default {
// the names are currently the wrong way around in these logs
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquad: (.+) has revived (.+)\./,
@ -9,10 +7,10 @@ export default {
raw: args[0],
time: args[1],
chainID: args[2],
victim: await logParser.server.getPlayerByName(args[4]),
reviver: await logParser.server.getPlayerByName(args[3])
reviverName: args[3],
victimName: args[4]
};
logParser.server.emit(PLAYER_REVIVED, data);
logParser.emit('PLAYER_REVIVED', data);
}
};

View File

@ -1,5 +1,3 @@
import { PLAYER_UNPOSSESS } from '../../events.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquadTrace: \[DedicatedServer](?:ASQPlayerController::)?OnUnPossess\(\): PC=(.+)/,
onMatch: async (args, logParser) => {
@ -7,14 +5,12 @@ export default {
raw: args[0],
time: args[1],
chainID: args[2],
player: await logParser.server.getPlayerByName(args[3], true),
switchPossess: false
playerSuffix: args[3],
switchPossess: args[3] in logParser.eventStore && logParser.eventStore[args[3]] === args[2]
};
if (args[3] in logParser.eventStore && logParser.eventStore[args[3]] === args[2])
data.switchPossess = true;
delete logParser.eventStore[args[3]];
logParser.server.emit(PLAYER_UNPOSSESS, data);
logParser.emit('PLAYER_UNPOSSESS', data);
}
};

View File

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

View File

@ -1,5 +1,3 @@
import { TICK_RATE } from '../../events.js';
export default {
regex: /^\[([0-9.:-]+)]\[([ 0-9]*)]LogSquad: USQGameState: Server Tick Rate: ([0-9.]+)/,
onMatch: (args, logParser) => {
@ -10,6 +8,6 @@ export default {
tickRate: parseFloat(args[3])
};
logParser.server.emit(TICK_RATE, data);
logParser.emit('TICK_RATE', data);
}
};

View File

@ -9,19 +9,22 @@
"assets",
"core",
"factory",
"log-parser",
"plugins",
"rcon",
"squad-server"
],
"scripts": {
"lint": "eslint --fix . && prettier --write \"./**/*.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"
"build-config": "node core/build-config.js",
"build-readme": "node core/build-readme.js",
"build-all": "node core/build-config.js && node factory/build-readme.js"
},
"type": "module",
"dependencies": {
"core": "1.0.0",
"factory": "1.0.0"
"factory": "1.0.0",
"squad-server": "1.0.0"
},
"devDependencies": {
"eslint": "^7.6.0",

View File

@ -1,26 +0,0 @@
import { TEAMKILL } from 'squad-server/events';
export default {
name: 'auto-tk-warn',
description:
'The <code>auto-tk-warn</code> plugin will automatically warn players in game to apologise for teamkills when ' +
'they teamkill another player.',
defaultEnabled: true,
optionsSpec: {
message: {
required: false,
description: 'The message to warn players with.',
default: 'Please apologise for ALL TKs in ALL chat!',
example: 'Test'
}
},
init: async (server, options) => {
server.on(TEAMKILL, (info) => {
// ignore suicides
if (info.attacker.steamID === info.victim.steamID) return;
server.rcon.warn(info.attacker.steamID, options.message);
});
}
};

View File

@ -1,49 +0,0 @@
import { CHAT_MESSAGE } from 'squad-server/events';
export default {
name: 'chat-commands',
description:
'The <code>chat-command</code> plugin can be configured to make chat commands that broadcast or warn the caller ' +
'with present messages.',
defaultEnabled: true,
optionsSpec: {
commands: {
required: false,
description:
'An array of objects containing the following properties: ' +
'<ul>' +
'<li><code>command</code> - The command that initiates the message.</li>' +
'<li><code>type</code> - Either <code>warn</code> or <code>broadcast</code>.</li>' +
'<li><code>response</code> - The message to respond with.</li>' +
'<li><code>ignoreChats</code> - A list of chats to ignore the commands in. Use this to limit it to admins.</li>' +
'</ul>',
default: [
{
command: '!squadjs',
type: 'warn',
response: 'This server is powered by SquadJS.',
ignoreChats: []
}
]
}
},
init: async (server, options) => {
server.on(CHAT_MESSAGE, (info) => {
// loop through all possibilities
for (const command of options.commands) {
// check if message is a command
if (!info.message.startsWith(command.command)) continue;
// check if ignored channel
if (command.ignoreChats.includes(info.chat)) continue;
// React to command with either a broadcast or a warning
if (command.type === 'broadcast') {
server.rcon.broadcast(command.response);
} else if (command.type === 'warn') {
server.rcon.warn(info.steamID, command.response);
}
}
});
}
};

View File

@ -1,52 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { ADMIN_BROADCAST } from 'squad-server/events';
export default {
name: 'discord-admin-broadcast',
description:
'The <code>discord-admin-broadcast</code> plugin will send a copy of admin broadcasts made in game to a Discord ' +
'channel.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin broadcasts to.',
default: '',
example: '667741905228136459'
},
color: {
required: false,
description: 'The color of the embed.',
default: 16761867
}
},
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(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
}
}
});
});
}
};

View File

@ -1,98 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { PLAYER_POSSESS, PLAYER_UNPOSSESS } from 'squad-server/events';
export default {
name: 'discord-admin-cam-logs',
description:
'The <code>discord-admin-cam-logs</code> plugin will log in game admin camera usage to a Discord channel.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin cam usage to.',
default: '',
example: '667741905228136459'
},
color: {
required: false,
description: 'The color of the embed.',
default: 16761867
}
},
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
const adminsInCam = {};
server.on(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
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
server.on(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

@ -1,129 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { CHAT_MESSAGE } from 'squad-server/events';
export default {
name: 'discord-admin-request',
description:
'The <code>discord-admin-request</code> plugin will ping admins in a Discord channel when a player requests an ' +
'admin via the <code>!admin</code> command in in-game chat.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin broadcasts to.',
default: '',
example: '667741905228136459'
},
ignoreChats: {
required: false,
description: 'A list of chat names to ignore.',
default: [],
example: ['ChatSquad']
},
ignorePhrases: {
required: false,
description: 'A list of phrases to ignore.',
default: [],
example: ['switch']
},
adminPrefix: {
required: false,
description: 'The command that calls an admin.',
default: '!admin'
},
pingGroups: {
required: false,
description: 'A list of Discord role IDs to ping.',
default: [],
example: ['500455137626554379']
},
pingDelay: {
required: false,
description: 'Cooldown for pings in milliseconds.',
default: 60 * 1000
},
color: {
required: false,
description: 'The color of the embed.',
default: 16761867
}
},
init: async (server, options) => {
let lastPing = null;
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(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,79 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { CHAT_MESSAGE } from 'squad-server/events';
export default {
name: 'discord-chat',
description: 'The <code>discord-chat</code> plugin will log in-game chat to a Discord channel.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin broadcasts to.',
default: '',
example: '667741905228136459'
},
ignoreChats: {
required: false,
default: ['ChatSquad'],
description: 'A list of chat names to ignore.'
},
chatColors: {
required: false,
description: 'The color of the embed for each chat.',
default: {},
example: { ChatAll: 16761867 }
},
color: {
required: false,
description: 'The color of the embed.',
default: 16761867
}
},
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(CHAT_MESSAGE, async (info) => {
if (options.ignoreChats.includes(info.chat)) return;
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}`
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
}
};

View File

@ -1,37 +0,0 @@
export default {
name: 'discord-debug',
description:
'The <code>discord-debug</code> plugin can be used to help debug SquadJS by dumping SquadJS events to a ' +
'Discord channel.',
defaultEnabled: false,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin broadcasts to.',
default: '',
example: '667741905228136459'
},
events: {
required: true,
description: 'A list of events to dump.',
default: [],
example: ['PLAYER_DIED']
}
},
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
for (const event of options.events) {
server.on(event, (info) => {
channel.send(`\`\`\`${JSON.stringify({ ...info, event }, null, 2)}\`\`\``);
});
}
}
};

View File

@ -1,58 +0,0 @@
export default {
name: 'discord-rcon',
description:
'The <code>discord-rcon</code> plugin allows a specified Discord channel to be used as a RCON console to run ' +
'RCON commands.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel you wish to turn into a RCON console.',
default: '',
example: '667741905228136459'
},
prependAdminNameInBroadcast: {
required: false,
description: "Prepend the admin's name when he makes an announcement.",
default: false
}
},
init: async (server, options) => {
options.discordClient.on('message', async (message) => {
if (message.author.bot || message.channel.id !== options.channelID) return;
let command = message.content;
if (options.prependAdminNameInBroadcast && command.toLowerCase().startsWith('adminbroadcast'))
command = command.replace(
/^AdminBroadcast /i,
`AdminBroadcast ${message.member.displayName}: `
);
const response = await server.rcon.execute(command);
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 responseMessage of responseMessages) {
await message.channel.send(`\`\`\`${responseMessage}\`\`\``);
}
});
}
};

View File

@ -1,50 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { NEW_GAME } from 'squad-server/events';
export default {
name: 'discord-round-winner',
description: 'The `discord-round-winner` plugin will send the round winner to a Discord channel.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin broadcasts to.',
default: '',
example: '667741905228136459'
},
color: {
required: false,
description: 'The color of the embed.',
default: 16761867
}
},
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(NEW_GAME, async (info) => {
channel.send({
embed: {
title: 'Round Winner',
color: options.color,
fields: [
{
name: 'Message',
value: `${info.winner} won on ${info.layer}`
}
],
timestamp: info.time.toISOString(),
footer: {
text: COPYRIGHT_MESSAGE
}
}
});
});
}
};

View File

@ -1,133 +0,0 @@
import tinygradient from 'tinygradient';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { A2S_INFO_UPDATED } from 'squad-server/events';
export default {
name: 'discord-server-status',
description:
'The <code>discord-server-status</code> plugin displays a server status embed to Discord when someone uses the ' +
'<code>!server</code> command in a Discord channel.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
color: {
required: false,
description: 'The color code of the Discord embed.',
default: 16761867
},
colorGradient: {
required: false,
description: 'Apply gradient color to Discord embed depending on the player count.',
default: true
},
connectLink: {
required: false,
description: 'Display a Steam server connection link.',
default: true
},
command: {
required: false,
description: 'The command that displays the embed.',
default: '!server'
},
disableStatus: {
required: false,
description: 'Disable the bot status.',
default: false
}
},
init: async (server, options) => {
options.discordClient.on('message', async (message) => {
if (message.content !== options.command) return;
const serverStatus = await message.channel.send(makeEmbed(server, options));
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;
// ignore bots reacting
if (reaction.count === 1) return;
// remove reaction and readd it
await reaction.remove();
await reaction.message.react('🔄');
// update the message
await reaction.message.edit(makeEmbed(server, options));
});
server.on(A2S_INFO_UPDATED, () => {
if (!options.disableStatus)
options.discordClient.user.setActivity(
`(${server.playerCount}/${server.publicSlots}) ${server.currentLayer}`,
{ type: 'WATCHING' }
);
});
}
};
const gradient = tinygradient([
{ color: '#ff0000', pos: 0 },
{ color: '#ffff00', pos: 0.5 },
{ color: '#00ff00', pos: 1 }
]);
function makeEmbed(server, options) {
let players = `${server.playerCount}`;
if (server.publicQueue + server.reserveQueue > 0)
players += ` (+${server.publicQueue + server.reserveQueue})`;
players += ` / ${server.publicSlots}`;
if (server.reserveSlots > 0) players += ` (+${server.reserveSlots})`;
const fields = [
{
name: 'Players',
value: `\`\`\`${players}\`\`\``
},
{
name: 'Current Layer',
value: `\`\`\`${server.currentLayer}\`\`\``,
inline: true
},
{
name: 'Next Layer',
value: `\`\`\`${server.nextLayer || 'Unknown'}\`\`\``,
inline: true
}
];
if (options.connectLink)
fields.push({
name: 'Join Server',
value: `steam://connect/${server.host}:${server.queryPort}`
});
return {
embed: {
title: server.serverName,
color: options.colorGradient
? parseInt(gradient.rgbAt(server.playerCount / server.publicSlots).toHex(), 16)
: options.color,
fields: fields,
timestamp: new Date().toISOString(),
footer: {
text: `Server Status by ${COPYRIGHT_MESSAGE}`
}
}
};
}

View File

@ -1,97 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { TEAMKILL } from 'squad-server/events';
export default {
name: 'discord-teamkill',
description:
'The <code>discord-teamkill</code> plugin logs teamkills and related information to a Discord channel for admin to review.',
defaultEnabled: true,
optionsSpec: {
discordClient: {
required: true,
description: 'The name of the Discord Connector to use.',
default: 'discord'
},
channelID: {
required: true,
description: 'The ID of the channel to log admin broadcasts to.',
default: '',
example: '667741905228136459'
},
teamkillColor: {
required: false,
description: 'The color of the embed for teamkills.',
default: 16761867
},
suicideColor: {
required: false,
description: 'The color of the embed for suicides.',
default: 16761867
},
ignoreSuicides: {
required: false,
description: 'Ignore suicides.',
default: false
},
disableSCBL: {
required: false,
description: 'Disable Squad Community Ban List information.',
default: false
}
},
init: async (server, options) => {
const channel = await options.discordClient.channels.fetch(options.channelID);
server.on(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
}
}
});
});
}
};

View File

@ -1,66 +0,0 @@
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-admin-request/index.js';
import discordDebug from './discord-debug/index.js';
import discordRCON from './discord-rcon/index.js';
import discordRoundWinner from './discord-round-winner/index.js';
import discordServerStatus from './discord-server-status/index.js';
import discordTeamkill from './discord-teamkill/index.js';
import intervalledBroadcasts from './intervalled-broadcasts/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 skipMap from './skipmap/index.js';
import teamRandomizer from './team-randomizer/index.js';
import chatCommands from './chat-commands/index.js';
export {
autoTKWarn,
chatCommands,
discordAdminBroadcast,
discordAdminCamLogs,
discordChat,
discordChatAdminRequest,
discordDebug,
discordRCON,
discordRoundWinner,
discordServerStatus,
discordTeamkill,
intervalledBroadcasts,
mapvote123,
mapvoteDidYouMean,
mysqlLog,
seedingMessage,
skipMap,
teamRandomizer
};
const plugins = [
autoTKWarn,
chatCommands,
discordAdminBroadcast,
discordAdminCamLogs,
discordChat,
discordChatAdminRequest,
discordDebug,
discordRCON,
discordRoundWinner,
discordServerStatus,
discordTeamkill,
intervalledBroadcasts,
mapvote123,
mapvoteDidYouMean,
mysqlLog,
seedingMessage,
skipMap,
teamRandomizer
];
const namedPlugins = {};
for (const plugin of plugins) {
namedPlugins[plugin.name] = plugin;
}
export default namedPlugins;

View File

@ -1,27 +0,0 @@
export default {
name: 'intervalled-broadcasts',
description:
'The `intervalled-broadcasts` plugin allows you to set broadcasts, which will be broadcasted at preset intervals',
defaultEnabled: false,
optionsSpec: {
broadcasts: {
required: false,
description: 'The broadcasted messages.',
default: ['Server powered by SquadJS.']
},
interval: {
required: false,
description: 'How frequently to broadcast in seconds.',
default: 5 * 60 * 1000
}
},
init: async (server, options) => {
setInterval(() => {
server.rcon.broadcast(options.broadcasts[0]);
options.broadcasts.push(options.broadcasts.shift());
}, options.interval);
}
};

View File

@ -1,178 +0,0 @@
import { SquadLayerFilter } from 'core/squad-layers';
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { NEW_GAME, CHAT_MESSAGE } from 'squad-server/events';
import MapVote from './mapvote.js';
export default {
name: 'mapvote-123',
description:
'The <code>mapvote-123</code> plugin provides map voting functionality. This variant of map voting allows admins to specify ' +
'a small number of maps which are numbered and announced in admin broadcasts. Players can then vote for the map ' +
'their choice by typing the corresponding map number into chat.' +
'\n\n' +
'Player Commands:\n' +
' * <code>!mapvote help</code> - Show other commands players can use.\n' +
' * <code>!mapvote results</code> - Show the results of the current map vote.\n' +
' * <code><layer number></code> - Vote for a layer using the layer number.\n' +
'\n\n' +
'Admin Commands (Admin Chat Only):\n' +
' * <code>!mapvote start <layer name 1>, <layer name 2>, ...</code> - Start a new map vote with the specified maps.\n' +
' * <code>!mapvote restart</code> - Restarts the map vote with the same layers.\n' +
' * <code>!mapvote end</code> - End the map vote and announce the winner.\n' +
' * <code>!mapvote destroy</code> - End the map vote without announcing the winner.\n',
defaultEnabled: false,
optionsSpec: {
minVoteCount: {
required: false,
description: 'The minimum number of votes required for the vote to succeed.',
default: null,
example: 3
}
},
init: async (server, options) => {
let mapvote = null;
server.on(NEW_GAME, () => {
mapvote = null;
});
server.on(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, 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.`
);
});
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 (!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}`);
}
if (options.minVoteCount !== null)
await server.rcon.warn(
info.steamID,
`${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,180 +0,0 @@
import { COPYRIGHT_MESSAGE } from 'core/constants';
import { NEW_GAME, CHAT_MESSAGE } from 'squad-server/events';
import MapVote from './mapvote.js';
export default {
name: 'mapvote-did-you-mean',
description:
'The <code>mapvote-did-you-mean</code> plugin provides map voting functionality. This variant of map voting uses a "Did you ' +
'mean?" algorithm to allow players to easily select one of a large pool of layers by typing it\'s name into ' +
'the in-game chat.' +
'\n\n' +
'Player Commands:\n' +
' * <code>!mapvote help</code> - Show other commands players can use.\n' +
' * <code>!mapvote results</code> - Show the results of the current map vote.\n' +
' * <code>!mapvote <layer name></code> - Vote for the specified layer. Misspelling will be corrected where possible.\n' +
'\n\n' +
'Admin Commands (Admin Chat Only):\n' +
' * <code>!mapvote start</code> - Start a new map vote\n' +
' * <code>!mapvote restart</code> - Restarts the map vote.\n' +
' * <code>!mapvote end</code> - End the map vote and announce the winner.\n' +
' * <code>!mapvote destroy</code> - End the map vote without announcing the winner.\n',
defaultEnabled: false,
optionsSpec: {
layerFilter: {
required: false,
description: 'The layers players can choose from.',
default: 'layerFilter'
},
alwaysOn: {
required: false,
description: 'If true then the map voting system will always be live.',
default: true
},
minPlayerCount: {
required: false,
description: 'The minimum number of players required for the vote to succeed.',
default: null,
example: 10
},
minVoteCount: {
required: false,
description: 'The minimum number of votes required for the vote to succeed.',
default: null,
example: 5
}
},
init: async (server, options) => {
let mapvote;
let manuallyCreated;
async function newMapvote(manuallyCreatedOption = true) {
mapvote = new MapVote(server, options.layerFilter, {
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(NEW_GAME, () => {
if (options.alwaysOn) {
newMapvote(false);
} else {
mapvote = null;
}
});
server.on(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,
'!mapvote <layer name> - Vote for the specified layer.'
);
await server.rcon.warn(
info.steamID,
'When inputting a layer name, we autocorrect any miss spelling.'
);
if (options.minVoteCount !== null)
await server.rcon.warn(
info.steamID,
`${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 (!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) {
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, COPYRIGHT_MESSAGE);
});
}
};

View File

@ -1,117 +0,0 @@
import EventEmitter from 'events';
import { SquadLayers } from 'core/squad-layers';
export default class MapVote extends EventEmitter {
constructor(server, squadLayerFilter, options = {}) {
super();
this.server = server;
this.squadLayerFilter = squadLayerFilter;
this.layerVotes = {};
this.layerVoteTimes = {};
this.playerVotes = {};
this.currentWinner = null;
this.minVoteCount = options.minVoteCount || null;
}
addVote(identifier, layerName) {
if (this.layerVotes[layerName]) {
this.layerVotes[layerName] += 1;
} else {
this.layerVotes[layerName] = 1;
this.layerVoteTimes[layerName] = new Date();
}
this.playerVotes[identifier] = layerName;
}
removeVote(identifier) {
if (!this.playerVotes[identifier]) return;
if (this.layerVotes[this.playerVotes[identifier]])
this.layerVotes[this.playerVotes[identifier]] -= 1;
if (this.layerVotes[this.playerVotes[identifier]] === 0) {
delete this.layerVotes[this.playerVotes[identifier]];
delete this.layerVoteTimes[this.playerVotes[identifier]];
}
delete this.playerVotes[identifier];
}
getResults(applyMinVoteCount = false) {
if (
!applyMinVoteCount ||
this.minVoteCount === null ||
Object.keys(this.playerVotes).length >= this.minVoteCount
) {
return Object.keys(this.layerVotes)
.map((layerName) => ({
layer: this.squadLayerFilter.getLayerByLayerName(layerName),
votes: this.layerVotes[layerName]
}))
.sort((a, b) => {
if (a.votes > b.votes) return -1;
if (a.votes < b.votes) return 1;
return this.layerVoteTimes[a.layer.layer] < this.layerVoteTimes[b.layer.layer] ? -1 : 1;
});
} else return [];
}
async makeVote(identifier, layer) {
layer = SquadLayers.getLayerByLayerName(layer);
if (!this.squadLayerFilter.inLayerPool(layer))
throw new Error(`${layer.layer} is not in layer pool.`);
if (!this.squadLayerFilter.isLayerHistoryCompliant(this.server, layer))
throw new Error(`${layer.layer} was played too recently.`);
if (!this.squadLayerFilter.isMapHistoryCompliant(this.server, layer))
throw new Error(`${layer.map} was played too recently.`);
if (!this.squadLayerFilter.isGamemodeHistoryCompliant(this.server, layer))
throw new Error(`${layer.gamemode} was played too recently.`);
if (!this.squadLayerFilter.isGamemodeRepetitiveCompliant(this.server, layer))
throw new Error(`${layer.gamemode} has been played too much recently.`);
if (!this.squadLayerFilter.isFactionCompliant(this.server, layer))
throw new Error('Cannot be played as one team will remain the same faction.');
if (!this.squadLayerFilter.isFactionHistoryCompliant(this.server, layer))
throw new Error(
`Cannot be played as either ${layer.teamOne.faction} or ${layer.teamTwo.faction} has been played too recently.`
);
if (!this.squadLayerFilter.isFactionRepetitiveCompliant(this.server, layer))
throw new Error(
`Cannot be played as either ${layer.teamOne.faction} or ${layer.teamTwo.faction} has been played too much recently.`
);
if (!this.squadLayerFilter.isPlayerCountCompliant(this.server, layer))
throw new Error(
`${layer.layer} is only suitable for a player count between ${layer.estimatedSuitablePlayerCount.min} and ${layer.estimatedSuitablePlayerCount.max}.`
);
this.removeVote(identifier);
this.addVote(identifier, layer.layer);
const results = this.getResults(true);
if (results.length > 0) {
if (results[0].layer.layer !== this.currentWinner) {
await this.server.rcon.execute(`AdminSetNextMap ${results[0].layer.layer}`);
this.emit('NEW_WINNER', results);
this.currentWinner = results[0].layer.layer;
}
}
return layer.layer;
}
async makeVoteByDidYouMean(identifier, layerName) {
const layer = SquadLayers.getLayerByDidYouMean(layerName);
if (layer === null) throw new Error(`${layerName} is not a Squad layer.`);
return this.makeVote(identifier, layer.layer);
}
async makeVoteByNumber(identifier, number) {
const layer = this.squadLayerFilter.getLayerByNumber(number);
return this.makeVote(identifier, layer.layer);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,137 +0,0 @@
import {
NEW_GAME,
PLAYER_WOUNDED,
PLAYER_DIED,
PLAYER_REVIVED,
TICK_RATE,
PLAYERS_UPDATED
} from 'squad-server/events';
export default {
name: 'mysql-log',
description:
'The <code>mysql-log</code> plugin will log various server statistics and events to a MySQL database. This is great for ' +
'server performance monitoring and/or player stat tracking.' +
'\n\n' +
'Installation:\n' +
' * Obtain/Install MySQL. MySQL v8.x.x has been tested with this plugin and is recommended.\n' +
' * Enable legacy authentication in your database using [this guide](https://stackoverflow.com/questions/50093144/mysql-8-0-client-does-not-support-authentication-protocol-requested-by-server).\n' +
' * Execute the [schema](https://github.com/Thomas-Smyth/SquadJS/blob/master/plugins/mysql-log/mysql-schema.sql) to setup the database.\n' +
' * Add a server to the database with <code>INSERT INTO Server (name) VALUES ("Your Server Name");</code>.\n' +
' * Find the ID of the server you just inserted with <code>SELECT * FROM Server;</code>.\n' +
' * Replace the server ID in your config with the ID from the inserted record in the database.\n' +
'\n\n' +
'If you encounter any issues you can enable <code>"debug": true</code> in your MySQL connector to get more error logs in the console.\n' +
'\n\n' +
'Grafana:\n' +
' * [Grafana](https://grafana.com/) is a cool way of viewing server statistics stored in the database.\n' +
' * Install Grafana.\n' +
' * Add your MySQL database as a datasource named <code>SquadJS - MySQL</code>.\n' +
' * Import the [SquadJS Dashboard](https://github.com/Thomas-Smyth/SquadJS/blob/master/plugins/mysql-log/SquadJS-Dashboard.json) to get a preconfigured MySQL only Grafana dashboard.\n' +
' * Install any missing Grafana plugins.',
defaultEnabled: false,
optionsSpec: {
mysqlPool: {
required: true,
description: 'The name of the MySQL Pool Connector to use.',
default: 'mysql'
},
overrideServerID: {
required: false,
description: 'A overridden server ID.',
default: null
}
},
init: async (server, options) => {
const serverID = options.overrideServerID === null ? server.id : options.overrideServerID;
server.on(TICK_RATE, (info) => {
options.mysqlPool.query(
'INSERT INTO ServerTickRate(time, server, tick_rate) VALUES (?,?,?)',
[info.time, serverID, info.tickRate]
);
});
server.on(PLAYERS_UPDATED, (players) => {
options.mysqlPool.query(
'INSERT INTO PlayerCount(time, server, player_count) VALUES (NOW(),?,?)',
[serverID, players.length]
);
});
server.on(NEW_GAME, (info) => {
options.mysqlPool.query('call NewMatch(?,?,?,?,?,?,?,?)', [
serverID,
info.time,
info.dlc,
info.mapClassname,
info.layerClassname,
info.map,
info.layer,
info.winner
]);
});
server.on(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(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(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

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

View File

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

View File

@ -1,98 +0,0 @@
import { PLAYER_CONNECTED } from 'squad-server/events';
export default {
name: 'seeding-message',
description:
'The <code>seeding-message</code> plugin broadcasts seeding rule messages to players at regular intervals or ' +
'after a new player has connected to the server. It can also be configured to display live messages when the ' +
'server goes live.',
defaultEnabled: true,
optionsSpec: {
mode: {
required: false,
description:
'Display seeding messages at a set interval or after players join. Either <code>interval</code> or <code>onjoin</code>.',
default: 'interval'
},
interval: {
required: false,
description: 'How frequently to display the seeding messages in seconds.',
default: 150 * 1000
},
delay: {
required: false,
description: 'How long to wait after a player joins to display the announcement in seconds.',
default: 45 * 1000
},
seedingThreshold: {
required: false,
description: 'Number of players before the server is considered live.',
default: 50
},
seedingMessage: {
required: false,
description: 'The seeding message to display.',
default: 'Seeding Rules Active! Fight only over the middle flags! No FOB Hunting!'
},
liveEnabled: {
required: false,
description: 'Display a "Live" message when a certain player count is met.',
default: true
},
liveThreshold: {
required: false,
description:
'When above the seeding threshold, but within this number "Live" messages are displayed.',
default: 2
},
liveMessage: {
required: false,
description: 'The "Live" message to display.',
default: 'Live'
}
},
init: async (server, options) => {
switch (options.mode) {
case 'interval':
setInterval(() => {
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.interval);
break;
case 'onjoin':
server.on(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,198 +0,0 @@
import { CHAT_MESSAGE, NEW_GAME } from 'squad-server/events';
import { COPYRIGHT_MESSAGE } from 'core/constants';
export default {
name: 'skipmap',
description:
'The <code>skipmap</code> plugin will allow players to vote via <code>+</code>/<code>-</code> if they wish to skip the current map',
defaultEnabled: false,
optionsSpec: {
command: {
required: false,
description: 'The name of the command to be used in chat.',
default: '!skipmap'
},
voteDuration: {
required: false,
description: 'How long the vote should go on for.',
default: 5 * 60 * 1000
},
startTimer: {
required: false,
description: 'Time before voting is allowed.',
default: 15 * 60 * 1000
},
endTimer: {
required: false,
description: 'Time before voting is no longer allowed.',
default: 30 * 60 * 1000
},
pastVoteTimer: {
required: false,
description: 'Time that needs to have passed since the last vote.',
default: 10 * 60 * 1000
},
minimumVotes: {
required: false,
description: 'The minimum percentage of people required to vote for the vote to go through.',
default: 20
},
reminderInterval: {
required: false,
description: 'The time between individual reminders.',
default: 2 * 60 * 1000
}
},
init: (server, options) => {
let voteActive;
let votePos = 0;
let voteNeg = 0;
let playerVotes = {};
let intervalReminderBroadcasts;
let timeoutVote;
let timeLastVote = null;
server.on(CHAT_MESSAGE, async (info) => {
// check if message is command
if (!info.message.startsWith(options.command)) return;
if (voteActive) {
await server.rcon.warn(info.steamID, 'Skipmap vote already in progress.');
return;
}
// check if enough time has passed since start of round and if not, inform the player
if (
server.layerHistory.length > 0 &&
server.layerHistory[0].time > Date.now() - options.startTimer
) {
const seconds = Math.floor(
(server.layerHistory[0].time + options.startTimer - Date.now()) / 1000
);
const minutes = Math.floor(seconds / 60);
await server.rcon.warn(
info.steamID,
`Not enough time has passed since the start of the match. Please try again in ${
minutes ? `${minutes}min` : ''
} ${seconds ? `${seconds - minutes * 60}s` : ''}`
);
return;
}
// check if enough time remains in the round, if not, inform player
if (
server.layerHistory.length > 0 &&
server.layerHistory[0].time < Date.now() - options.endTimer
) {
await server.rcon.warn(info.steamID, 'Match has progressed too far.');
return;
}
// check if enough time has passed since the last vote
if (timeLastVote && timeLastVote > Date.now() - options.pastVoteTimer) {
await server.rcon.warn(info.steamID, 'Not enough time has passed since the last vote.');
return;
}
await server.rcon.warn(info.steamID, 'You have started a skip map vote.');
await server.rcon.warn(info.steamID, COPYRIGHT_MESSAGE);
await server.rcon.broadcast(
'A vote to skip the current map has been started. Please vote in favour of skipping the map with + or against with -.'
);
// Actual vote
voteActive = true;
votePos = 1;
voteNeg = 0;
playerVotes = {};
playerVotes[info.steamID] = '+';
timeLastVote = new Date(); // As a vote happened, stop any further votes from happening until enough time has passed
// Set reminders
intervalReminderBroadcasts = setInterval(async () => {
await server.rcon.broadcast(
'A vote to skip the current map is in progress. Please vote in favour of skipping the map with + or against with -.'
);
await server.rcon.broadcast(
`Currently ${votePos} people voted in favour and ${voteNeg} against skipping the current map.`
);
}, options.reminderInterval);
// End vote
// Disable recording of new votes, stop further broadcasts
timeoutVote = setTimeout(() => {
voteActive = false;
clearInterval(intervalReminderBroadcasts);
// Check if enough people voted
if (voteNeg + votePos < options.minimumVotes) {
server.rcon.broadcast('Not enough people voted for the vote to go through.');
return;
}
if (votePos > voteNeg) {
server.rcon.broadcast(
`The vote to skip the current map has passed. ${votePos} voted in favour, ${voteNeg} against.`
);
server.rcon.execute('AdminEndMatch');
} else {
server.rcon.broadcast(
`Not enough people voted in favour of skipping the match. ${votePos} voted in favour, ${voteNeg} against.`
);
}
}, options.voteDuration);
});
// Clear timeouts and intervals when new game starts
server.on(NEW_GAME, () => {
clearInterval(intervalReminderBroadcasts);
clearTimeout(timeoutVote);
voteActive = false;
timeLastVote = null;
});
// Record votes
server.on(CHAT_MESSAGE, async (info) => {
if (!voteActive) return;
if (!['+', '-'].includes(info.message)) return;
// Check if player has voted previously, if yes, remove their vote
if (playerVotes[info.steamID]) {
if (playerVotes[info.steamID] === '+') votePos--;
else voteNeg--;
}
// Record player vote
if (info.message === '+') {
votePos++;
await server.rcon.warn(info.steamID, 'Your vote in favour has been saved.');
} else if (info.message === '-') {
voteNeg++;
await server.rcon.warn(info.steamID, 'Your vote against has been saved.');
}
await server.rcon.warn(info.steamID, COPYRIGHT_MESSAGE);
playerVotes[info.steamID] = info.message;
// If 50% of people voted in favour, instantly win the vote
if (votePos > server.players.length / 2) {
await server.rcon.broadcast(
`The vote to skip the current map has passed. ${votePos} voted in favour, ${voteNeg} against.`
);
await server.rcon.execute('AdminEndMatch');
timeLastVote = new Date();
voteActive = false;
clearInterval(intervalReminderBroadcasts);
clearTimeout(timeoutVote);
}
});
}
};

View File

@ -1,56 +0,0 @@
import { CHAT_MESSAGE } from 'squad-server/events';
export default {
name: 'team-randomizer',
description:
"The <code>team-randomizer</code> plugin can be used to randomize teams. It's great for destroying clan stacks " +
'or for social events. It can be run by typing <code>!randomize</code> into in-game admin chat.',
defaultEnabled: true,
optionsSpec: {
command: {
required: false,
description: 'The command used to randomize the teams.',
default: '!randomize'
}
},
init: async (server, options) => {
const commandRegex = new RegExp(`^${options.command}`, 'i');
server.on(CHAT_MESSAGE, (info) => {
if (info.chat !== 'ChatAdmin') return;
const match = info.message.match(commandRegex);
if (!match) return;
const players = server.players.slice(0);
let currentIndex = players.length;
let temporaryValue;
let randomIndex;
// While there remain elements to shuffle...
while (currentIndex !== 0) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = players[currentIndex];
players[currentIndex] = players[randomIndex];
players[randomIndex] = temporaryValue;
}
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

@ -1,29 +1,28 @@
import EventEmitter from 'events';
import EventEmiiter from 'events';
import net from 'net';
import moment from 'moment';
const SERVERDATA_EXECCOMMAND = 0x02;
const SERVERDATA_RESPONSE_VALUE = 0x00;
const SERVERDATA_AUTH = 0x03;
// const SERVERDATA_AUTH_RESPONSE = 0x02;
const SERVERDATA_CHAT_VALUE = 0x01;
import RCONProtocol from './protocol.js';
const MID_PACKET_ID = 0x01;
const END_PACKET_ID = 0x02;
import { CHAT_MESSAGE, RCON_ERROR } from '../events.js';
export default class Rcon extends EventEmiiter {
constructor(options = {}) {
super();
for (const option of ['host', 'port', 'password'])
if (!(option in options)) throw new Error(`${option} must be specified.`);
export default class Rcon {
constructor(options = {}, emitter) {
if (!options.host) throw new Error('Host must be specified.');
this.host = options.host;
if (!options.rconPort) throw new Error('RCON port must be specified.');
this.port = options.rconPort;
if (!options.rconPassword) throw new Error('RCON password must be specified.');
this.password = options.rconPassword;
this.verboseEnabled = options.rconVerbose || false;
this.emitter = emitter || new EventEmitter();
this.port = options.port;
this.password = options.password;
this.reconnectInterval = null;
this.rconAutoReconnectInterval = options.rconAutoReconnectInterval || 5000;
this.autoRconnectInterval = options.autoReconnectInterval || 5000;
this.maximumPacketSize = 4096;
@ -32,69 +31,12 @@ export default class Rcon {
this.autoReconnect = true;
this.requestQueue = [];
this.currentMultiPacket = [];
this.currentMultiPacketResponse = [];
this.ignoreNextEndPacket = false;
this.onData = this.onData.bind(this);
}
/* RCON functionality */
watch() {
this.verbose('Method Exec: watch()');
return this.connect();
}
unwatch() {
this.verbose('Method Exec: unwatch()');
return this.disconnect();
}
execute(command) {
this.verbose(`Method Exec: execute(${command})`);
return this.write(RCONProtocol.SERVERDATA_EXECCOMMAND, command);
}
async getMapInfo() {
const response = await this.execute('ShowNextMap');
const match = response.match(/^Current map is (.+), Next map is (.*)/);
return {
currentLayer: match[1],
nextLayer: match[2].length === 0 ? null : match[2]
};
}
async listPlayers() {
const response = await this.execute('ListPlayers');
const players = [];
for (const line of response.split('\n')) {
const match = line.match(
/ID: ([0-9]+) \| SteamID: ([0-9]{17}) \| Name: (.+) \| Team ID: ([0-9]+) \| Squad ID: ([0-9]+|N\/A)/
);
if (!match) continue;
players.push({
playerID: match[1],
steamID: match[2],
name: match[3],
teamID: match[4],
squadID: match[5] !== 'N/A' ? match[5] : null
});
}
return players;
}
async broadcast(message) {
await this.execute(`AdminBroadcast ${message}`);
}
async warn(steamID, message) {
await this.execute(`AdminWarn "${steamID}" ${message}`);
}
/* Core socket functionality */
connect() {
this.verbose('Method Exec: connect()');
return new Promise((resolve, reject) => {
@ -107,7 +49,7 @@ export default class Rcon {
this.client.on('error', (err) => {
this.verbose(`Socket Error: ${err.message}`);
this.emitter.emit(RCON_ERROR, err);
this.emitter.emit('RCON_ERROR', err);
});
this.client.on('close', async (hadError) => {
@ -126,7 +68,7 @@ export default class Rcon {
} catch (err) {
this.verbose('AutoReconnect Failed.');
}
}, this.rconAutoReconnectInterval);
}, this.autoReconnectInterval);
});
const onConnect = async () => {
@ -134,7 +76,7 @@ export default class Rcon {
this.client.removeListener('error', onError);
this.connected = true;
this.verbose('Sending auth packet...');
await this.write(RCONProtocol.SERVERDATA_AUTH, this.password);
await this.write(SERVERDATA_AUTH, this.password);
resolve();
};
@ -175,69 +117,13 @@ export default class Rcon {
});
}
write(type, body) {
return new Promise((resolve, reject) => {
if (!this.client.writable) {
reject(new Error('Unable to write to socket'));
return;
}
if (!this.connected) {
reject(new Error('Not connected.'));
return;
}
// prepare packets to send
const encodedPacket = this.encodePacket(type, RCONProtocol.ID_MID, body);
const encodedEmptyPacket = this.encodePacket(
RCONProtocol.SERVERDATA_EXECCOMMAND,
RCONProtocol.ID_END,
''
);
if (this.maximumPacketSize > 0 && encodedPacket.length > this.maximumPacketSize)
reject(new Error('Packet too long.'));
// prepare to handle response.
const handleAuthMultiPacket = async () => {
this.client.removeListener('error', reject);
for (const packet of this.currentMultiPacket) {
if (packet.type === RCONProtocol.SERVERDATA_RESPONSE_VALUE) continue;
if (packet.id !== RCONProtocol.ID_MID) {
this.verbose('Unable to authenticate.');
await this.disconnect(false);
reject(new Error('Unable to authenticate.'));
}
this.currentMultiPacket = [];
this.verbose('Authenticated.');
resolve();
}
};
const handleMultiPacket = () => {
this.client.removeListener('error', reject);
let response = '';
for (const packet of this.currentMultiPacket) {
response += packet.body;
}
this.currentMultiPacket = [];
resolve(response);
};
if (type === RCONProtocol.SERVERDATA_AUTH) this.requestQueue.push(handleAuthMultiPacket);
else this.requestQueue.push(handleMultiPacket);
this.client.once('error', reject);
// send packets
this.client.write(encodedPacket);
this.client.write(encodedEmptyPacket);
});
decodePacket(buf) {
return {
size: buf.readInt32LE(0),
id: buf.readInt32LE(4),
type: buf.readInt32LE(8),
body: buf.toString('utf8', 12, buf.byteLength - 2)
};
}
onData(inputBuf) {
@ -250,21 +136,21 @@ export default class Rcon {
const decodedPacket = this.decodePacket(packetBuf);
if (decodedPacket.type === RCONProtocol.SERVERDATA_CHAT_VALUE) {
if (decodedPacket.type === SERVERDATA_CHAT_VALUE) {
// emit chat messages to own event
const message = decodedPacket.body.match(
/\[(ChatAll|ChatTeam|ChatSquad|ChatAdmin)] \[SteamID:([0-9]{17})] (.+?) : (.*)/
);
this.emitter.emit(CHAT_MESSAGE, {
this.emit('CHAT_MESSAGE', {
raw: decodedPacket.body,
chat: message[1],
steamID: message[2],
player: message[3],
name: message[3],
message: message[4],
time: moment.utc().toDate()
time: Date.now()
});
} else if (decodedPacket.id === RCONProtocol.ID_END) {
} else if (decodedPacket.id === END_PACKET_ID) {
if (this.ignoreNextEndPacket) {
this.ignoreNextEndPacket = false;
// boost the offset as the length seems wrong for this response
@ -278,7 +164,7 @@ export default class Rcon {
func();
} else {
// push packet to multipacket queue
this.currentMultiPacket.push(decodedPacket);
this.currentMultiPacketResponse.push(decodedPacket);
}
}
}
@ -296,16 +182,102 @@ export default class Rcon {
return buffer;
}
decodePacket(buf) {
return {
size: buf.readInt32LE(0),
id: buf.readInt32LE(4),
type: buf.readInt32LE(8),
body: buf.toString('utf8', 12, buf.byteLength - 2)
};
write(type, body) {
return new Promise((resolve, reject) => {
if (!this.client.writable) {
reject(new Error('Unable to write to socket'));
return;
}
if (!this.connected) {
reject(new Error('Not connected.'));
return;
}
// prepare packets to send
const encodedPacket = this.encodePacket(type, MID_PACKET_ID, body);
const encodedEmptyPacket = this.encodePacket(SERVERDATA_EXECCOMMAND, END_PACKET_ID, '');
if (this.maximumPacketSize > 0 && encodedPacket.length > this.maximumPacketSize)
reject(new Error('Packet too long.'));
// prepare to handle response.
const handleAuthMultiPacket = async () => {
this.client.removeListener('error', reject);
for (const packet of this.currentMultiPacketResponse) {
if (packet.type === SERVERDATA_RESPONSE_VALUE) continue;
if (packet.id !== MID_PACKET_ID) {
this.verbose('Unable to authenticate.');
await this.disconnect(false);
reject(new Error('Unable to authenticate.'));
}
this.currentMultiPacketResponse = [];
this.verbose('Authenticated.');
resolve();
}
};
const handleMultiPacket = () => {
this.client.removeListener('error', reject);
let response = '';
for (const packet of this.currentMultiPacketResponse) {
response += packet.body;
}
this.currentMultiPacketResponse = [];
resolve(response);
};
if (type === SERVERDATA_AUTH) this.requestQueue.push(handleAuthMultiPacket);
else this.requestQueue.push(handleMultiPacket);
this.client.once('error', reject);
// send packets
this.client.write(encodedPacket);
this.client.write(encodedEmptyPacket);
});
}
verbose(msg) {
if (this.verboseEnabled) console.log(`[${Date.now()}] RCON (Verbose): ${msg}`);
console.log(`[${Date.now()}] RCON (Verbose): ${msg}`);
}
execute(command) {
this.verbose(`Method Exec: execute(${command})`);
return this.write(SERVERDATA_EXECCOMMAND, command);
}
async getListPlayers() {
const response = await this.execute('ListPlayers');
const players = [];
for (const line of response.split('\n')) {
const match = line.match(
/ID: ([0-9]+) \| SteamID: ([0-9]{17}) \| Name: (.+) \| Team ID: ([0-9]+) \| Squad ID: ([0-9]+|N\/A)/
);
if (!match) continue;
players.push({
playerID: match[1],
steamID: match[2],
name: match[3],
teamID: match[4],
squadID: match[5] !== 'N/A' ? match[5] : null
});
}
return players;
}
async getLayerInfo() {
const response = await this.execute('ShowNextMap');
const match = response.match(/^Current map is (.+), Next map is (.*)/);
return { currentLayer: match[1], nextLayer: match[2].length === 0 ? null : match[2] };
}
}

8
rcon/package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "rcon",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./index.js"
}
}

View File

@ -1,204 +0,0 @@
/** Occurs when the player list is updated via RCON.
*
* Data:
* - Array of PlayerObjects
*/
const PLAYERS_UPDATED = 'PLAYERS_UPDATED';
/** Occurs when the layer info is updated via RCON.
*
* Data:
* - currentLayer - Current layer.
* - nextLayer - Next layer.
*/
const LAYERS_UPDATED = 'LAYERS_UPDATED';
/** Occurs when the server info is updated via A2S.
*
* Data:
* - serverName - Name of the server.
* - maxPlayers - Maximum number of players on the server.
* - publicSlots - Maximum number of public slots.
* - reserveSlots - Maximum number of reserved slots.
* - playerCount - Player count as per A2S query.
* - publicQueue - Length of the public queue.
* - reserveQueue - Length of the reserved queue.
* - matchTimeout - Time until match ends?
* - gameVersion - Game version.
*/
const A2S_INFO_UPDATED = 'A2S_INFO_UPDATED';
/** Occurs when a message is sent.
*
* Data:
* - chat - Chat the message was sent to.
* - steamID - Steam ID of the player.
* - player - Name of the player.
* - message - Message sent.
* - time - Time message was sent, AKA now.
*/
const CHAT_MESSAGE = 'CHAT_MESSAGE';
/** Occurs when an admin broadcast is made.
*
* Data:
* - time - Date object of when the event occurred.
* - message - The message that was broadcasted.
* - from - Apparently who broadcasted it, but this is broken in Squad logs.
*/
const ADMIN_BROADCAST = 'ADMIN_BROADCAST';
/** Occurs when a new layer is loaded.
*
* Data:
* - time - Date object of when the event occurred.
* - dlc - DLC / Mod the layer was loaded from.
* - mapClassname - Classname of the map.
* - layerClassname - Classname of the layer.
* - map - Map name (if known).
* - layer - Layer name (if known).
*/
const NEW_GAME = 'NEW_GAME';
/** Occurs when a player possess a new object.
*
* Data:
* - time - Date object of when the event occurred.
* - player - PlayerObject of the admin.
* - possessClassname - Classname of the object.
*/
const PLAYER_POSSESS = 'PLAYER_POSSESS';
/** Occurs when a player unpossess an object.
*
* Data:
* - time - Date object of when the event occurred.
* - player - PlayerObject of the admin.
* - switchPossess - True if switching a possess.
*/
const PLAYER_UNPOSSESS = 'PLAYER_UNPOSSESS';
/** Occurs when a new layer is loaded.
*
* Data:
* - time - Date object of when the event occurred.
* - dlc - DLC / Mod the layer was loaded from.
* - mapClassname - Classname of the map.
* - layerClassname - Classname of the layer.
* - map - Map name (if known).
* - layer - Layer name (if known).
*/
const LAYER_CHANGE = 'LAYER_CHANGE';
/** Occurs when a new player connects.
*
* Data:
* - time - Date object of when the event occurred.
* - player - PlayerObject of the player.
*/
const PLAYER_CONNECTED = 'PLAYER_CONNECTED';
/** Occurs when a player is damaged.
*
* Data:
* - time - Date object of when the event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - weapon - The classname of the weapon used.
*/
const PLAYER_DAMAGED = 'PLAYER_DAMAGED';
/** Occurs when a player is wounded.
*
* Data:
* - time - Date object of when the event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
* - suicide - Was the kill a suicide.
*/
const PLAYER_WOUNDED = 'PLAYER_WOUNDED';
/** Occurs when a player is teamkilled.
*
* Data:
* - time - Date object of when the event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
* - suicide - Was the kill a suicide.
*/
const TEAMKILL = 'TEAMKILL';
/** Occurs when a player dies.
*
* Data:
* - time - Date object of when the event occurred.
* - woundTime - Date object of when the wound event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
* - suicide - Was the kill a suicide.
*/
const PLAYER_DIED = 'PLAYER_DIED';
/** Occurs when a player is revived.
*
* Data:
* - time - Date object of when the event occurred.
* - woundTime - Date object of when the wound event occurred.
* - victim - PlayerObject of the damaged player.
* - damage - Amount of damage inflicted.
* - attacker - PlayerObject of the attacking player.
* - attackerPlayerController - PlayerController of the attacking player.
* - weapon - The classname of the weapon used.
* - teamkill - Whether the kill was a teamkill.
* - suicide - Was the kill a suicide.
* - reviver - PlayerObject of the reviving player.
*/
const PLAYER_REVIVED = 'PLAYER_REVIVED';
/** Occurs when the server tick rate is updated.
*
* Data:
* - time - Date object of when the event occurred.
* - tickRate - Tick rate of the server.
*/
const TICK_RATE = 'TICK_RATE';
/** Occurs when an RCON error occurs.
*
* Data:
* - ErrorObject
*/
const RCON_ERROR = 'RCON_ERROR';
export {
PLAYERS_UPDATED,
LAYERS_UPDATED,
A2S_INFO_UPDATED,
ADMIN_BROADCAST,
CHAT_MESSAGE,
NEW_GAME,
LAYER_CHANGE,
PLAYER_CONNECTED,
PLAYER_POSSESS,
PLAYER_UNPOSSESS,
PLAYER_DAMAGED,
TEAMKILL,
PLAYER_WOUNDED,
PLAYER_DIED,
PLAYER_REVIVED,
TICK_RATE,
RCON_ERROR
};

View File

@ -1,171 +1,372 @@
import EventEmitter from 'events';
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import Discord from 'discord.js';
import Gamedig from 'gamedig';
import mysql from 'mysql';
import LogParser from './log-parser/index.js';
import Rcon from './rcon/index.js';
import { SquadLayers } from './utils/squad-layers.js';
import LogParser from 'log-parser';
import Rcon from 'rcon';
import {
LAYER_CHANGE,
PLAYERS_UPDATED,
LAYERS_UPDATED,
A2S_INFO_UPDATED,
NEW_GAME
} from './events.js';
import plugins from './plugins/index.js';
export default class Server extends EventEmitter {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default class SquadServer extends EventEmitter {
constructor(options = {}) {
super();
// store options
if (!('id' in options)) throw new Error('Server must have an ID.');
this.id = options.id;
for (const option of ['host', 'queryPort'])
if (!(option in options)) throw new Error(`${option} must be specified.`);
if (!('host' in options)) throw new Error('Server must have a host.');
this.host = options.host;
if (!('queryPort' in options)) throw new Error('Server must have a queryPort.');
this.queryPort = options.queryPort;
this.updateInterval = options.updateInterval || 30 * 1000;
// setup additional classes
this.rcon = new Rcon(options, this);
this.logParser = new LogParser(options, this);
// setup internal data storage
this.layerHistory = options.layerHistory || [];
this.layerHistory = [];
this.layerHistoryMaxLength = options.layerHistoryMaxLength || 20;
this.players = [];
// store additional information about players by SteamID
this.suffixStore = {};
this.plugins = [];
// setup internal listeners
this.on(NEW_GAME, this.onLayerChange.bind(this));
this.squadLayers = new SquadLayers(options.squadLayersSource);
// setup period updaters
this.updatePlayers = this.updatePlayers.bind(this);
this.updatePlayerTimeout = setTimeout(this.updatePlayers, this.updateInterval);
this.logParser = new LogParser({
mode: options.logReaderMode,
logDir: options.logDir,
setInterval(async () => {
const data = await this.rcon.getMapInfo();
this.currentLayer = data.currentLayer;
this.nextLayer = data.nextLayer;
this.emit(LAYERS_UPDATED, data);
}, this.updateInterval);
host: options.host,
port: options.ftpPort,
user: options.ftpUser,
password: options.ftpPassword,
secure: options.ftpSecure,
timeout: options.ftpTimeout,
verbose: options.ftpVerbose,
fetchInterval: options.ftpFetchInterval,
maxTempFileSize: options.ftpMaxTempFileSize
});
setInterval(async () => {
const data = await Gamedig.query({
type: 'squad',
host: this.host,
port: this.queryPort
});
this.logParser.on('ADMIN_BROADCAST', (data) => {
this.emit('ADMIN_BROADCAST', data);
});
this.serverName = data.name;
this.logParser.on('NEW_GAME', (data) => {
let layer;
if (data.layer) layer = this.squadLayers.getLayerByLayerName(data.layer);
else layer = this.squadLayers.getLayerByLayerClassname(data.layerClassname);
this.maxPlayers = parseInt(data.maxplayers);
this.publicSlots = parseInt(data.raw.rules.NUMPUBCONN);
this.reserveSlots = parseInt(data.raw.rules.NUMPRIVCONN);
this.layerHistory.unshift({ ...layer, time: data.time });
this.layerHistory = this.layerHistory.slice(0, this.layerHistoryMaxLength);
this.playerCount = parseInt(data.raw.rules.PlayerCount_i);
this.publicQueue = parseInt(data.raw.rules.PublicQueue_i);
this.reserveQueue = parseInt(data.raw.rules.ReservedQueue_i);
this.emit('NEW_GAME', data);
});
this.matchTimeout = parseFloat(data.raw.rules.MatchTimeout_f);
this.gameVersion = data.raw.version;
this.logParser.on('PLAYER_CONNECTED', async (data) => {
data.player = await this.getPlayerBySteamID(data.steamID);
if (data.player) data.player.suffix = data.playerSuffix;
this.emit(A2S_INFO_UPDATED, {
serverName: this.serverName,
maxPlayers: this.maxPlayers,
publicSlots: this.publicSlots,
reserveSlots: this.reserveSlots,
playerCount: this.playerCount,
publicQueue: this.publicQueue,
reserveQueue: this.reserveQueue,
matchTimeout: this.matchTimeout,
gameVersion: this.gameVersion
});
}, this.updateInterval);
delete data.steamID;
delete data.playerSuffix;
this.emit('PLAYER_CONNECTED', data);
});
this.logParser.on('PLAYER_DAMAGED', async (data) => {
data.victim = await this.getPlayerByName(data.victimName);
data.attacker = await this.getPlayerByName(data.attackerName);
if (data.victim && data.attacker) data.teamkill = data.victim.teamID === data.attacker.teamID;
delete data.victimName;
delete data.attackerName;
this.emit('PLAYER_DAMAGED', data);
});
this.logParser.on('PLAYER_WOUNDED', async (data) => {
data.victim = await this.getPlayerByName(data.victimName);
data.attacker = await this.getPlayerByName(data.attackerName);
if (data.victim && data.attacker) data.teamkill = data.victim.teamID === data.attacker.teamID;
delete data.victimName;
delete data.attackerName;
this.emit('PLAYER_WOUNDED', data);
if (data.teamkill) this.emit('TEAMKILL', data);
});
this.logParser.on('PLAYER_DIED', async (data) => {
data.victim = await this.getPlayerByName(data.victimName);
data.attacker = await this.getPlayerByName(data.attackerName);
if (data.victim && data.attacker) data.teamkill = data.victim.teamID === data.attacker.teamID;
delete data.victimName;
delete data.attackerName;
this.emit('PLAYER_DIED', data);
});
this.logParser.on('PLAYER_REVIVED', async (data) => {
data.victim = await this.getPlayerByName(data.victimName);
data.attacker = await this.getPlayerByName(data.attackerName);
data.reviver = await this.getPlayerByName(data.reviverName);
delete data.victimName;
delete data.attackerName;
delete data.reviverName;
this.emit('PLAYER_REVIVED', data);
});
this.logParser.on('PLAYER_POSSESS', async (data) => {
data.player = await this.getPlayerByNameSuffix(data.playerSuffix);
if (data.player) data.player.possessClassname = data.possessClassname;
delete data.playerSuffix;
this.emit('PLAYER_POSSESS', data);
});
this.logParser.on('PLAYER_UNPOSSESS', async (data) => {
data.player = await this.getPlayerByNameSuffix(data.playerSuffix);
delete data.playerSuffix;
this.emit('PLAYER_UNPOSSESS', data);
});
this.logParser.on('TICK_RATE', (data) => {
this.emit('TICK_RATE', data);
});
this.rcon = new Rcon({
host: options.host,
port: options.rconPort,
password: options.rconPassword,
autoReconnectInterval: options.rconAutoReconnectInterval
});
this.rcon.on('CHAT_MESSAGE', async (data) => {
data.player = await this.getPlayerBySteamID(data.steamID);
this.emit('CHAT_MESSAGE', data);
});
this.rcon.on('RCON_ERROR', (data) => {
this.emit('RCON_ERROR', data);
});
this.updatePlayerList = this.updatePlayerList.bind(this);
this.updatePlayerListInterval = 30 * 1000;
this.updatePlayerListTimeout = null;
this.updateLayerInformation = this.updateLayerInformation.bind(this);
this.updateLayerInformationInterval = 30 * 1000;
this.updateLayerInformationTimeout = null;
this.updateA2SInformation = this.updateA2SInformation.bind(this);
this.updateA2SInformationInterval = 30 * 1000;
this.updateA2SInformationTimeout = null;
}
async watch() {
console.log(`Watching server ${this.id}...`);
if (this.logParser) await this.logParser.watch();
if (this.rcon) await this.rcon.watch();
}
async updatePlayerList() {
if (this.updatePlayerListTimeout) clearTimeout(this.updatePlayerListTimeout);
async unwatch() {
if (this.logParser) await this.logParser.unwatch();
if (this.rcon) await this.rcon.unwatch();
console.log('Stopped watching.');
}
async updatePlayers() {
clearTimeout(this.updatePlayerTimeout);
this.players = await this.rcon.listPlayers();
// readd additional information about the player we have collected
for (let i = 0; i < this.players.length; i++) {
this.players[i].suffix = this.suffixStore[this.players[i].steamID];
}
// delay another update
this.updatePlayerTimeout = setTimeout(this.updatePlayers, this.updateInterval);
this.emit(PLAYERS_UPDATED, this.players);
}
async getPlayerByName(name, suffix = false) {
let matchingPlayers;
matchingPlayers = [];
const oldPlayerInfo = {};
for (const player of this.players) {
if (player[suffix ? 'suffix' : 'name'] !== name) continue;
matchingPlayers.push(player);
oldPlayerInfo[player.steamID] = player;
}
if (matchingPlayers.length === 0 && suffix === false) {
await this.updatePlayers();
this.players = (await this.rcon.getListPlayers()).map((player) => ({
...oldPlayerInfo[player.steamID],
...player
}));
matchingPlayers = [];
for (const player of this.players) {
if (player[suffix ? 'suffix' : 'name'] !== name) continue;
matchingPlayers.push(player);
}
this.updatePlayerListTimeout = setTimeout(this.updatePlayerList, this.updatePlayerListInterval);
}
async updateLayerInformation() {
if (this.updateLayerInformationTimeout) clearTimeout(this.updateLayerInformationTimeout);
const layerInfo = await this.rcon.getLayerInfo();
if (this.layerHistory.length === 0) {
const layer = SquadLayers.getLayerByLayerName(layerInfo.currentLayer);
this.layerHistory.unshift({ ...layer, time: Date.now() });
this.layerHistory = this.layerHistory.slice(0, this.layerHistoryMaxLength);
}
if (matchingPlayers.length === 1) return matchingPlayers[0];
else return null;
this.nextLayer = layerInfo.nextLayer;
this.updateLayerInformationTimeout = setTimeout(
this.updateLayerInformation,
this.updateLayerInformationInterval
);
}
async updateA2SInformation() {
if (this.updateA2SInformationTimeout) clearTimeout(this.updateA2SInformationTimeout);
const data = await Gamedig.query({ type: 'squad', host: this.host, port: this.queryPort });
this.serverName = data.name;
this.maxPlayers = parseInt(data.maxplayers);
this.publicSlots = parseInt(data.raw.rules.NUMPUBCONN);
this.reserveSlots = parseInt(data.raw.rules.NUMPRIVCONN);
this.playerCount = parseInt(data.raw.rules.PlayerCount_i);
this.publicQueue = parseInt(data.raw.rules.PublicQueue_i);
this.reserveQueue = parseInt(data.raw.rules.ReservedQueue_i);
this.matchTimeout = parseFloat(data.raw.rules.MatchTimeout_f);
this.gameVersion = data.raw.version;
this.updateA2SInformationTimeout = setTimeout(
this.updateA2SInformation,
this.updateA2SInformationInterval
);
}
async getPlayerByCondition(condition, retry = true) {
let matches;
matches = this.players.filter(condition);
if (matches.length === 1) return matches[0];
if (!retry) return null;
await this.updatePlayerList();
matches = this.players.filter(condition);
if (matches.length === 1) return matches[0];
return null;
}
async getPlayerBySteamID(steamID) {
let matchingPlayers;
return this.getPlayerByCondition((player) => player.steamID === steamID);
}
matchingPlayers = [];
for (const player of this.players) {
if (player.steamID !== steamID) continue;
matchingPlayers.push(player);
async getPlayerByName(name) {
return this.getPlayerByCondition((player) => player.name === name);
}
async getPlayerByNameSuffix(suffix) {
return this.getPlayerByCondition((player) => player.suffix === suffix, false);
}
async watch() {
await this.squadLayers.pull();
await this.rcon.connect();
await this.logParser.watch();
await this.updatePlayerList();
await this.updateLayerInformation();
await this.updateA2SInformation();
}
async unwatch() {
await this.rcon.disconnect();
await this.logParser.unwatch();
}
static async buildFromConfig(configPath = './config.json') {
console.log('Reading config file...');
configPath = path.resolve(__dirname, '../', configPath);
if (!fs.existsSync(configPath)) throw new Error('Config file does not exist.');
const unparsedConfig = fs.readFileSync(configPath, 'utf8');
console.log('Parsing config file...');
let config;
try {
config = JSON.parse(unparsedConfig);
} catch (err) {
throw new Error('Unable to parse config file.');
}
if (matchingPlayers.length === 0) {
await this.updatePlayers();
console.log('Creating SquadServer...');
const server = new SquadServer(config.server);
matchingPlayers = [];
for (const player of this.players) {
if (player.steamID !== steamID) continue;
matchingPlayers.push(player);
// pull layers read to use to create layer filter connectors
await server.squadLayers.pull();
console.log('Preparing connectors...');
const connectors = {};
for (const pluginConfig of config.plugins) {
const Plugin = plugins[pluginConfig.plugin];
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
if (!(optionName in pluginConfig))
throw new Error(
`${Plugin.name}: ${optionName} (${option.connector} connector) is missing.`
);
// ignore non connectors
if (!option.connector) continue;
const connectorName = pluginConfig[optionName];
// skip already created connectors
if (connectors[connectorName]) continue;
const connectorConfig = config.connectors[connectorName];
if (option.connector === 'discord') {
console.log(`Starting discord connector ${connectorName}...`);
connectors[connectorName] = new Discord.Client();
await connectors[connectorName].login(connectorConfig);
} else if (option.connector === 'mysql') {
console.log(`Starting mysqlPool connector ${connectorName}...`);
connectors[connectorName] = mysql.createPool(connectorConfig);
} else if (option.connector === 'squadlayerpool') {
console.log(`Starting squadlayerfilter connector ${connectorName}...`);
connectors[connectorName] = server.squadLayers[connectorConfig.type](
connectorConfig.filter,
connectorConfig.activeLayerFilter
);
} else {
throw new Error(`${option.connector} is an unsupported connector type.`);
}
}
}
return matchingPlayers[0];
}
console.log('Applying plugins to SquadServer...');
for (const pluginConfig of config.plugins) {
if (!plugins[pluginConfig.plugin])
throw new Error(`Plugin ${pluginConfig.plugin} does not exist.`);
onLayerChange(info) {
this.layerHistory.unshift(info);
this.layerHistory = this.layerHistory.slice(0, this.layerHistoryMaxLength);
this.emit(LAYER_CHANGE, info);
const Plugin = plugins[pluginConfig.plugin];
console.log(`Initialising ${Plugin.name}...`);
const options = {};
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
if (option.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;
}
}
server.plugins.push(new Plugin(server, options));
}
return server;
}
}

View File

@ -1,39 +0,0 @@
import path from 'path';
import FTPTail from 'ftp-tail';
export default class TailLogReader {
constructor(queueLine, options = {}) {
if (typeof queueLine !== 'function')
throw new Error('queueLine argument must be specified and be a function.');
if (!options.host) throw new Error('host argument must be specified.');
if (!options.ftpUser) throw new Error('user argument must be specified.');
if (!options.ftpPassword) throw new Error('password argument must be specified.');
this.reader = new FTPTail({
host: options.host,
port: options.ftpPort || 21,
user: options.ftpUser,
password: options.ftpPassword,
secure: options.ftpSecure || false,
timeout: options.ftpTimeout || 2000,
encoding: 'utf8',
verbose: options.ftpVerbose,
path: path.join(options.logDir, 'SquadGame.log'),
fetchInterval: options.ftpTetchInterval || 0,
maxTempFileSize: options.ftpMaxTempFileSize || 5 * 1000 * 1000 // 5 MB
});
this.reader.on('line', queueLine);
}
async watch() {
await this.reader.watch();
}
async unwatch() {
await this.reader.unwatch();
}
}

View File

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

View File

@ -3,18 +3,17 @@
"version": "1.0.0",
"type": "module",
"dependencies": {
"async": "^3.2.0",
"core": "1.0.0",
"ftp-tail": "^1.0.2",
"axios": "^0.20.0",
"didyoumean": "^1.2.1",
"discord.js": "^12.3.1",
"gamedig": "^2.0.20",
"moment": "^2.24.0",
"tail": "^2.0.3"
"log-parser": "1.0.0",
"mysql": "^2.18.1",
"rcon": "1.0.0"
},
"exports": {
".": "./index.js",
"./events": "./events.js",
"./log-parser": "./log-parser/index.js",
"./rcon": "./rcon/index.js",
"./logo": "./utils/print-logo.js",
"./plugins": "./plugins/index.js"
}
}

View File

@ -0,0 +1,13 @@
export default class BasePlugin {
static get description() {
throw new Error('Plugin missing "static get description()" method.');
}
static get defaultEnabled() {
throw new Error('Plugin missing "static get defaultEnabled()" method.');
}
static get optionsSpecification() {
throw new Error('Plugin missing "static get optionSpecification()" method.');
}
}

View File

@ -0,0 +1,34 @@
import BasePlugin from './base-plugin.js';
export default class ExamplePlugin extends BasePlugin {
static get description() {
return 'An example plugin that shows how to implement a basic plugin.';
}
static get defaultEnabled() {
return false;
}
static get optionsSpecification() {
return {
exampleOption: {
required: false,
description: 'An example option.',
default: 'A default value.',
example: 'An example value.'
},
exampleConnector: {
required: true,
description: 'An example squadlayerpool connector.',
connector: 'squadlayerpool',
default: 'squadlayerpool'
}
};
}
constructor(server, options) {
super();
// bind events onto server object
}
}

View File

@ -0,0 +1,10 @@
import ExamplePlugin from './example-plugin.js';
const plugins = [ExamplePlugin];
const pluginsByName = {};
for (const plugin of plugins) {
pluginsByName[plugin.name] = plugin;
}
export default pluginsByName;

View File

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

View File

@ -1,7 +1,8 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import plugins from 'plugins';
import plugins from '../plugins/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -14,14 +15,14 @@ const pluginKeys = Object.keys(plugins).sort((a, b) =>
);
for (const pluginKey of pluginKeys) {
const plugin = plugins[pluginKey];
const Plugin = plugins[pluginKey];
const pluginConfig = { plugin: plugin.name, enabled: plugin.defaultEnabled };
for (const option in plugin.optionsSpec) {
pluginConfig[option] = plugin.optionsSpec[option].default;
const pluginConfig = { plugin: Plugin.name, enabled: Plugin.defaultEnabled };
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
pluginConfig[optionName] = option.default;
}
template.plugins.push(pluginConfig);
}
fs.writeFileSync(path.resolve(__dirname, '../config.json'), JSON.stringify(template, null, 2));
fs.writeFileSync(path.resolve(__dirname, '../../config.json'), JSON.stringify(template, null, 2));

View File

@ -1,7 +1,8 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import plugins from 'plugins';
import plugins from '../plugins/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -12,12 +13,10 @@ const sortedPluginNames = pluginNames.sort((a, b) =>
const pluginInfo = [];
for (const pluginName of sortedPluginNames) {
const plugin = plugins[pluginName];
const Plugin = plugins[pluginName];
const options = [];
for (const optionName of Object.keys(plugin.optionsSpec)) {
const option = plugin.optionsSpec[optionName];
for (const [optionName, option] of Object.entries(Plugin.optionsSpecification)) {
let optionInfo = `<h4>${optionName}${option.required ? ' (Required)' : ''}</h4>
<h6>Description</h6>
<p>${option.description}</p>
@ -41,9 +40,9 @@ for (const pluginName of sortedPluginNames) {
pluginInfo.push(
`<details>
<summary>${plugin.name}</summary>
<h2>${plugin.name}</h2>
<p>${plugin.description}</p>
<summary>${Plugin.name}</summary>
<h2>${Plugin.name}</h2>
<p>${Plugin.description}</p>
<h3>Options</h3>
${options.join('\n')}
</details>`
@ -53,7 +52,7 @@ for (const pluginName of sortedPluginNames) {
const pluginInfoText = pluginInfo.join('\n\n');
fs.writeFileSync(
path.resolve(__dirname, '../README.md'),
path.resolve(__dirname, '../../README.md'),
fs
.readFileSync(path.resolve(__dirname, './templates/readme-template.md'), 'utf8')
.replace(/\/\/PLUGIN-INFO\/\//, pluginInfoText)

View File

@ -18,8 +18,8 @@
},
"connectors": {
"discord": "Discord Login Token",
"layerFilter": {
"type": "buildFromFilter",
"squadlayerpool": {
"type": "buildPoolFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,

View File

@ -1,6 +1,6 @@
<div align="center">
<img src="core/assets/squadjs-logo.png" alt="Logo" width="500"/>
<img src="assets/squadjs-logo.png" alt="Logo" width="500"/>
#### SquadJS
@ -91,7 +91,7 @@ Requires a Discord bot login token.
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",
"type": "buildPoolFromFilter",
"filter": {
"whitelistedLayers": null,
"blacklistedLayers": null,
@ -129,7 +129,7 @@ Connects to a filtered list of Squad layers and filters them either by an "initi
},
```
* `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.
- `buildPoolFromFilter` - 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.
@ -141,8 +141,8 @@ Connects to a filtered list of Squad layers and filters them either by an "initi
- `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"]`.
- `buildPoolFromFile` - Builds the Squad layers list from a Squad layer config file. `filter` should be the filename of the config file.
- `buildPoolFromLayerNames` - 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.

View File

@ -5,7 +5,7 @@ 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')
fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8')
).version;
/* As set out by the terms of the license, the following should not be modified. */

View File

@ -1,4 +1,4 @@
import { SQUADJS_VERSION, COPYRIGHT_MESSAGE } from '../constants.js';
import { SQUADJS_VERSION, COPYRIGHT_MESSAGE } from './constants.js';
const LOGO = `
_____ ____ _ _ _____ \x1b[33m_\x1b[0m

View File

@ -0,0 +1,274 @@
import axios from 'axios';
import didYouMean from 'didyoumean';
import fs from 'fs';
class SquadLayersBase {
get layerNames() {
return this.layers.map((layer) => layer.name);
}
getLayerByCondition(condition) {
const results = this.layers.filter(condition);
return results.length === 1 ? results[0] : null;
}
getLayerByLayerName(layerName) {
return this.getLayerByCondition((layer) => layer.layer === layerName);
}
getLayerByLayerClassname(layerClassname) {
return this.getLayerByCondition((layer) => layer.layerClassname === layerClassname);
}
getLayerByLayerNameAutoCorrection(layerName) {
return this.getLayerByLayerName(didYouMean(layerName, this.layerNames()));
}
getLayerByNumber(layerNumber) {
return this.getLayerByCondition((layer) => layer.layerNumber === layerNumber);
}
}
class SquadLayers extends SquadLayersBase {
constructor(source) {
super();
this.source =
source || 'https://raw.githubusercontent.com/Thomas-Smyth/squad-layers/master/layers.json';
this.pulled = false;
}
async pull(force = false) {
if (this.pulled && !force) return;
this.layers = (await axios.get(this.source)).data;
for (let i = 0; i < this.layers.length; i++) this.layers[i].layerNumber = i + 1;
}
buildPoolFromLayerNames(layerNames, activeFilter) {
return new SquadLayersPool(
this.layers.filter((layer) => layerNames.includes(layer.layer)),
activeFilter
);
}
buildPoolFromLayerNamesAutoCorrection(layerNames, activeFilter) {
return this.buildPoolFromLayerNames(
layerNames.map((layerName) => this.getLayerByLayerNameAutoCorrection(layerName)),
activeFilter
);
}
buildPoolFromFile(path, activeFilter, delimiter = '\n') {
return this.buildPoolFromLayerNames(
fs.readFileSync(path, 'utf8').split(delimiter),
activeFilter
);
}
buildPoolFromFilter(filter, activeFilter) {
const whitelistedLayers = filter.whitelistedLayers || null;
const blacklistedLayers = filter.blacklistedLayers || null;
const whitelistedMaps = filter.whitelistedMaps || null;
const blacklistedMaps = filter.blacklistedMaps || null;
const whitelistedGamemodes = filter.whitelistedGamemodes || null;
const blacklistedGamemodes = filter.blacklistedGamemodes || ['Training'];
const flagCountMin = filter.flagCountMin || null;
const flagCountMax = filter.flagCountMax || null;
const hasCommander = filter.hasCommander || null;
const hasTanks = filter.hasTanks || null;
const hasHelicopters = filter.hasHelicopters || null;
const layers = [];
for (const layer of this.layers) {
// Whitelist / Blacklist Layers
if (whitelistedLayers !== null && !whitelistedLayers.includes(layer.layer)) continue;
if (blacklistedLayers !== null && blacklistedLayers.includes(layer.layer)) continue;
// Whitelist / Blacklist Maps
if (whitelistedMaps !== null && !whitelistedMaps.includes(layer.map)) continue;
if (blacklistedMaps !== null && blacklistedMaps.includes(layer.map)) continue;
// Whitelist / Blacklist Gamemodes
if (whitelistedGamemodes !== null && !whitelistedGamemodes.includes(layer.gamemode)) continue;
if (blacklistedGamemodes !== null && blacklistedGamemodes.includes(layer.gamemode)) continue;
// Flag Count
if (flagCountMin !== null && layer.flagCount < flagCountMin) continue;
if (flagCountMax !== null && layer.flagCount > flagCountMax) continue;
// Other Properties
if (hasCommander !== null && layer.commander !== hasCommander) continue;
if (hasTanks !== null && (layer.tanks !== 'N/A') !== hasTanks) continue;
if (hasHelicopters !== null && (layer.helicopters !== 'N/A') !== hasHelicopters) continue;
layers.push(layer);
}
return new SquadLayersPool(layers, activeFilter);
}
}
class SquadLayersPool extends SquadLayersBase {
constructor(layers, activeFilter = null) {
super();
this.layers = layers;
for (let i = 0; i < this.layers.length; i++) this.layers[i].layerNumber = i + 1;
this.activeFilter = activeFilter;
}
inPool(layer) {
if (typeof layer === 'object') layer = layer.layer;
return super.layerNames.includes(layer);
}
isHistoryCompliant(layerHistory, layer) {
if (this.activeFilter === null) return true;
if (typeof layer === 'object') layer = layer.layer;
for (
let i = 0;
i < Math.min(layerHistory.length, this.activeFilter.layerHistoryTolerance);
i++
) {
if (new Date() - layerHistory[i].time > this.activeFilter.historyResetTime) return true;
if (layerHistory[i].layer === layer) return false;
}
return true;
}
isMapHistoryCompliant(layerHistory, layer) {
if (this.activeFilter === null) return true;
if (typeof layer === 'string') layer = this.getLayerByLayerName(layer);
for (let i = 0; i < Math.min(layerHistory.length, this.activeFilter.mapHistoryTolerance); i++) {
if (new Date() - layerHistory[i].time > this.activeFilter.historyResetTime) return true;
if (layerHistory[i].map === layer.map) return false;
}
return true;
}
isGamemodeHistoryCompliant(layerHistory, layer) {
if (this.activeFilter === null) return true;
if (typeof layer === 'string') layer = this.getLayerByLayerName(layer);
const gamemodeHistoryTolerance = this.activeFilter.gamemodeHistoryTolerance[layer.gamemode];
if (!gamemodeHistoryTolerance) return true;
for (let i = 0; i < Math.min(layerHistory.length, gamemodeHistoryTolerance); i++) {
if (new Date() - layerHistory[i].time > this.activeFilter.historyResetTime) return true;
if (layerHistory[i].gamemode === layer.gamemode) return false;
}
return true;
}
isGamemodeRepetitiveCompliant(layerHistory, layer) {
if (this.activeFilter === null) return true;
if (typeof layer === 'string') layer = this.getLayerByLayerName(layer);
const gamemodeRepetitiveTolerance = this.activeFilter.gamemodeRepetitiveTolerance[
layer.gamemode
];
if (!gamemodeRepetitiveTolerance) return true;
for (let i = 0; i < Math.min(layerHistory.length, gamemodeRepetitiveTolerance); i++) {
if (new Date() - layerHistory[i].time > this.activeFilter.historyResetTime) return true;
if (layerHistory[i].gamemode.gamemode !== layer.gamemode) return true;
}
return false;
}
isFactionCompliant(layerHistory, layer) {
if (this.activeFilter === null || this.activeFilter.factionComplianceEnabled === false)
return true;
if (layerHistory.length === 0) return true;
if (typeof layer === 'string') layer = this.getLayerByLayerName(layer);
return (
!layerHistory[0] ||
(layerHistory[0].teamOne.faction !== layer.teamTwo.faction &&
layerHistory[0].teamTwo.faction !== layer.teamOne.faction)
);
}
isFactionHistoryCompliant(layerHistory, layer, faction = null) {
if (this.activeFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
if (faction === null) {
return (
this.isFactionHistoryCompliant(layerHistory, layer, layer.teamOne.faction) &&
this.isFactionHistoryCompliant(layerHistory, layer, layer.teamTwo.faction)
);
} else {
const factionThreshold = this.activeFilter.factionHistoryTolerance[faction];
if (!factionThreshold) return true;
for (let i = 0; i < Math.min(layerHistory.length, factionThreshold); i++) {
if (new Date() - layerHistory[i].time > this.activeFilter.historyResetTime) return true;
if (
layerHistory[i].teamOne.faction === faction ||
layerHistory[i].teamTwo.faction === faction
)
return false;
}
return true;
}
}
isFactionRepetitiveCompliant(layerHistory, layer, faction = null) {
if (this.activeFilter === null) return true;
if (typeof layer === 'string') layer = SquadLayers.getLayerByLayerName(layer);
if (faction === null) {
return (
this.isFactionRepetitiveCompliant(layerHistory, layer, layer.teamOne.faction) &&
this.isFactionRepetitiveCompliant(layerHistory, layer, layer.teamTwo.faction)
);
} else {
const factionThreshold = this.activeFilter.factionRepetitiveTolerance[faction];
if (!factionThreshold) return true;
for (let i = 0; i < Math.min(layerHistory.length, factionThreshold); i++) {
if (new Date() - layerHistory[i].time > this.activeFilter.historyResetTime) return true;
if (
layerHistory[i].teamOne.faction !== faction &&
layerHistory[i].teamTwo.faction !== faction
)
return true;
}
return false;
}
}
isPlayerCountCompliant(server, layer) {
if (this.activeFilter === null || this.activeFilter.playerCountComplianceEnabled === false)
return true;
if (typeof layer === 'string') layer = this.getLayerByLayerName(layer);
return !(
server.players.length > layer.estimatedSuitablePlayerCount.max ||
server.players.length < layer.estimatedSuitablePlayerCount.min
);
}
}
export { SquadLayers, SquadLayersPool };