-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathnextcontrol.js
More file actions
587 lines (468 loc) · 22.7 KB
/
nextcontrol.js
File metadata and controls
587 lines (468 loc) · 22.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
/**
* NextControl
* A Trackmania (2020) dedicated server controller script
*
* Main class file
*/
/**
* Required libraries
*/
import gbxremote from 'gbxremote';
import mongodb from 'mongodb';
import mariadb from 'mariadb';
/**
* Other imports
*/
import * as CallbackParams from './lib/callbackparams.js';
import * as Classes from './lib/classes.js';
import { format, logger, stripFormatting } from './lib/utilities.js';
import { DatabaseLib } from "./lib/databaseLib.js";
import { ServerLib } from "./lib/serverLib.js";
import { Settings } from './settings.js';
import { Sentences } from './lib/sentences.js';
import { getPluginList } from './plugins.js'
import { TMX } from './lib/tmx.js'
import * as fs from "fs";
const dbtype = Settings.usedDatabase.toLocaleLowerCase();
/**
* Main class containing the controller's brain
*/
export class NextControl {
/**
* Object containing Dictionaries (login => list), to store query results in
*/
lists = {
/**
* List containing an array of PlayerInfos from a previous query. Key is the player login starting a query before.
* @type {Map<string, Array<Classes.PlayerInfo>>}
*/
players: undefined,
/**
* List containing an array of Maps from a previous query. Key is the player login starting a query before.
* @type {Map<string, Array<Classes.Map>>}
*/
maps: undefined
}
/**
* Chat Commands list
* @type {Array<Classes.ChatCommand>}
*/
chatCommands
/**
* Admin Commands list
* @type {Array<Classes.ChatCommand>}
*/
adminCommands
/**
* Flag will be set to true, once the class instance is ready for listening.
* @type {Boolean}
*/
isReady = false;
/**
* Mongodb database object
* @type {mongodb.Db}
*/
mongoDb
/**
* MariaDB database connection object
* @type {mariadb.PoolConnection}
*/
mysql
/**
* DatabaseLib object
* @type {DatabaseLib}
*/
dblib
/**
* ServerLib objekt
* @type {ServerLib}
*/
serverlib
/**
* Controller for the gamemode settings
* @type {Classes.ModeSettingsController}
*/
modeSettings
/**
* List of required collections that need to exist in the MongoDB database
* @type {Array<String>}
*/
requiredCollections = []
/**
* Do not instatiate this class yourself (unless you know what you're doing ;-)), the only existing object should be passed around by the object itself!
*/
constructor () {
this.lists.players = new Map();
this.lists.maps = new Map();
}
/**
* Prepares NextControl to be ready for use
*/
async startup() {
logger('su', 'Starting NextControl...');
// create Trackmania XML-RPC client
let client = gbxremote.createClient(Settings.trackmania.port);
let serverPromise = new Promise((resolve, reject) => {
// check for error
client.on('error', () => {
logger('er', 'Could not connect to server. Check your settings!');
process.exit(1);
})
// upon connection
client.on('connect', async () => {
// set API version
await client.query('SetApiVersion', ['2019-03-02']).catch(() => {
logger('er', 'Setting API version failed.');
process.exit(2);
});
// authenticate as SuperAdmin
await client.query('Authenticate', [Settings.trackmania.login, Settings.trackmania.password]).catch(() => {
logger('er', 'Authentication failed -- check credentials!');
process.exit(3);
});
// enable callbacks
await client.query('EnableCallbacks', [true]).catch(() => {
logger('er', 'Enabling callbacks failed.');
process.exit(4);
});
// enable script callbacks
await client.query('TriggerModeScriptEventArray', ['XmlRpc.EnableCallbacks', ['true']]).catch(() => {
logger('er', 'Enabling script callbacks failed.');
process.exit(5);
});
// and "return" the functioning client object
resolve(client);
});
});
// wait for promise
await serverPromise.catch(e => {
logger('er', 'Connecting to the server has failed. Check port.');
process.exit(6);
});
logger('su', 'Connected to Trackmania Server');
// set properties accordingly
this.client = client;
// woo, we're connected!
await this.client.query('ChatSendServerMessage', ['$0f0~~ $fffStarting NextControl ...']);
if (dbtype === 'mongodb') {
// create MongoDB client
let database = new mongodb.MongoClient(Settings.mongoDb.uri, { useNewUrlParser: true, useUnifiedTopology: true });
// wait for database connection
await database.connect().catch(e => {
logger('er', JSON.stringify(e, null, 2));
process.exit(7);
});
// set properties accordingly
this.mongoDb = await database.db(Settings.mongoDb.database)
} else if (dbtype === "mysql") {
// create connection pool
let pool = await mariadb.createPool(Settings.mySql);
// create actual connection
let conn = await pool.getConnection().catch(e => {
logger('er', JSON.stringify(e, null, 2));
process.exit(7);
});
// set properties accordingly
this.mysql = conn;
}
// set up helper libraries
this.serverlib = new ServerLib(this);
this.dblib = new DatabaseLib(this);
// woo, we're connected!
logger('su', 'Connected to Database Server');
await this.client.query('ChatSendServerMessage', ['$0f0~~ $fffConnected to database ...']);
// set up necessary collections or tables
if (dbtype === "mongodb") await this.dblib.mongodbCheckCollections();
if (dbtype === "mysql") await this.dblib.mysqlCheckTables();
// ensure ./settings/ exists for our plugins
if (!fs.existsSync('./settings'))
fs.mkdirSync('./settings');
// now lets load plugins:
this.chatCommands = [];
this.adminCommands = [];
this.plugins = getPluginList(this);
// log plugins
let pluginList = "";
this.plugins.forEach((plugin, idx) => {
if (idx < this.plugins.length - 1)
pluginList += plugin.name + ', '; else pluginList += plugin.name
});
logger('su', 'Plugins loaded: ' + pluginList);
// log commands
let commandList = '';
this.chatCommands.forEach((command, idx) => {
if (idx < this.chatCommands.length - 1)
commandList += command.commandName + ', '; else commandList += command.commandName
})
logger('su', 'Chat commands registered: ' + commandList);
// log admin commands
let adminCList = '';
this.adminCommands.forEach((command, idx) => {
if (idx < this.adminCommands.length - 1)
adminCList += command.commandName + ', '; else adminCList += command.commandName
})
logger('su', 'Admin commands registered: ' + adminCList);
// now that we're done:
this.isReady = true;
logger('i', 'Startup completed, starting to listen');
await this.client.query('ChatSendServerMessage', ['$0f0~~ $fffUp and running!']);
this.startListening();
}
/**
* Function, to start listening to the server and dealing with the server's callbacks
*/
async startListening() {
if (!this.isReady) return false
// initialize status object
this.status = new Classes.Status();
await this.status.init(this);
// initialize jukebox
this.jukebox = new Classes.Jukebox();
// initialize mode settings controller
this.modeSettings = new Classes.ModeSettingsController(this);
await this.modeSettings.init();
// start actually listening
this.client.on('callback', async (method, para) => {
let p;
// we need to catch callbacks from the gamemode script beforehand, to properly get along with them
if (method === 'ManiaPlanet.ModeScriptCallbackArray') {
method = para.shift();
p = JSON.parse(para[0][0]);
}
//console.log(beautify({method: method, para: para}, null, 2));
if (method === 'Trackmania.Event.WayPoint') {
p = new Classes.WaypointInfo(p);
this.plugins.forEach(plugin => { if (typeof plugin.onWaypoint != "undefined") plugin.onWaypoint(p); })
} else if (method === 'ManiaPlanet.PlayerConnect') {
let login = String(para[0]);
p = new Classes.PlayerInfo(await this.client.query('GetPlayerInfo', [login, 1]));
// add player to status
this.status.addPlayer(p);
//get variables right for handlers
let isSpectator = Boolean(para[1]);
// start player connect handlers
this.plugins.forEach(plugin => { if (typeof plugin.onPlayerConnect != "undefined") plugin.onPlayerConnect(p, isSpectator) });
} else if (method === 'ManiaPlanet.PlayerDisconnect') {
let player = this.status.getPlayer(String(para[0])), //<- playerInfo
reason = String(para[1]);
// clear temporarily stored lists for the leaving player
Object.keys(this.lists).forEach(key => {
if (this.lists[key].has(player.login)) this.lists[key].delete(player.login);
});
// start player disconnect handlers
this.plugins.forEach(plugin => { if (typeof plugin.onPlayerDisconnect != "undefined") plugin.onPlayerDisconnect(player, reason) });
// remove player from status
this.status.removePlayer(player.login);
} else if (method === 'ManiaPlanet.PlayerChat') {
let login = String(para[1]),
text = String(para[2]),
isCommand = Boolean(para[3]);
// chat command handling
if (isCommand) {
let splitCommand = text.substring(text.charAt(1)=='/'?2:1).split(' '),
command = splitCommand.shift(),
params = splitCommand.join(' '),
player = this.status.getPlayer(login);
if (command == 'admin' || text.charAt(1) == '/') {
// handle admin command, command is "first" parameter
if (!Settings.admins.includes(login)) {
// player is not admin!
logger('r', login + ' tried using command /admin ' + adminCommand + ', but is no admin!');
this.client.query('ChatSendServerMessageToLogin', [Sentences.playerNotAdmin, login]);
}
var splitAdminCommand,adminCommand,adminParams;
if (command =='admin'){
splitAdminCommand = params.split(' ');
adminCommand = splitAdminCommand.shift();
adminParams = splitAdminCommand;
} else if (text.charAt(1) == '/'){
splitAdminCommand = params.split(' ');
adminCommand = command;
adminParams = splitAdminCommand;
}
logger('r', stripFormatting(player.name) + ' used admin command ' + adminCommand + ' with parameters: ' + adminParams);
this.adminCommands.forEach(commandDefinition => {
if (commandDefinition.commandName === adminCommand) {
this.plugins.forEach(plugin => {
if (plugin.name == commandDefinition.pluginName)
plugin[commandDefinition.commandHandler.name](login, adminParams);
})
}
});
}
else {
// handle regular command
logger('r', stripFormatting(player.name) + ' used command /' + command + ' with parameters: ' + params);
this.chatCommands.forEach(commandDefinition => {
if (commandDefinition.commandName === command) {
this.plugins.forEach(plugin => {
if (plugin.name == commandDefinition.pluginName) {
plugin[commandDefinition.commandHandler.name](login, splitCommand);
}
})
}
});
}
}
// regular onChat function
this.plugins.forEach(plugin => { if (typeof plugin.onChat != "undefined") plugin.onChat(login, text) });
} else if (method === 'ManiaPlanet.BeginMap') {
// parse map object
p = new Classes.Map(para[0]);
/**
* copy of the Map object as given by server
* @type {Classes.Map}
*/
let servMap = JSON.parse(JSON.stringify(p));
// check if map is in database already
if (dbtype === 'mongodb') {
if ((await this.mongoDb.collection('maps').countDocuments({uid : p.uid})) > 0)
p = Classes.Map.fromDb(await this.mongoDb.collection('maps').findOne({uid : p.uid}));
else { // map isn't in database yet:
// find TMX id
p.setTMXId(await TMX.getID(p.uid));
// update database entry
await this.mongoDb.collection('maps').insertOne(p);
}
} else if (dbtype === 'mysql') {
this.mysql.query('SELECT * FROM maps WHERE uid = ?', p.uid).then(async rows => {
if (rows.length > 0)
p = Classes.Map.fromDb(rows[0]);
else { // map isn't in database yet:
// find TMX id
p.setTMXId(await TMX.getID(p.uid));
// update database entry
this.mysql.query('INSERT INTO maps VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)', [
p.uid,
p.name,
p.file,
p.author,
p.mood,
p.medals,
p.coppers,
p.isMultilap,
p.nbLaps,
p.nbCheckpoints,
p.type,
p.style,
p.tmxid
]).catch(err => {
logger('er', err)
});
}
}).catch(err => {
logger('er', err)
});
}
let hasChanged = false;
// check if the map has a set TMX ID
if (p.tmxid === -1) {
p.setTMXId(await TMX.getID(p.uid));
hasChanged = true;
}
// check if the map has a set Checkpoint number
if (p.nbCheckpoints === -1) {
p.nbCheckpoints = servMap.nbCheckpoints;
hasChanged = true;
}
// check if the map has a set Lap number
if (p.nbLaps === -1) {
p.nbLaps = servMap.nbLaps;
hasChanged = true;
}
// update the map document in database, if we have just changed it
if (dbtype === 'mongodb') {
if (hasChanged) await this.mongoDb.collection('maps').updateOne({uid: p.uid}, {$set: p})
} else if (dbtype === 'mysql') {
if (hasChanged) this.mysql.query('UPDATE maps SET name=?,file=?,author=?,mood=?,medals=?,coppers=?,isMultilap=?,nbLaps=?,nbCheckpoints=?,type=?,style=?,tmxid=? WHERE uid = ?', [
p.name,
p.file,
p.author,
p.mood,
p.medals,
p.coppers,
p.isMultilap,
p.nbLaps,
p.nbCheckpoints,
p.type,
p.style == undefined ? "" : p.style, // It's common that the style is undefined, so we need to check for that and add an empty string if it's undefined
p.tmxid,
p.uid
]);
}
// update status:
this.status.map = p;
this.plugins.forEach(plugin => { if (typeof plugin.onBeginMap != "undefined") plugin.onBeginMap(p) });
} else if (method === 'ManiaPlanet.BeginMatch') {
// has no parameters
this.plugins.forEach(plugin => { if (typeof plugin.onBeginMatch != "undefined") plugin.onBeginMatch() });
} else if (method === 'ManiaPlanet.BillUpdated') {
p = new CallbackParams.UpdatedBill(para);
this.plugins.forEach(plugin => { if (typeof plugin.onBillUpdate != "undefined") plugin.onBillUpdate(p) });
} else if (method === 'ManiaPlanet.EndMap') {
p = new Classes.Map(para[0]);
this.plugins.forEach(plugin => { if (typeof plugin.onEndMap != "undefined") plugin.onEndMap(p) });
} else if (method === 'ManiaPlanet.EndMatch') {
p = new CallbackParams.MatchResults(para);
// reset mode settings to default
await this.modeSettings.resetSettings();
this.plugins.forEach(plugin => { if (typeof plugin.onEndMatch != "undefined") plugin.onEndMatch(p) });
} else if (method === 'ManiaPlanet.MapListModified') {
p = new CallbackParams.MaplistChange(para);
this.plugins.forEach(plugin => { if (typeof plugin.onMaplistChange != "undefined") plugin.onMaplistChange(p) });
} else if (method === 'ManiaPlanet.PlayerAlliesChanged') {
p = para[0]; // = player login
this.plugins.forEach(plugin => { if (typeof plugin.onPlayersAlliesChange != "undefined") plugin.onPlayersAlliesChange(p) });
} else if (method === 'ManiaPlanet.PlayerInfoChanged') {
p = new Classes.PlayerInfo(para[0]);
this.plugins.forEach(plugin => { if (typeof plugin.onPlayerInfoChange != "undefined") plugin.onPlayerInfoChange(p) });
} else if (method === 'ManiaPlanet.PlayerManialinkPageAnswer') {
p = new CallbackParams.ManialinkPageAnswer(para);
this.plugins.forEach(plugin => { if (typeof plugin.onManialinkPageAnswer != "undefined") plugin.onManialinkPageAnswer(p) });
} else if (method === 'ManiaPlanet.StatusChanged') {
p = Classes.ServerStatus.fromCallback(para);
this.plugins.forEach(plugin => { if (typeof plugin.onStatusChange != "undefined") plugin.onStatusChange(p) });
} else if (method === 'ManiaPlanet.TunnelDataReceived') {
p = new CallbackParams.TunnelData(para);
this.plugins.forEach(plugin => { if (typeof plugin.onTunnelDataRecieved != "undefined") plugin.onTunnelDataRecieved(p) });
} else if (method === 'ManiaPlanet.VoteUpdated') {
p = Classes.CallVote.fromCallback(para);
this.plugins.forEach(plugin => { if (typeof plugin.onVoteUpdate != "undefined") plugin.onVoteUpdate(p) });
} else if (method === 'TrackMania.PlayerIncoherence') {
p = new CallbackParams.PlayerIncoherence(para);
this.plugins.forEach(plugin => { if (typeof plugin.onIncoherence != "undefined") plugin.onIncoherence(p) });
}
});
}
/**
* Registers a chat command to be used later
* @param {Classes.ChatCommand} comm
*/
registerChatCommand(comm) {
// no custom admin command allowed
if (comm.name == 'admin') { logger('w', `A chat command from plugin ${comm.pluginName} attempted to register itself as /admin, fix the plugin or contact the plugin's developer.`); return; }
// faulty command definition
if (comm.commandName == undefined || comm.commandHandler == undefined || comm.commandDescription == undefined || comm.commandName == '' || comm.commandDescription == '') { logger('w', `Chat command ${comm.toString()} from plugin ${comm.pluginName} has an invalid command definition lacking name, a handler function or a description, fix the plugin or contact the plugin's developer.`); return; }
this.chatCommands.push(comm);
}
/**
* Registers a chat command to be used
* @param {Classes.ChatCommand} comm
*/
registerAdminCommand(comm) {
// faulty command definition
if (comm.commandName == undefined || comm.commandHandler == undefined || comm.commandDescription == undefined || comm.commandName == '' || comm.commandDescription == '') { logger('w', `Chat command ${comm.toString()} from plugin ${comm.pluginName} has an invalid command definition lacking name, a handler function or a description, fix the plugin or contact the plugin's developer.`); return; }
this.adminCommands.push(comm);
}
/**
* Registers a collection as required to exist for startup checks
* @param {String} collection
*/
addRequiredCollection(collection) {
if (!this.requiredCollections.includes(collection))
this.requiredCollections.push(collection);
}
}
// actually executed code
let nc = new NextControl();
await nc.startup();