-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
701 lines (615 loc) · 16.5 KB
/
index.js
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
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
"use strict"
///////////////////////////////////////////////////////////////////////////////
//
// kag.party - a queue site for king arthur's gold
//
// TODO: https server, or ensure works behind https proxy
//
// TODO: matchmake more actively; avoid leaving 1-2 stragglers in a queue
// after a game started.
///////////////////////////////////////////////////////////////////////////////
//deps
const fs = require("fs");
const http = require("http");
const https = require("https");
const url = require("url");
const crypto = require("crypto");
const node_static = require("node-static");
const WebSocket = require('ws');
//helpers
//perform a http request
//cb takes (error, data)
// error is null on success
// data is the data received as a string (either all of it, or however much was recieved til the error occurred)
function http_request(url, cb)
{
let data = '';
(url.indexOf("https://") == 0 ? https : http).get(url, (resp) => {
const statusCode = resp.statusCode;
let error;
if (statusCode !== 200) {
error = new Error(`Request Failed.\nStatus Code: ${statusCode}`);
}
if (error) {
//tell the caller
cb(error, "", resp);
// consume response data to free up memory
res.resume();
return;
}
resp.on('data', (chunk) => {
data += chunk;
});
resp.on('end', () => {
cb(null, data, resp);
});
}).on("error", (err) => {
cb(err, data, null);
});
}
///////////////////////////////////////////////////////////////////////////////
//cli options
const config = JSON.parse(fs.readFileSync("./config.json"));
let config_defaults = {
port: 8000,
cache: 3600,
timeout: 5000,
behind_proxy: false,
salt: "replace_me",
};
for (let name in config_defaults) {
if (config[name] == undefined) {
config[name] = config_defaults[name];
}
}
///////////////////////////////////////////////////////////////////////////////
//stats for this instance
let stats = {
//games played counter
games_played_total: 0,
games_played_permode: {
CTF: 0,
TDM: 0,
TTH: 0
},
//
players_total: 0,
players_permode: {
CTF: 0,
TDM: 0,
TTH: 0
},
//moving average
average_wait: 0,
average_wait_samples: 0,
average_wait_samples_max: 50,
}
function stats_add_game(mode) {
stats.games_played_total++;
if(stats.games_played_permode[mode] != undefined) {
stats.games_played_permode[mode]++;
}
}
function stats_add_player(mode) {
stats.players_total++;
if(stats.players_permode[mode] != undefined) {
stats.players_permode[mode]++;
}
}
function add_wait_time(sample) {
stats.average_wait_samples = Math.min(stats.average_wait_samples_max, stats.average_wait_samples + 1);
const count = stats.average_wait_samples;
stats.average_wait = ((stats.average_wait * (count - 1)) + sample) / count;
}
function print_stats() {
console.log(stats);
}
function send_stats(ws) {
ws.send(JSON.stringify({
type: "stats",
stats: {
average_wait: stats.average_wait,
games_played_total: stats.games_played_total,
players_total: stats.players_total,
}
}))
}
///////////////////////////////////////////////////////////////////////////////
//player
class Server {
constructor(region, ip, port) {
this.region = region;
this.ip = ip;
this.port = port;
//updated from API
this.name = "";
this.players = 100;
this.mods = false;
this.valid = false;
this.link = `kag://${this.ip}:${this.port}`;
this.api_url = `https://api.kag2d.com/v1/game/thd/kag/server/${this.ip}/${this.port}/status`;
this.update();
}
update() {
http_request(this.api_url, (e, json, resp) => {
if(e) {
console.error("error reading api: ", e);
return;
}
let response = null;
try {
response = JSON.parse(json);
} catch (e) {
console.error("bad json from api: ", resp, json);
console.error("parse result: ", e);
return
}
this.valid = response.serverStatus.connectable;
this.name = response.serverStatus.name;
this.players = response.serverStatus.currentPlayers;
this.mods = response.serverStatus.usingMods;
//console.log("server update: ", this.link, this.valid, this.players, this.name)
})
}
}
///////////////////////////////////////////////////////////////////////////////
//player
//
// salted+hashed id is used instead of raw ip addr as a simple protection to the user.
class Player {
constructor(socket, ip_addr) {
this.connected = true;
this.socket = socket;
this.id = crypto.createHash('sha256').update(ip_addr).update("__player__").update(config.salt).digest('hex');
//TODO: fetch rank from db based on id here
this.rank = 0;
this._name = "";
this.region = null;
this.mode = null;
this.join_time = -1;
this.ready = false;
this.matchmaker = null;
}
get wait_time() {
return (this.join_time < 0) ? 0 : ((Date.now() - this.join_time) / 1000);
}
//transparent name formatting
//empty name gets replaced with anonymous
get name() {
return ((this._name == "") ? "(Anonymous)" : this._name);
}
set name(v) {
this._name = v;
}
//send data
send(data) {
if (!this.connected) return;
this.socket.send(JSON.stringify(data));
}
//receive data
receive(message) {
if (!this.connected) return;
let data = null;
try {
data = JSON.parse(message);
} catch(e) {
this.send(JSON.stringify({type: "error", reason: "json parse failed on receive"}));
this.disconnect();
return;
}
if (data.type == "update") {
//can change some fields but not others depending on ready state
let fields = ["name", "region", "mode"];
if (this.ready) {
fields = ["name"];
}
//actually change the fields if present in the packet
fields.forEach((v) => {
if (data.fields[v] != undefined) {
this[v] = data.fields[v];
}
});
} else if (data.type == "ready") {
this.ready = true;
this.join_time = Date.now();
}
//TODO: track name changes?
}
//socket broke
disconnect() {
this.socket.close();
this.connected = false;
this.ready = false;
}
}
///////////////////////////////////////////////////////////////////////////////
//actual game shedule handling logic for kag.party
//
class Matchmaker {
constructor(args) {
//fill out default args
if(!args || !args.name || !args.thresholds || !args.timers || !args.servers) {
throw "bad args to Matchmaker ctor"
}
this.name = args.name;
//player thresholds
this.thresholds = {};
this.thresholds.play_now = args.thresholds.play_now || 10;
this.thresholds.play_min = args.thresholds.play_min || 4;
//timers
this.timers = {};
this.timers.wait_max = args.timers.wait_max || (5 * 60);
this.timers.wait_min = args.timers.wait_min || (1 * 60);
//other members
this.players = [];
this.servers = args.servers;
this.dirty = false;
//keep servers up to date in the background; query 1 each interval
//TODO: probably stagger this to smooth bandwidth spiking
let _matchmaker = this;
this._server_update_i = 0;
this.server_update_interval = setInterval(function() {
let i = _matchmaker._server_update_i;
//poll api for server info
let s = _matchmaker.servers[i];
s.update();
//iterate and wrap
i = (i + 1) % _matchmaker.servers.length;
_matchmaker._server_update_i = i;
}, 5000);
this.server_update_interval.unref();
}
//map of list of players in each region
get_region_lists() {
let region_lists = {};
this.players.forEach((p) => {
let reg = p.region;
if (!region_lists[reg]) {
region_lists[reg] = [];
}
region_lists[reg].push(p);
})
return region_lists
}
add_player(player) {
player.ready = true;
player.matchmaker = this;
this.players.push(player);
this.dirty = true;
}
remove_player(player) {
let i = this.players.indexOf(player);
if (i != -1) {
player.ready = false;
player.matchmaker = null;
this.players.splice(i, 1);
this.dirty = true;
}
}
//message sending
send_all(message) {
this.players.forEach((p) => {
p.send(message);
})
}
//logic to sync info to all players
sync() {
this.send_all({
type: "queue sync",
players: this.players.map(p => {
return {
name: p.name,
region: p.region
};
})
});
}
//logic to start the game
start(players) {
//find the "most empty" servers
let acceptable_servers = this.servers.sort((a, b) => {
a = a.player_count
b = b.player_count
return (a < b) ? -1 : (a > b) ? 1 : 0;
}).filter((s, i, a) => {
return s.valid && s.player_count == a[0].player_count
});
//report failure to find a game
if (acceptable_servers.length == 0) {
console.error("couldn't find a suitable server for a game!");
return;
}
//pick random suitable server
let server = acceptable_servers[Math.floor(Math.random() * acceptable_servers.length)];
//send all players to the game
players.forEach((p) => {
//send them a game
p.send({
type: "start game",
name: server.name,
//TODO: wrap this link in a click tracking link per-player to detect bad actors + get click-through stats
link: server.link
});
//remove the players from the gamemode (they've been handled)
this.remove_player(p);
//disconnect them (they'll reconnect when they want to play again)
p.disconnect();
});
//TODO: stats here
}
//gather the best-match players for a player, (up to this.thresholds.play_now)
gather_players_for(player) {
const limit = this.thresholds.play_now
//initially filter for N players in same region
let gather_players = this.players.filter((p) => {
return p.region == player.region
}).slice(0, limit)
//linear search through remaining players (in wait order)
let i = 0;
while (gather_players.length < limit && i < this.players.length) {
let p = this.players[i++];
let added = (gather_players.indexOf(p) != -1);
if (!added) {
gather_players.push(p);
}
}
return gather_players;
}
//schedule games in the gamemode
tick() {
//first up, remove any dc'd players
this.players.filter((p) => {
return (!p.connected || !p.ready)
}).forEach((p) => {
console.log("removing disconnected player ", p.name);
this.remove_player(p);
})
if (this.players.length == 0) {
//nothing more to do
return;
}
//update state if it's changed
if(this.dirty) {
this.dirty = false;
this.sync();
}
if(this.players.length >= this.thresholds.play_min) {
//players are sorted on wait time because of FIFO add
let max_wait_time = this.players[0].wait_time;
//wait at least this long
if (max_wait_time >= this.timers.wait_min)
{
if(
//got enough players to get a game going
this.players.length >= this.thresholds.play_now ||
//waited too long
max_wait_time >= this.timers.wait_max
) {
//gather longest-waiting players and send em off
let players = this.gather_players_for(this.players[0])
this.start(players);
console.log("starting game for ", players.map((p) => {return p.name}))
}
}
}
}
}
/*
//server definitions
//could probably be moved to a config file somewhere tbh
//CTF
new Server("EU", "138.201.55.232", 10592),
new Server("EU", "138.201.55.232", 10594),
new Server("EU", "138.201.55.232", 10596),
new Server("US", "162.221.187.210", 10610),
new Server("US", "162.221.187.210", 10617),
new Server("AU", "108.61.212.78", 10649 ),
//TDM
new Server("EU", "138.201.55.232", 10600),
new Server("EU", "138.201.55.232", 10762),
new Server("US", "162.221.187.210", 10611),
new Server("US", "162.221.187.210", 10761),
new Server("US", "162.221.187.210", 10615),
new Server("AU", "108.61.212.78", 10651 ),
new Server("AU", "108.61.212.78", 10763 ),
}
//TTH
new Server("EU", "138.201.55.232", 10595),
new Server("US", "162.221.187.210", 10612),
new Server("US", "162.221.187.210", 10616),
new Server("AU", "108.61.212.78", 10650 ),
*/
//actual
let gamemodes = [
new Matchmaker ({
name: "TDM",
thresholds: {
play_now: 6,
play_min: 2,
},
timers: {
wait_max: 30,//(2 * 60),
wait_min: 10,
},
servers: [
new Server("EU", "138.201.55.232", 10600),
new Server("EU", "138.201.55.232", 10762),
new Server("US", "162.221.187.210", 10611),
new Server("US", "162.221.187.210", 10761),
new Server("US", "162.221.187.210", 10615),
new Server("AU", "108.61.212.78", 10651 ),
new Server("AU", "108.61.212.78", 10763 )
]
}),
new Matchmaker ({
name: "CTF",
thresholds: {
play_now: 10,
play_min: 4,
},
timers: {
wait_max: 30,//(2 * 60),
wait_min: 10,
},
servers: [
new Server("EU", "138.201.55.232", 10592),
new Server("EU", "138.201.55.232", 10594),
new Server("EU", "138.201.55.232", 10596),
new Server("US", "162.221.187.210", 10610),
new Server("US", "162.221.187.210", 10617),
new Server("AU", "108.61.212.78", 10649 )
]
}),
new Matchmaker ({
name: "TTH",
thresholds: {
play_now: 10,
play_min: 4,
},
timers: {
wait_max: 30,//(2 * 60),
wait_min: 10,
},
servers: [
new Server("EU", "138.201.55.232", 10595),
new Server("US", "162.221.187.210", 10612),
new Server("US", "162.221.187.210", 10616),
new Server("AU", "108.61.212.78", 10650 )
]
})
]
//update at 10hz
const update_interval = setInterval(function () {
gamemodes.forEach((m) => {
m.tick();
});
}, 100);
update_interval.unref();
//the websocket server (run behind our http server)
const wss = new WebSocket.Server({ noServer: true });
//(collection of all players)
let all_players = [];
//ws connection handling
wss.on('connection', function connection(ws, req) {
//new ws connection
//get the address
let socket = req.connection;
let ip = socket.remoteAddress;
if (config.behind_proxy) {
ip = req.headers['x-forwarded-for'].split(/\s*,\s*/)[0];
}
ip = "" + ip;
console.log("player connection from", ip);
//create the player
let player = new Player(ws, ip);
//add to players collection
all_players.push(player);
//send them info on what gamemodes/regions are available
ws.send(JSON.stringify({
type: "available",
entries: gamemodes.map((g) => {
return {
name: g.name,
regions: g.servers.reduce((a, s) => {
if (a.indexOf(s.region) == -1) {
a.push(s.region);
}
return a;
}, [])
};
})
}));
//
ws.on('message', function ws_message(data) {
console.log(data);
let old_ready = player.ready;
player.receive(data);
//put into right gamemode on
if (player.ready && !old_ready) {
console.log("player", player.name, "ready");
for (let i = 0; i < gamemodes.length; i++) {
let mode = gamemodes[i]
if (mode.name == player.mode) {
mode.add_player(player);
console.log("player added to", mode.name);
console.log("current players ", mode.players.length);
break;
}
}
}
});
ws.on('close', function ws_message(data) {
//remove from gamemode if it exists
if (player.matchmaker) {
player.matchmaker.remove_player(player);
}
//remove player from players collection
let i = all_players.indexOf(player);
if (i != -1) {
all_players.splice(i, 1);
}
//mark disconnected
player.connected = false;
console.log("player", player.name, "disconnected");
});
//(required for timeout handling to work)
ws.is_alive = true;
ws.on('pong', function heartbeat() {
this.is_alive = true;
});
});
//timeout inactive sockets (doesn't keep the event loop alive)
const timeout_interval = setInterval(function ping() {
wss.clients.forEach((ws) => {
if (!ws.is_alive) {
return ws.terminate();
}
ws.is_alive = false;
ws.ping(() => {});
});
}, config.timeout);
timeout_interval.unref(); //(don't stay alive just for this)
///////////////////////////////////////////////////////////////////////////////
//static file server from public/
const static_serve = new node_static.Server("./public", {
cache: config.cache,
})
//actual http server
const server = http.createServer((req, res) => {
const pathname = url.parse(req.url).pathname;
if (pathname == "/ws") {
//(websocket connection incoming, handled by upgrade below)
} else {
//otherwise, let static file server handle it
req.addListener('end', () => {
static_serve.serve(req, res, (err, result) => {
if (err) {
//file not found or similar
res.writeHead(err.status, err.headers);
res.end();
} else {
//success
}
});
}).resume();
}
});
//websocket upgrade
server.on('upgrade', function upgrade(request, socket, head) {
const pathname = url.parse(request.url).pathname;
if (pathname == '/ws') {
//(hand off to wss, emit the connection event)
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} else {
//bad upgrade
socket.destroy();
}
});
//client protocol screwup
server.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
//go!
server.listen(config.port);