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