Fix memory leak of moves hash on server side. Draft Align4
[xogo.git] / server.js
CommitLineData
41534b92 1const params = require("./parameters.js");
f46a68b8 2const WebSocket = require("ws");
b4ae3ff6
BA
3const wss = new WebSocket.Server({
4 port: params.socket_port,
5 path: params.socket_path
6});
41534b92
BA
7
8let challenges = {}; //variantName --> socketId, name
f46a68b8 9let games = {}; //gameId --> gameInfo (vname, fen, players, options, time)
41534b92
BA
10let sockets = {}; //socketId --> socket
11const variants = require("./variants.js");
f46a68b8
BA
12const Crypto = require("crypto");
13const randstrSize = 8;
8a9f61ce 14
524e87ed 15function send(sid, code, data) {
41534b92 16 const socket = sockets[sid];
f46a68b8 17 // If a player deletes local infos and then tries to resume a game,
41534b92 18 // sockets[oppSid] will probably not exist anymore:
b4ae3ff6 19 if (socket)
3a77a0b4 20 socket.send(JSON.stringify(Object.assign({code: code}, data)));
524e87ed 21}
41534b92 22
b4ae3ff6 23function initializeGame(vname, players, options) {
32f57b42
BA
24 const gid =
25 Crypto.randomBytes(randstrSize).toString("hex").slice(0, randstrSize);
26 games[gid] = {
27 vname: vname,
cae17481 28 players: players,
32f57b42 29 options: options,
4cec374b
BA
30 time: Date.now(),
31 moveHash: {} //set of moves hashes seen so far
32f57b42 32 };
b4ae3ff6
BA
33 return gid;
34}
35
36// Provide seed in case of, so that both players initialize with same FEN
37function launchGame(gid) {
38 const gameInfo = Object.assign(
554e3ad3 39 {seed: Math.floor(Math.random() * 19840), gid: gid},
b4ae3ff6
BA
40 games[gid]
41 );
42 // players array is supposed to be full:
554e3ad3
BA
43 for (const p of games[gid].players)
44 send(p.sid, "gamestart", gameInfo);
32f57b42
BA
45}
46
47function getRandomVariant() {
48 // Pick a variant at random in the list
49 const index = Math.floor(Math.random() * variants.length);
50 return variants[index].name;
51}
52
524e87ed 53wss.on("connection", (socket, req) => {
41534b92
BA
54 const sid = req.url.split("=")[1]; //...?sid=...
55 sockets[sid] = socket;
56 socket.isAlive = true;
f46a68b8 57 socket.on("pong", () => socket.isAlive = true);
41534b92 58
f46a68b8 59 socket.on("message", (msg) => {
41534b92
BA
60 const obj = JSON.parse(msg);
61 switch (obj.code) {
62 // Send challenge (may trigger game creation)
63 case "seekgame": {
57b8015b
BA
64 let oppIndex = undefined, //variant name
65 choice = undefined; //variant finally played
66 const vname = obj.vname, //variant requested
41534b92
BA
67 randvar = (obj.vname == "_random");
68 if (vname == "_random") {
f46a68b8 69 // Pick any current challenge if possible
41534b92
BA
70 const currentChalls = Object.keys(challenges);
71 if (currentChalls.length >= 1) {
72 choice =
73 currentChalls[Math.floor(Math.random() * currentChalls.length)];
57b8015b 74 oppIndex = choice;
41534b92
BA
75 }
76 }
77 else if (challenges[vname]) {
57b8015b 78 // Anyone wanting to play the same variant ?
41534b92 79 choice = vname;
57b8015b 80 oppIndex = vname;
41534b92 81 }
57b8015b
BA
82 else if (challenges["_random"]) {
83 // Anyone accepting any variant (including vname) ?
84 choice = vname;
85 oppIndex = "_random";
86 }
87 if (oppIndex) {
b4ae3ff6
BA
88 if (choice == "_random")
89 choice = getRandomVariant();
41534b92
BA
90 // Launch game
91 let players = [
92 {sid: sid, name: obj.name, randvar: randvar},
57b8015b 93 Object.assign({}, challenges[oppIndex])
41534b92 94 ];
57b8015b 95 delete challenges[oppIndex];
b4ae3ff6
BA
96 if (Math.random() < 0.5)
97 players = players.reverse();
98 // Empty options = default
99 launchGame( initializeGame(choice, players, {}) );
41534b92
BA
100 }
101 else
102 // Place challenge and wait. 'randvar' indicate if we play anything
103 challenges[vname] = {sid: sid, name: obj.name, randvar: randvar};
104 break;
105 }
f46a68b8 106 // Set FEN after game was created (received twice)
41534b92
BA
107 case "setfen":
108 games[obj.gid].fen = obj.fen;
109 break;
110 // Send back game informations
b4ae3ff6
BA
111 case "getgame":
112 if (!games[obj.gid])
113 send(sid, "nogame");
114 else
115 send(sid, "gameinfo", games[obj.gid]);
41534b92 116 break;
41534b92
BA
117 // Cancel challenge
118 case "cancelseek":
119 delete challenges[obj.vname];
120 break;
121 // Receive rematch
8a9f61ce 122 case "rematch":
b4ae3ff6
BA
123 if (!games[obj.gid])
124 send(sid, "closerematch");
41534b92
BA
125 else {
126 const myIndex = (games[obj.gid].players[0].sid == sid ? 0 : 1);
b4ae3ff6
BA
127 if (!games[obj.gid].rematch)
128 games[obj.gid].rematch = [0, 0];
32f57b42 129 games[obj.gid].rematch[myIndex] = !obj.random ? 1 : 2;
41534b92
BA
130 if (games[obj.gid].rematch[1-myIndex]) {
131 // Launch new game, colors reversed
32f57b42 132 let vname = games[obj.gid].vname;
cae17481 133 const allrand = games[obj.gid].rematch.every(r => r == 2);
b4ae3ff6
BA
134 if (allrand)
135 vname = getRandomVariant();
554e3ad3 136 games[obj.gid].players.forEach(p => p.randvar = allrand);
b4ae3ff6 137 const gid = initializeGame(vname,
554e3ad3
BA
138 games[obj.gid].players.reverse(),
139 games[obj.gid].options);
b4ae3ff6 140 launchGame(gid);
41534b92
BA
141 }
142 }
143 break;
8a9f61ce
BA
144 // Rematch cancellation
145 case "norematch":
41534b92
BA
146 if (games[obj.gid]) {
147 const myIndex = (games[obj.gid].players[0].sid == sid ? 0 : 1);
148 send(games[obj.gid].players[1-myIndex].sid, "closerematch");
149 }
150 break;
41534b92 151 // Create game vs. friend
8a9f61ce 152 case "creategame": {
41534b92 153 let players = [
f46a68b8 154 {sid: obj.player.sid, name: obj.player.name},
41534b92
BA
155 undefined
156 ];
157 if (
158 obj.player.color == 'b' ||
159 (obj.player.color == '' && Math.random() < 0.5)
160 ) {
161 players = players.reverse();
162 }
b4ae3ff6
BA
163 // Incomplete players array: do not start game yet
164 const gid = initializeGame(obj.vname, players, obj.options);
165 send(sid, "gamecreated", {gid: gid});
166 // If nobody joins within 3 minutes, delete game
167 setTimeout(
168 () => {
169 if (games[gid] && games[gid].players.some(p => !p))
170 delete games[gid];
171 },
172 3 * 60000
173 );
41534b92 174 break;
8a9f61ce 175 }
41534b92 176 // Join game vs. friend
8a9f61ce 177 case "joingame":
b4ae3ff6
BA
178 if (!games[obj.gid])
179 send(sid, "jointoolate");
41534b92 180 else {
41534b92 181 const emptySlot = games[obj.gid].players.findIndex(p => !p);
b4ae3ff6
BA
182 if (emptySlot < 0)
183 send(sid, "jointoolate");
184 else {
185 // Join a game (started by some other player)
186 games[obj.gid].players[emptySlot] = {sid: sid, name: obj.name};
187 launchGame(obj.gid);
188 }
41534b92
BA
189 }
190 break;
41534b92 191 // Relay a move + update games object
8a9f61ce 192 case "newmove":
c4e9bb92
BA
193 // NOTE: still potential racing issues, but... fingers crossed
194 const hash = Crypto.createHash("md5")
195 .update(JSON.stringify(obj.fen))
196 .digest("hex");
4cec374b 197 if (games[obj.gid].moveHash[hash])
c4e9bb92 198 break;
4cec374b 199 games[obj.gid].moveHash[hash] = true;
41534b92 200 games[obj.gid].fen = obj.fen;
4cec374b 201 games[obj.gid].time = Date.now(); //update useful if verrry slow game
41534b92
BA
202 const playingWhite = (games[obj.gid].players[0].sid == sid);
203 const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid;
f46a68b8 204 send(oppSid, "newmove", {moves: obj.moves});
41534b92 205 break;
41534b92 206 // Relay "game ends" message
f46a68b8
BA
207 case "gameover":
208 if (obj.relay) {
209 const playingWhite = (games[obj.gid].players[0].sid == sid);
210 const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid;
211 send(oppSid, "gameover", { gid: obj.gid });
212 }
213 // 2 minutes timeout for rematch:
214 setTimeout(() => delete games[obj.gid], 2 * 60000);
41534b92 215 break;
41534b92
BA
216 }
217 });
218 socket.on("close", () => {
219 delete sockets[sid];
220 for (const [key, value] of Object.entries(challenges)) {
221 if (value.sid == sid) {
222 delete challenges[key];
223 break; //only one challenge per player
224 }
225 }
3c61449b 226 for (let g of Object.values(games)) {
b4ae3ff6 227 const myIndex = g.players.findIndex(p => p && p.sid == sid);
3c61449b
BA
228 if (myIndex >= 0) {
229 if (g.rematch && g.rematch[myIndex] > 0) g.rematch[myIndex] = 0;
230 break; //only one game per player
231 }
232 }
41534b92
BA
233 });
234});
235
f46a68b8 236const heartbeat = setInterval(() => {
41534b92 237 wss.clients.forEach((ws) => {
b4ae3ff6
BA
238 if (ws.isAlive === false)
239 return ws.terminate();
41534b92
BA
240 ws.isAlive = false;
241 ws.ping();
242 });
243}, 30000);
f46a68b8
BA
244
245// Every 24 hours, scan games and remove if last move older than 24h
246const dayInMillisecs = 24 * 60 * 60 * 1000;
247const killOldGames = setInterval(() => {
248 const now = Date.now();
249 Object.keys(games).forEach(gid => {
b4ae3ff6
BA
250 if (now - games[gid].time >= dayInMillisecs)
251 delete games[gid];
f46a68b8
BA
252 });
253}, dayInMillisecs);
254
255// TODO: useful code here?
256wss.on("close", () => {
257 clearInterval(heartbeat);
258 clearInterval(killOldGames);
259});