mirror of
https://github.com/AsgardEternal/SquadJS.git
synced 2024-09-28 07:14:23 -05:00
SquadJS v2.0 Initial Framework
This commit is contained in:
parent
45f089c94b
commit
39efc08a78
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,7 +1,6 @@
|
||||
# Project Files
|
||||
*.tmp
|
||||
|
||||
index-test.js
|
||||
config-test.json
|
||||
|
||||
# Dependencies
|
||||
|
554
README.md
554
README.md
@ -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
|
||||
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
152
config.json
152
config.json
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
@ -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
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
@ -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);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
export default function (time) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, time);
|
||||
});
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import SquadServer from 'squad-server';
|
||||
|
||||
export default function (config) {
|
||||
return new SquadServer(config.server);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
16
index.js
16
index.js
@ -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...');
|
||||
});
|
||||
|
@ -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;
|
37
log-parser/log-readers/ftp.js
Normal file
37
log-parser/log-readers/ftp.js
Normal 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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
14
log-parser/rules/player-connected.js
Normal file
14
log-parser/rules/player-connected.js
Normal 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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
11
package.json
11
package.json
@ -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",
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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];
|
||||
});
|
||||
}
|
||||
};
|
@ -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.`
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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)}\`\`\``);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -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}\`\`\``);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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;
|
@ -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);
|
||||
}
|
||||
};
|
@ -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' : ''
|
||||
})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
@ -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
@ -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
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
@ -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 ;
|
@ -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"
|
||||
}
|
||||
}
|
@ -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.');
|
||||
}
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -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
8
rcon/package.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "rcon",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
13
squad-server/plugins/base-plugin.js
Normal file
13
squad-server/plugins/base-plugin.js
Normal 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.');
|
||||
}
|
||||
}
|
34
squad-server/plugins/example-plugin.js
Normal file
34
squad-server/plugins/example-plugin.js
Normal 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
|
||||
}
|
||||
}
|
10
squad-server/plugins/index.js
Normal file
10
squad-server/plugins/index.js
Normal 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;
|
@ -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
|
||||
};
|
@ -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));
|
@ -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)
|
@ -18,8 +18,8 @@
|
||||
},
|
||||
"connectors": {
|
||||
"discord": "Discord Login Token",
|
||||
"layerFilter": {
|
||||
"type": "buildFromFilter",
|
||||
"squadlayerpool": {
|
||||
"type": "buildPoolFromFilter",
|
||||
"filter": {
|
||||
"whitelistedLayers": null,
|
||||
"blacklistedLayers": null,
|
@ -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.
|
@ -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. */
|
@ -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
|
274
squad-server/utils/squad-layers.js
Normal file
274
squad-server/utils/squad-layers.js
Normal 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 };
|
Loading…
Reference in New Issue
Block a user