Merge branch 'v2' into auto-kick-afk

This commit is contained in:
Thomas Smyth 2020-12-08 12:17:02 +00:00 committed by GitHub
commit fae5dd4efa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1200 additions and 507 deletions

View File

@ -78,13 +78,6 @@ Connectors should be named, for example the above is named `discord`, and should
See below for more details on connectors and their associated config.
##### Discord
Connects to Discord via `discord.js`.
```json
"discord": "Discord Login Token",
```
Requires a Discord bot login token.
##### Squad Layer Filter
Connects to a filtered list of Squad layers and filters them either by an "initial filter" or an "active filter" that depends on current server information, e.g. player count.
```js
@ -153,19 +146,33 @@ Connects to a filtered list of Squad layers and filters them either by an "initi
- `factionHistoryTolerance` - A faction can only be played again after this number of layers. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
- `factionRepetitiveTolerance` - A faction can only be played this number of times in a row. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
##### MySQL
Connects to a MySQL database.
##### Discord
Connects to Discord via `discord.js`.
```json
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"password": "password",
"database": "squadjs"
"discord": "Discord Login Token",
```
Requires a Discord bot login token.
##### Databases
SquadJS uses [Sequelize](https://sequelize.org/) to connect and use a wide range of SQL databases.
The connector should be configured using any of Sequelize's single argument configuration options.
For example:
```json
"mysql": "mysql://user:pass@example.com:5432/dbname"
```
or:
```json
"sqlite": {
"dialect": "sqlite",
"storage": "path/to/database.sqlite"
}
```
The config is a set of pool connection options as listed in the [Node.js mysql](https://www.npmjs.com/package/mysql) documentation.
See [Sequelize's documentation](https://sequelize.org/master/manual/getting-started.html#connecting-to-a-database) for more details.
#### Plugins
The `plugins` section in your config file lists all plugins built into SquadJS, e.g.:
@ -274,6 +281,30 @@ The following is a list of plugins built into SquadJS, you can click their title
]</code></pre>
</details>
<details>
<summary>DBLog</summary>
<h2>DBLog</h2>
<p>The <code>mysql-log</code> plugin will log various server statistics and events to a database. This is great for server performance monitoring and/or player stat tracking.
Grafana (NOT YET WORKING WITH V2):
* [Grafana](https://grafana.com/) is a cool way of viewing server statistics stored in the database.
* Install Grafana.
* Add your database as a datasource named <code>SquadJS</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>database (Required)</h4>
<h6>Description</h6>
<p>The Sequelize connector to log server information to.</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>DiscordAdminBroadcast</summary>
<h2>DiscordAdminBroadcast</h2>
@ -441,23 +472,6 @@ The following is a list of plugins built into SquadJS, you can click their title
]</code></pre>
</details>
<details>
<summary>DiscordPlaceholder</summary>
<h2>DiscordPlaceholder</h2>
<p>The <code>DiscordPlaceholder</code> plugin can be used to create placeholder messages in Discord for use by other plugins.</p>
<h3>Options</h3>
<h4>discordClient (Required)</h4>
<h6>Description</h6>
<p>Discord connector name.</p>
<h6>Default</h6>
<pre><code>discord</code></pre>
<h4>command</h4>
<h6>Description</h6>
<p>Command that triggers the placeholder message.</p>
<h6>Default</h6>
<pre><code>!placeholder</code></pre>
</details>
<details>
<summary>DiscordRcon</summary>
<h2>DiscordRcon</h2>

View File

@ -54,12 +54,12 @@
}
},
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"username": "squadjs",
"password": "password",
"database": "squadjs"
"database": "squadjs",
"dialect": "mysql"
}
},
"plugins": [
@ -94,6 +94,12 @@
}
]
},
{
"plugin": "DBLog",
"enabled": false,
"database": "mysql",
"overrideServerID": null
},
{
"plugin": "DiscordAdminBroadcast",
"enabled": false,
@ -138,12 +144,6 @@
"channelID": "",
"events": []
},
{
"plugin": "DiscordPlaceholder",
"enabled": true,
"discordClient": "discord",
"command": "!placeholder"
},
{
"plugin": "DiscordRcon",
"enabled": false,

View File

@ -8,11 +8,16 @@ async function main() {
const configPath = process.argv[2];
if (config && configPath) throw new Error('Cannot accept both a config and config path.');
// create a SquadServer instance
const server = config
? await SquadServerFactory.buildFromConfigString(config)
: await SquadServerFactory.buildFromConfigFile(configPath || './config.json');
// watch the server
await server.watch();
// now mount the plugins
await Promise.all(server.plugins.map(async (plugin) => await plugin.mount()));
}
main();

View File

@ -3,28 +3,32 @@ import path from 'path';
import { fileURLToPath } from 'url';
import Discord from 'discord.js';
import mysql from 'mysql';
import sequelize from 'sequelize';
import Logger from 'core/logger';
import SquadServer from './index.js';
import plugins from './plugins/index.js';
const { Sequelize } = sequelize;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default class SquadServerFactory {
static async buildFromConfig(config) {
// Setup logging levels
// setup logging levels
for (const [module, verboseness] of Object.entries(config.verboseness)) {
Logger.setVerboseness(module, verboseness);
}
// create SquadServer
Logger.verbose('SquadServerFactory', 1, 'Creating SquadServer...');
const server = new SquadServer(config.server);
// pull layers read to use to create layer filter connectors
await server.squadLayers.pull();
// initialise connectors
Logger.verbose('SquadServerFactory', 1, 'Preparing connectors...');
const connectors = {};
for (const pluginConfig of config.plugins) {
@ -36,46 +40,31 @@ export default class SquadServerFactory {
// ignore non connectors
if (!option.connector) continue;
// check the connector is listed in the options
if (!(optionName in pluginConfig))
throw new Error(
`${Plugin.name}: ${optionName} (${option.connector} connector) is missing.`
);
// get the name of the connector
const connectorName = pluginConfig[optionName];
// skip already created connectors
if (connectors[connectorName]) continue;
const connectorConfig = config.connectors[connectorName];
if (option.connector === 'discord') {
Logger.verbose('SquadServerFactory', 1, `Starting discord connector ${connectorName}...`);
connectors[connectorName] = new Discord.Client();
await connectors[connectorName].login(connectorConfig);
} else if (option.connector === 'mysql') {
Logger.verbose(
'SquadServerFactory',
1,
`Starting mysqlPool connector ${connectorName}...`
// create the connector
connectors[connectorName] = await SquadServerFactory.createConnector(
server,
option.connector,
connectorName,
config.connectors[connectorName]
);
connectors[connectorName] = mysql.createPool(connectorConfig);
} else if (option.connector === 'squadlayerpool') {
Logger.verbose(
'SquadServer',
1,
`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.`);
}
}
}
Logger.verbose('SquadServerFactory', 1, 'Applying plugins to SquadServer...');
// initialise plugins
Logger.verbose('SquadServerFactory', 1, 'Initialising plugins...');
for (const pluginConfig of config.plugins) {
if (!pluginConfig.enabled) continue;
@ -86,30 +75,40 @@ export default class SquadServerFactory {
Logger.verbose('SquadServerFactory', 1, `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.`
const plugin = new Plugin(server, pluginConfig, connectors);
// allow the plugin to do any asynchronous work needed before it can be mounted
await plugin.prepareToMount();
server.plugins.push(plugin);
}
return server;
}
static async createConnector(server, type, connectorName, connectorConfig) {
Logger.verbose('SquadServerFactory', 1, `Starting ${type} connector ${connectorName}...`);
if (type === 'squadlayerpool') {
return server.squadLayers[connectorConfig.type](
connectorConfig.filter,
connectorConfig.activeLayerFilter
);
}
options[optionName] = pluginConfig[optionName] || option.default;
}
if (type === 'discord') {
const connector = new Discord.Client();
await connector.login(connectorConfig);
return connector;
}
server.plugins.push(new Plugin(server, options, pluginConfig));
if (type === 'sequelize') {
const connector = new Sequelize(connectorConfig);
await connector.authenticate();
return connector;
}
Logger.verbose('SquadServerFactory', 1, 'SquadServer built.');
return server;
throw new Error(`${type.connector} is an unsupported connector type.`);
}
static parseConfig(configString) {

View File

@ -21,6 +21,7 @@ export default class SquadServer extends EventEmitter {
for (const option of ['host', 'queryPort'])
if (!(option in options)) throw new Error(`${option} must be specified.`);
this.id = options.id;
this.options = options;
this.layerHistory = [];
@ -139,11 +140,10 @@ export default class SquadServer extends EventEmitter {
});
this.logParser.on('NEW_GAME', (data) => {
let layer;
if (data.layer) layer = this.squadLayers.getLayerByLayerName(data.layer);
else layer = this.squadLayers.getLayerByLayerClassname(data.layerClassname);
if (data.layer) data.layer = this.squadLayers.getLayerByLayerName(data.layer);
else data.layer = this.squadLayers.getLayerByLayerClassname(data.layerClassname);
this.layerHistory.unshift({ ...layer, time: data.time });
this.layerHistory.unshift({ ...data.layer, time: data.time });
this.layerHistory = this.layerHistory.slice(0, this.layerHistoryMaxLength);
this.emit('NEW_GAME', data);
@ -192,7 +192,6 @@ export default class SquadServer extends EventEmitter {
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 =
@ -266,6 +265,8 @@ export default class SquadServer extends EventEmitter {
async updatePlayerList() {
if (this.updatePlayerListTimeout) clearTimeout(this.updatePlayerListTimeout);
Logger.verbose('SquadServer', 1, `Updating player list...`);
try {
const oldPlayerInfo = {};
for (const player of this.players) {
@ -288,16 +289,23 @@ export default class SquadServer extends EventEmitter {
if (player.squadID !== oldPlayerInfo[player.steamID].squadID)
this.emit('PLAYER_SQUAD_CHANGE', player);
}
this.emit('UPDATED_PLAYER_INFORMATION');
} catch (err) {
Logger.verbose('SquadServer', 1, 'Failed to update player list.', err);
}
Logger.verbose('SquadServer', 1, `Updated player list.`);
this.updatePlayerListTimeout = setTimeout(this.updatePlayerList, this.updatePlayerListInterval);
}
async updateLayerInformation() {
if (this.updateLayerInformationTimeout) clearTimeout(this.updateLayerInformationTimeout);
Logger.verbose('SquadServer', 1, `Updating layer information...`);
try {
const layerInfo = await this.rcon.getLayerInfo();
@ -309,10 +317,14 @@ export default class SquadServer extends EventEmitter {
}
this.nextLayer = layerInfo.nextLayer;
this.emit('UPDATED_LAYER_INFORMATION');
} catch (err) {
Logger.verbose('SquadServer', 1, 'Failed to update layer information.', err);
}
Logger.verbose('SquadServer', 1, `Updated layer information.`);
this.updateLayerInformationTimeout = setTimeout(
this.updateLayerInformation,
this.updateLayerInformationInterval
@ -322,6 +334,8 @@ export default class SquadServer extends EventEmitter {
async updateA2SInformation() {
if (this.updateA2SInformationTimeout) clearTimeout(this.updateA2SInformationTimeout);
Logger.verbose('SquadServer', 1, `Updating A2S information...`);
try {
const data = await Gamedig.query({
type: 'squad',
@ -341,10 +355,14 @@ export default class SquadServer extends EventEmitter {
this.matchTimeout = parseFloat(data.raw.rules.MatchTimeout_f);
this.gameVersion = data.raw.version;
this.emit('UPDATED_A2S_INFORMATION');
} catch (err) {
Logger.verbose('SquadServer', 1, 'Failed to update A2S information.', err);
}
Logger.verbose('SquadServer', 1, `Updated A2S information.`);
this.updateA2SInformationTimeout = setTimeout(
this.updateA2SInformation,
this.updateA2SInformationInterval

View File

@ -9,8 +9,14 @@
"discord.js": "^12.3.1",
"gamedig": "^2.0.20",
"log-parser": "1.0.0",
"mysql": "^2.18.1",
"mariadb": "^2.5.1",
"mysql2": "^2.2.5",
"pg": "^8.5.1",
"pg-hstore": "^2.3.3",
"rcon": "1.0.0",
"sequelize": "^6.3.5",
"sqlite3": "^5.0.0",
"tedious": "^9.2.1",
"tinygradient": "^1.1.2"
},
"exports": {

View File

@ -19,11 +19,21 @@ export default class AutoTKWarn extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.server.on('TEAMKILL', async (info) => {
this.onTeamkill = this.onTeamkill.bind(this);
}
async mount() {
this.server.on('TEAMKILL', this.onTeamkill);
}
async unmount() {
this.server.removeEventListener('TEAMKILL', this.onTeamkill);
}
async onTeamkill(info) {
await this.server.rcon.warn(info.attacker.steamID, this.options.message);
});
}
}

View File

@ -1,4 +1,35 @@
import Logger from 'core/logger';
export default class BasePlugin {
constructor(server, options, connectors) {
this.server = server;
this.options = {};
this.rawOptions = options;
for (const [optionName, option] of Object.entries(this.constructor.optionsSpecification)) {
if (option.connector) {
this.options[optionName] = connectors[this.rawOptions[optionName]];
} else {
if (option.required) {
if (!(optionName in this.rawOptions))
throw new Error(`${this.constructor.name}: ${optionName} is required but missing.`);
if (option.default === this.rawOptions[optionName])
throw new Error(
`${this.constructor.name}: ${optionName} is required but is the default value.`
);
}
this.options[optionName] = this.rawOptions[optionName] || option.default;
}
}
}
async prepareToMount() {}
async mount() {}
async unmount() {}
static get description() {
throw new Error('Plugin missing "static get description()" method.');
}
@ -11,9 +42,7 @@ export default class BasePlugin {
throw new Error('Plugin missing "static get optionSpecification()" method.');
}
constructor(server, options = {}, optionsRaw = {}) {
this.server = server;
this.options = options;
this.optionsRaw = optionsRaw;
verbose(...args) {
Logger.verbose(this.constructor.name, ...args);
}
}

View File

@ -36,9 +36,7 @@ export default class ChatCommands extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
async mount() {
for (const command of this.options.commands) {
this.server.on(`CHAT_COMMAND:${command.command}`, async (data) => {
if (command.ignoreChats.includes(data.chat)) return;

View File

@ -0,0 +1,540 @@
import Sequelize from 'sequelize';
import BasePlugin from './base-plugin.js';
const { DataTypes } = Sequelize;
export default class DBLog extends BasePlugin {
static get description() {
return (
'The <code>mysql-log</code> plugin will log various server statistics and events to a database. This is great ' +
'for server performance monitoring and/or player stat tracking.' +
'\n\n' +
'Grafana (NOT YET WORKING WITH V2):\n' +
' * [Grafana](https://grafana.com/) is a cool way of viewing server statistics stored in the database.\n' +
' * Install Grafana.\n' +
' * Add your database as a datasource named <code>SquadJS</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.'
);
}
static get defaultEnabled() {
return false;
}
static get optionsSpecification() {
return {
database: {
required: true,
connector: 'sequelize',
description: 'The Sequelize connector to log server information to.',
default: 'mysql'
},
overrideServerID: {
required: false,
description: 'A overridden server ID.',
default: null
}
};
}
constructor(server, options, connectors) {
super(server, options, connectors);
this.models = {};
this.createModel('Server', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING
}
});
this.createModel('TickRate', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
time: {
type: DataTypes.DATE,
notNull: true
},
tickRate: {
type: DataTypes.FLOAT,
notNull: true
}
});
this.createModel('PlayerCount', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
time: {
type: DataTypes.DATE,
notNull: true,
defaultValue: DataTypes.NOW
},
players: {
type: DataTypes.INTEGER,
notNull: true
},
publicQueue: {
type: DataTypes.INTEGER,
notNull: true
},
reserveQueue: {
type: DataTypes.INTEGER,
notNull: true
}
});
this.createModel('Match', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
dlc: {
type: DataTypes.STRING
},
mapClassname: {
type: DataTypes.STRING
},
layerClassname: {
type: DataTypes.STRING
},
map: {
type: DataTypes.STRING
},
layer: {
type: DataTypes.STRING
},
startTime: {
type: DataTypes.DATE,
notNull: true
},
endTime: {
type: DataTypes.DATE
},
winner: {
type: DataTypes.STRING
}
});
this.createModel('SteamUser', {
steamID: {
type: DataTypes.STRING,
primaryKey: true
},
lastName: {
type: DataTypes.STRING
}
});
this.createModel('Wound', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
time: {
type: DataTypes.DATE,
notNull: true
},
victimName: {
type: DataTypes.STRING
},
victimTeamID: {
type: DataTypes.INTEGER
},
victimSquadID: {
type: DataTypes.INTEGER
},
attackerName: {
type: DataTypes.STRING
},
attackerTeamID: {
type: DataTypes.INTEGER
},
attackerSquadID: {
type: DataTypes.INTEGER
},
damage: {
type: DataTypes.FLOAT
},
weapon: {
type: DataTypes.STRING
},
teamkill: {
type: DataTypes.BOOLEAN
}
});
this.createModel('Death', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
time: {
type: DataTypes.DATE,
notNull: true
},
woundTime: {
type: DataTypes.DATE
},
victimName: {
type: DataTypes.STRING
},
victimTeamID: {
type: DataTypes.INTEGER
},
victimSquadID: {
type: DataTypes.INTEGER
},
attackerName: {
type: DataTypes.STRING
},
attackerTeamID: {
type: DataTypes.INTEGER
},
attackerSquadID: {
type: DataTypes.INTEGER
},
damage: {
type: DataTypes.FLOAT
},
weapon: {
type: DataTypes.STRING
},
teamkill: {
type: DataTypes.BOOLEAN
}
});
this.createModel('Revive', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
time: {
type: DataTypes.DATE,
notNull: true
},
woundTime: {
type: DataTypes.DATE
},
victimName: {
type: DataTypes.STRING
},
victimTeamID: {
type: DataTypes.INTEGER
},
victimSquadID: {
type: DataTypes.INTEGER
},
attackerName: {
type: DataTypes.STRING
},
attackerTeamID: {
type: DataTypes.INTEGER
},
attackerSquadID: {
type: DataTypes.INTEGER
},
damage: {
type: DataTypes.FLOAT
},
weapon: {
type: DataTypes.STRING
},
teamkill: {
type: DataTypes.BOOLEAN
},
reviverName: {
type: DataTypes.BOOLEAN
},
reviverTeamID: {
type: DataTypes.INTEGER
},
reviverSquadID: {
type: DataTypes.INTEGER
}
});
this.models.Server.hasMany(this.models.TickRate, {
foreignKey: { name: 'server', allowNull: false },
onDelete: 'CASCADE'
});
this.models.Server.hasMany(this.models.PlayerCount, {
foreignKey: { name: 'server', allowNull: false },
onDelete: 'CASCADE'
});
this.models.Server.hasMany(this.models.Match, {
foreignKey: { name: 'server', allowNull: false },
onDelete: 'CASCADE'
});
this.models.Server.hasMany(this.models.Wound, {
foreignKey: { name: 'server', allowNull: false },
onDelete: 'CASCADE'
});
this.models.Server.hasMany(this.models.Death, {
foreignKey: { name: 'server', allowNull: false },
onDelete: 'CASCADE'
});
this.models.Server.hasMany(this.models.Revive, {
foreignKey: { name: 'server', allowNull: false },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Wound, {
foreignKey: { name: 'attacker' },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Wound, {
foreignKey: { name: 'victim' },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Death, {
foreignKey: { name: 'attacker' },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Death, {
foreignKey: { name: 'victim' },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Revive, {
foreignKey: { name: 'attacker' },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Revive, {
foreignKey: { name: 'victim' },
onDelete: 'CASCADE'
});
this.models.SteamUser.hasMany(this.models.Revive, {
foreignKey: { name: 'reviver' },
onDelete: 'CASCADE'
});
this.models.Match.hasMany(this.models.Wound, {
foreignKey: { name: 'match' },
onDelete: 'CASCADE'
});
this.models.Match.hasMany(this.models.Death, {
foreignKey: { name: 'match' },
onDelete: 'CASCADE'
});
this.models.Match.hasMany(this.models.Revive, {
foreignKey: { name: 'match' },
onDelete: 'CASCADE'
});
this.onTickRate = this.onTickRate.bind(this);
this.onUpdatedA2SInformation = this.onUpdatedA2SInformation.bind(this);
this.onNewGame = this.onNewGame.bind(this);
this.onPlayerWounded = this.onPlayerWounded.bind(this);
this.onPlayerDied = this.onPlayerDied.bind(this);
this.onPlayerRevived = this.onPlayerRevived.bind(this);
}
createModel(name, schema) {
this.models[name] = this.options.database.define(`DBLog_${name}`, schema, {
timestamps: false
});
}
async prepareToMount() {
await this.models.Server.sync();
await this.models.TickRate.sync();
await this.models.PlayerCount.sync();
await this.models.Match.sync();
await this.models.SteamUser.sync();
await this.models.Wound.sync();
await this.models.Death.sync();
await this.models.Revive.sync();
}
async mount() {
await this.models.Server.upsert({
id: this.options.overrideServerID || this.server.id,
name: this.server.serverName
});
this.match = await this.models.Match.findOne({
where: { server: this.options.overrideServerID || this.server.id, endTime: null }
});
this.server.on('TICK_RATE', this.onTickRate);
this.server.on('UPDATED_A2S_INFORMATION', this.onUpdatedA2SInformation);
this.server.on('NEW_GAME', this.onNewGame);
this.server.on('PLAYER_WOUNDED', this.onPlayerWounded);
this.server.on('PLAYER_DIED', this.onPlayerDied);
this.server.on('PLAYER_REVIVED', this.onPlayerRevived);
}
async unmount() {
this.server.removeEventListener('TICK_RATE', this.onTickRate);
this.server.removeEventListener('UPDATED_A2S_INFORMATION', this.onTickRate);
this.server.removeEventListener('NEW_GAME', this.onNewGame);
this.server.removeEventListener('PLAYER_WOUNDED', this.onPlayerWounded);
this.server.removeEventListener('PLAYER_DIED', this.onPlayerDied);
this.server.removeEventListener('PLAYER_REVIVED', this.onPlayerRevived);
}
async onTickRate(info) {
await this.models.TickRate.create({
server: this.options.overrideServerID || this.server.id,
time: info.time,
tickRate: info.tickRate
});
}
async onUpdatedA2SInformation() {
await this.models.PlayerCount.create({
server: this.options.overrideServerID || this.server.id,
players: this.server.a2sPlayerCount,
publicQueue: this.server.publicQueue,
reserveQueue: this.server.reserveQueue
});
}
async onNewGame(info) {
await this.models.Match.update(
{ endTime: info.time, winner: info.winner },
{ where: { server: this.options.overrideServerID || this.server.id, endTime: null } }
);
this.match = await this.models.Match.create({
server: this.options.overrideServerID || this.server.id,
dlc: info.dlc,
mapClassname: info.mapClassname,
layerClassname: info.layerClassname,
map: info.layer ? info.layer.map : null,
layer: info.layer ? info.layer.layer : null,
startTime: info.time
});
}
async onPlayerWounded(info) {
if (info.attacker)
await this.models.SteamUser.upsert({
steamID: info.attacker.steamID,
lastName: info.attacker.name
});
if (info.victim)
await this.models.SteamUser.upsert({
steamID: info.victim.steamID,
lastName: info.victim.name
});
await this.models.Wound.create({
server: this.options.overrideServerID || this.server.id,
match: this.match ? this.match.id : null,
time: info.time,
victim: info.victim ? info.victim.steamID : null,
victimName: info.victim ? info.victim.name : null,
victimTeamID: info.victim ? info.victim.teamID : null,
victimSquadID: info.victim ? info.victim.squadID : null,
attacker: info.attacker ? info.attacker.steamID : null,
attackerName: info.attacker ? info.attacker.name : null,
attackerTeamID: info.attacker ? info.attacker.teamID : null,
attackerSquadID: info.attacker ? info.attacker.squadID : null,
damage: info.damage,
weapon: info.weapon,
teamkill: info.teamkill
});
}
async onPlayerDied(info) {
if (info.attacker)
await this.models.SteamUser.upsert({
steamID: info.attacker.steamID,
lastName: info.attacker.name
});
if (info.victim)
await this.models.SteamUser.upsert({
steamID: info.victim.steamID,
lastName: info.victim.name
});
await this.models.Death.create({
server: this.options.overrideServerID || this.server.id,
match: this.match ? this.match.id : null,
time: info.time,
woundTime: info.woundTime,
victim: info.victim ? info.victim.steamID : null,
victimName: info.victim ? info.victim.name : null,
victimTeamID: info.victim ? info.victim.teamID : null,
victimSquadID: info.victim ? info.victim.squadID : null,
attacker: info.attacker ? info.attacker.steamID : null,
attackerName: info.attacker ? info.attacker.name : null,
attackerTeamID: info.attacker ? info.attacker.teamID : null,
attackerSquadID: info.attacker ? info.attacker.squadID : null,
damage: info.damage,
weapon: info.weapon,
teamkill: info.teamkill
});
}
async onPlayerRevived(info) {
if (info.attacker)
await this.models.SteamUser.upsert({
steamID: info.attacker.steamID,
lastName: info.attacker.name
});
if (info.victim)
await this.models.SteamUser.upsert({
steamID: info.victim.steamID,
lastName: info.victim.name
});
if (info.reviver)
await this.models.SteamUser.upsert({
steamID: info.reviver.steamID,
lastName: info.reviver.name
});
await this.models.Revive.create({
server: this.options.overrideServerID || this.server.id,
match: this.match ? this.match.id : null,
time: info.time,
woundTime: info.woundTime,
victim: info.victim ? info.victim.steamID : null,
victimName: info.victim ? info.victim.name : null,
victimTeamID: info.victim ? info.victim.teamID : null,
victimSquadID: info.victim ? info.victim.squadID : null,
attacker: info.attacker ? info.attacker.steamID : null,
attackerName: info.attacker ? info.attacker.name : null,
attackerTeamID: info.attacker ? info.attacker.teamID : null,
attackerSquadID: info.attacker ? info.attacker.squadID : null,
damage: info.damage,
weapon: info.weapon,
teamkill: info.teamkill,
reviver: info.reviver ? info.reviver.steamID : null,
reviverName: info.reviver ? info.reviver.name : null,
reviverTeamID: info.reviver ? info.reviver.teamID : null,
reviverSquadID: info.reviver ? info.reviver.squadID : null
});
}
}

View File

@ -29,10 +29,21 @@ export default class DiscordAdminBroadcast extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.server.on('ADMIN_BROADCAST', async (info) => {
this.onAdminBroadcast = this.onAdminBroadcast.bind(this);
}
async mount() {
this.server.on('ADMIN_BROADCAST', this.onAdminBroadcast);
}
async unmount() {
this.server.removeEventListener('ADMIN_BROADCAST', this.onAdminBroadcast);
}
async onAdminBroadcast(info) {
await this.sendDiscordMessage({
embed: {
title: 'Admin Broadcast',
@ -46,6 +57,5 @@ export default class DiscordAdminBroadcast extends DiscordBasePlugin {
timestamp: info.time.toISOString()
}
});
});
}
}

View File

@ -26,12 +26,26 @@ export default class DiscordAdminCamLogs extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.adminsInCam = {};
this.server.on('PLAYER_POSSESS', async (info) => {
this.onPlayerPossess = this.onPlayerPossess.bind(this);
this.onPlayerUnPossess = this.onPlayerUnPossess.bind(this);
}
async mount() {
this.server.on('PLAYER_POSSESS', this.onPlayerPossess);
this.server.on('PLAYER_UNPOSSESS', this.onPlayerUnPossess);
}
async unmount() {
this.server.removeEventListener('PLAYER_POSSESS', this.onPlayerPossess);
this.server.removeEventListener('PLAYER_UNPOSSESS', this.onPlayerUnPossess);
}
async onPlayerPossess(info) {
if (info.player === null || info.possessClassname !== 'CameraMan') return;
this.adminsInCam[info.player.steamID] = info.time;
@ -55,9 +69,9 @@ export default class DiscordAdminCamLogs extends DiscordBasePlugin {
timestamp: info.time.toISOString()
}
});
});
}
this.server.on('PLAYER_UNPOSSESS', async (info) => {
async onPlayerUnPossess(info) {
if (
info.player === null ||
info.switchPossess === true ||
@ -92,6 +106,5 @@ export default class DiscordAdminCamLogs extends DiscordBasePlugin {
});
delete this.adminsInCam[info.player.steamID];
});
}
}

View File

@ -57,12 +57,23 @@ export default class DiscordAdminRequest extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.lastPing = Date.now() - this.pingDelay;
this.lastPing = Date.now() - this.options.pingDelay;
this.server.on(`CHAT_COMMAND:${this.options.command}`, async (info) => {
this.onChatCommand = this.onChatCommand.bind(this);
}
async mount() {
this.server.on(`CHAT_COMMAND:${this.options.command}`, this.onChatCommand);
}
async unmount() {
this.server.removeEventListener(`CHAT_COMMAND:${this.options.command}`, this.onChatCommand);
}
async onChatCommand(info) {
if (this.options.ignoreChats.includes(info.chat)) return;
for (const ignorePhrase of this.options.ignorePhrases) {
@ -105,10 +116,7 @@ export default class DiscordAdminRequest extends DiscordBasePlugin {
}
};
if (
this.options.pingGroups.length > 0 &&
Date.now() - this.options.pingDelay > this.lastPing
) {
if (this.options.pingGroups.length > 0 && Date.now() - this.options.pingDelay > this.lastPing) {
message.content = this.options.pingGroups.map((groupID) => `<@&${groupID}>`).join(' ');
this.lastPing = Date.now();
}
@ -119,6 +127,5 @@ export default class DiscordAdminRequest extends DiscordBasePlugin {
info.player.steamID,
`An admin has been notified, please wait for us to get back to you.`
);
});
}
}

View File

@ -14,16 +14,11 @@ export default class DiscordBasePlugin extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
this.channel = null;
async prepareToMount() {
this.channel = await this.options.discordClient.channels.fetch(this.options.channelID);
}
async sendDiscordMessage(message, channelID = this.options.channelID) {
if (this.channel === null)
this.channel = await this.options.discordClient.channels.fetch(channelID);
async sendDiscordMessage(message) {
if (typeof message === 'object' && 'embed' in message)
message.embed.footer = { text: COPYRIGHT_MESSAGE };

View File

@ -37,10 +37,21 @@ export default class DiscordChat extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.server.on('CHAT_MESSAGE', async (info) => {
this.onChatMessage = this.onChatMessage.bind(this);
}
async mount() {
this.server.on('CHAT_MESSAGE', this.onChatMessage);
}
async unmount() {
this.server.removeEventListener('CHAT_MESSAGE', this.onChatMessage);
}
async onChatMessage(info) {
if (this.options.ignoreChats.includes(info.chat)) return;
await this.sendDiscordMessage({
@ -70,6 +81,5 @@ export default class DiscordChat extends DiscordBasePlugin {
timestamp: info.time.toISOString()
}
});
});
}
}

View File

@ -30,9 +30,7 @@ export default class DiscordDebug extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
async mount() {
for (const event of this.options.events) {
this.server.on(event, async (info) => {
await this.sendDiscordMessage(`\`\`\`${JSON.stringify({ ...info, event }, null, 2)}\`\`\``);

View File

@ -1,42 +0,0 @@
import BasePlugin from './base-plugin.js';
export default class DiscordPlaceholder extends BasePlugin {
static get description() {
return (
'The <code>DiscordPlaceholder</code> plugin can be used to create placeholder messages in Discord for use by ' +
'other plugins.'
);
}
static get defaultEnabled() {
return true;
}
static get optionsSpecification() {
return {
discordClient: {
required: true,
description: 'Discord connector name.',
connector: 'discord',
default: 'discord'
},
command: {
required: false,
description: 'Command that triggers the placeholder message.',
default: '!placeholder'
}
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
this.options.discordClient.on('message', async (message) => {
// check the author of the message is not a bot
if (message.author.bot || !message.content.toLowerCase().startsWith(this.options.command))
return;
await message.channel.send('Placeholder.');
});
}
}

View File

@ -47,12 +47,23 @@ export default class DiscordRcon extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.options.discordClient.on('message', async (message) => {
this.onMessage = this.onMessage.bind(this);
}
async mount() {
this.options.discordClient.on('message', this.onMessage);
}
async unmount() {
this.options.discordClient.removeEventListener('message', this.onMessage);
}
async onMessage(message) {
// check the author of the message is not a bot and that the channel is the RCON console channel
if (message.author.bot || message.channel.id !== this.channelID) return;
if (message.author.bot || message.channel.id !== this.options.channelID) return;
let command = message.content;
@ -72,8 +83,7 @@ export default class DiscordRcon extends BasePlugin {
if (!message.member._roles.includes(role)) continue;
for (const allowedCommand of allowedCommands)
if (commandPrefix[1].toLowerCase() === allowedCommand.toLowerCase())
hasPermission = true;
if (commandPrefix[1].toLowerCase() === allowedCommand.toLowerCase()) hasPermission = true;
}
if (!hasPermission) {
@ -84,7 +94,6 @@ export default class DiscordRcon extends BasePlugin {
// execute command and print response
await this.respondToMessage(message, await this.server.rcon.execute(command));
});
}
async respondToMessage(message, response) {

View File

@ -26,10 +26,21 @@ export default class DiscordRoundWinner extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.server.on('NEW_GAME', async (info) => {
this.onNewGame = this.onNewGame.bind(this);
}
async mount() {
this.server.on('NEW_GAME', this.onNewGame);
}
async unmount() {
this.server.removeEventListener('NEW_GAME', this.onNewGame);
}
async onNewGame(info) {
await this.sendDiscordMessage({
embed: {
title: 'Round Winner',
@ -43,6 +54,5 @@ export default class DiscordRoundWinner extends DiscordBasePlugin {
timestamp: info.time.toISOString()
}
});
});
}
}

View File

@ -41,10 +41,21 @@ export default class DiscordServerStatus extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
setInterval(async () => {
this.update = this.update.bind(this);
}
async mount() {
this.interval = setInterval(this.update, this.options.updateInterval);
}
async unmount() {
clearInterval(this.interval);
}
async update() {
for (const messageID of this.options.messageIDs) {
try {
const channel = await this.options.discordClient.channels.fetch(messageID.channelID);
@ -62,7 +73,6 @@ export default class DiscordServerStatus extends BasePlugin {
}`,
{ type: 'WATCHING' }
);
}, this.options.updateInterval);
}
getEmbed() {

View File

@ -33,10 +33,21 @@ export default class DiscordSubsystemRestarter extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.options.discordClient.on('message', async (message) => {
this.onMessage = this.onMessage.bind(this);
}
async mount() {
this.options.discordClient.on('message', this.onMessage);
}
async unmount() {
this.options.discordClient.removeEventListener('message', this.onMessage);
}
async onMessage(message) {
// check the author of the message is not a bot
if (message.author.bot) return;
@ -57,6 +68,5 @@ export default class DiscordSubsystemRestarter extends BasePlugin {
await this.server.restartLogParser();
message.reply('restarted the SquadJS LogParser subsystem.');
}
});
}
}

View File

@ -34,10 +34,21 @@ export default class DiscordTeamkill extends DiscordBasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.server.on('TEAMKILL', async (info) => {
this.onTeamkill = this.onTeamkill.bind(this);
}
async mount() {
this.server.on('TEAMKILL', this.onTeamkill);
}
async unmount() {
this.server.removeEventListener('TEAMKILL', this.onTeamkill);
}
async onTeamkill(info) {
if (!info.attacker) return;
const fields = [
@ -81,6 +92,5 @@ export default class DiscordTeamkill extends DiscordBasePlugin {
timestamp: info.time.toISOString()
}
});
});
}
}

View File

@ -1,12 +1,12 @@
import AutoTKWarn from './auto-tk-warn.js';
import AutoKickUnassigned from './auto-kick-unassigned.js';
import ChatCommands from './chat-commands.js';
import DBLog from './db-log.js';
import DiscordAdminBroadcast from './discord-admin-broadcast.js';
import DiscordAdminCamLogs from './discord-admin-cam-logs.js';
import DiscordAdminRequest from './discord-admin-request.js';
import DiscordChat from './discord-chat.js';
import DiscordDebug from './discord-debug.js';
import DiscordPlaceholder from './discord-placeholder.js';
import DiscordRcon from './discord-rcon.js';
import DiscordRoundWinner from './discord-round-winner.js';
import DiscordServerStatus from './discord-server-status.js';
@ -19,12 +19,12 @@ const plugins = [
AutoTKWarn,
AutoKickUnassigned,
ChatCommands,
DBLog,
DiscordAdminBroadcast,
DiscordAdminCamLogs,
DiscordAdminRequest,
DiscordChat,
DiscordDebug,
DiscordPlaceholder,
DiscordRcon,
DiscordRoundWinner,
DiscordServerStatus,

View File

@ -28,12 +28,22 @@ export default class IntervalledBroadcasts extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
setInterval(async () => {
this.broadcast = this.broadcast.bind(this);
}
async mount() {
this.interval = setInterval(this.broadcast, this.options.interval);
}
async unmount() {
clearInterval(this.interval);
}
async broadcast() {
await this.server.rcon.broadcast(this.options.broadcasts[0]);
this.broadcasts.push(this.options.broadcasts.shift());
}, this.options.interval);
}
}

View File

@ -48,14 +48,22 @@ export default class SeedingMode extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
setInterval(async () => {
if (
this.server.a2sPlayerCount !== 0 &&
this.server.a2sPlayerCount < this.options.liveThreshold
)
this.broadcast = this.broadcast.bind(this);
}
async mount() {
this.interval = setInterval(this.broadcast, this.options.interval);
}
async unmount() {
clearInterval(this.interval);
}
async broadcast() {
if (this.server.a2sPlayerCount !== 0 && this.server.a2sPlayerCount < this.options.liveThreshold)
await this.server.rcon.broadcast(this.options.seedingMessage);
else if (
this.server.a2sPlayerCount !== 0 &&
@ -63,6 +71,5 @@ export default class SeedingMode extends BasePlugin {
this.server.a2sPlayerCount < this.options.liveThreshold
)
await this.server.rcon.broadcast(this.options.liveMessage);
}, this.options.interval);
}
}

View File

@ -22,10 +22,21 @@ export default class TeamRandomizer extends BasePlugin {
};
}
constructor(server, options, optionsRaw) {
super(server, options, optionsRaw);
constructor(server, options, connectors) {
super(server, options, connectors);
this.server.on(`CHAT_COMMAND:${this.options.command}`, async (info) => {
this.onChatCommand = this.onChatCommand.bind(this);
}
async mount() {
this.server.on(`CHAT_COMMAND:${this.options.command}`, this.onChatCommand);
}
async unmount() {
this.server.removeEventListener(`CHAT_COMMAND:${this.options.command}`, this.onChatCommand);
}
async onChatCommand(info) {
if (info.chat !== 'ChatAdmin') return;
const players = this.server.players.slice(0);
@ -50,6 +61,5 @@ export default class TeamRandomizer extends BasePlugin {
team = team === '1' ? '2' : '1';
}
});
}
}

View File

@ -54,12 +54,12 @@
}
},
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"username": "squadjs",
"password": "password",
"database": "squadjs"
"database": "squadjs",
"dialect": "mysql"
}
},
"plugins": [],

View File

@ -78,13 +78,6 @@ Connectors should be named, for example the above is named `discord`, and should
See below for more details on connectors and their associated config.
##### Discord
Connects to Discord via `discord.js`.
```json
"discord": "Discord Login Token",
```
Requires a Discord bot login token.
##### Squad Layer Filter
Connects to a filtered list of Squad layers and filters them either by an "initial filter" or an "active filter" that depends on current server information, e.g. player count.
```js
@ -153,19 +146,33 @@ Connects to a filtered list of Squad layers and filters them either by an "initi
- `factionHistoryTolerance` - A faction can only be played again after this number of layers. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
- `factionRepetitiveTolerance` - A faction can only be played this number of times in a row. Factions can be specified individually inside the object. If they are not listed then the filter is not applied.
##### MySQL
Connects to a MySQL database.
##### Discord
Connects to Discord via `discord.js`.
```json
"mysql": {
"connectionLimit": 10,
"host": "host",
"port": 3306,
"user": "squadjs",
"password": "password",
"database": "squadjs"
"discord": "Discord Login Token",
```
Requires a Discord bot login token.
##### Databases
SquadJS uses [Sequelize](https://sequelize.org/) to connect and use a wide range of SQL databases.
The connector should be configured using any of Sequelize's single argument configuration options.
For example:
```json
"mysql": "mysql://user:pass@example.com:5432/dbname"
```
or:
```json
"sqlite": {
"dialect": "sqlite",
"storage": "path/to/database.sqlite"
}
```
The config is a set of pool connection options as listed in the [Node.js mysql](https://www.npmjs.com/package/mysql) documentation.
See [Sequelize's documentation](https://sequelize.org/master/manual/getting-started.html#connecting-to-a-database) for more details.
#### Plugins
The `plugins` section in your config file lists all plugins built into SquadJS, e.g.: