New variant idea
[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);
0e466aac
BA
58 if (params.dev == true) {
59 const chokidar = require("chokidar");
60 const watcher = chokidar.watch(
61 ["*.js", "*.css", "utils/", "variants/"],
62 {persistent: true});
63 watcher.on("change", path => send(sid, "filechange", {path: path}));
64 }
f46a68b8 65 socket.on("message", (msg) => {
41534b92
BA
66 const obj = JSON.parse(msg);
67 switch (obj.code) {
68 // Send challenge (may trigger game creation)
69 case "seekgame": {
57b8015b
BA
70 let oppIndex = undefined, //variant name
71 choice = undefined; //variant finally played
72 const vname = obj.vname, //variant requested
41534b92
BA
73 randvar = (obj.vname == "_random");
74 if (vname == "_random") {
f46a68b8 75 // Pick any current challenge if possible
41534b92
BA
76 const currentChalls = Object.keys(challenges);
77 if (currentChalls.length >= 1) {
78 choice =
79 currentChalls[Math.floor(Math.random() * currentChalls.length)];
57b8015b 80 oppIndex = choice;
41534b92
BA
81 }
82 }
83 else if (challenges[vname]) {
57b8015b 84 // Anyone wanting to play the same variant ?
41534b92 85 choice = vname;
57b8015b 86 oppIndex = vname;
41534b92 87 }
57b8015b
BA
88 else if (challenges["_random"]) {
89 // Anyone accepting any variant (including vname) ?
90 choice = vname;
91 oppIndex = "_random";
92 }
93 if (oppIndex) {
b4ae3ff6
BA
94 if (choice == "_random")
95 choice = getRandomVariant();
41534b92
BA
96 // Launch game
97 let players = [
98 {sid: sid, name: obj.name, randvar: randvar},
57b8015b 99 Object.assign({}, challenges[oppIndex])
41534b92 100 ];
57b8015b 101 delete challenges[oppIndex];
b4ae3ff6
BA
102 if (Math.random() < 0.5)
103 players = players.reverse();
104 // Empty options = default
105 launchGame( initializeGame(choice, players, {}) );
41534b92
BA
106 }
107 else
108 // Place challenge and wait. 'randvar' indicate if we play anything
109 challenges[vname] = {sid: sid, name: obj.name, randvar: randvar};
110 break;
111 }
f46a68b8 112 // Set FEN after game was created (received twice)
41534b92
BA
113 case "setfen":
114 games[obj.gid].fen = obj.fen;
115 break;
116 // Send back game informations
b4ae3ff6
BA
117 case "getgame":
118 if (!games[obj.gid])
119 send(sid, "nogame");
120 else
121 send(sid, "gameinfo", games[obj.gid]);
41534b92 122 break;
41534b92
BA
123 // Cancel challenge
124 case "cancelseek":
125 delete challenges[obj.vname];
126 break;
127 // Receive rematch
8a9f61ce 128 case "rematch":
b4ae3ff6
BA
129 if (!games[obj.gid])
130 send(sid, "closerematch");
41534b92
BA
131 else {
132 const myIndex = (games[obj.gid].players[0].sid == sid ? 0 : 1);
b4ae3ff6
BA
133 if (!games[obj.gid].rematch)
134 games[obj.gid].rematch = [0, 0];
32f57b42 135 games[obj.gid].rematch[myIndex] = !obj.random ? 1 : 2;
41534b92
BA
136 if (games[obj.gid].rematch[1-myIndex]) {
137 // Launch new game, colors reversed
32f57b42 138 let vname = games[obj.gid].vname;
cae17481 139 const allrand = games[obj.gid].rematch.every(r => r == 2);
b4ae3ff6
BA
140 if (allrand)
141 vname = getRandomVariant();
554e3ad3 142 games[obj.gid].players.forEach(p => p.randvar = allrand);
b4ae3ff6 143 const gid = initializeGame(vname,
554e3ad3
BA
144 games[obj.gid].players.reverse(),
145 games[obj.gid].options);
b4ae3ff6 146 launchGame(gid);
41534b92
BA
147 }
148 }
149 break;
8a9f61ce
BA
150 // Rematch cancellation
151 case "norematch":
41534b92
BA
152 if (games[obj.gid]) {
153 const myIndex = (games[obj.gid].players[0].sid == sid ? 0 : 1);
154 send(games[obj.gid].players[1-myIndex].sid, "closerematch");
155 }
156 break;
41534b92 157 // Create game vs. friend
8a9f61ce 158 case "creategame": {
41534b92 159 let players = [
f46a68b8 160 {sid: obj.player.sid, name: obj.player.name},
41534b92
BA
161 undefined
162 ];
163 if (
164 obj.player.color == 'b' ||
165 (obj.player.color == '' && Math.random() < 0.5)
166 ) {
167 players = players.reverse();
168 }
b4ae3ff6
BA
169 // Incomplete players array: do not start game yet
170 const gid = initializeGame(obj.vname, players, obj.options);
171 send(sid, "gamecreated", {gid: gid});
172 // If nobody joins within 3 minutes, delete game
173 setTimeout(
174 () => {
175 if (games[gid] && games[gid].players.some(p => !p))
176 delete games[gid];
177 },
178 3 * 60000
179 );
41534b92 180 break;
8a9f61ce 181 }
41534b92 182 // Join game vs. friend
8a9f61ce 183 case "joingame":
b4ae3ff6
BA
184 if (!games[obj.gid])
185 send(sid, "jointoolate");
41534b92 186 else {
41534b92 187 const emptySlot = games[obj.gid].players.findIndex(p => !p);
b4ae3ff6
BA
188 if (emptySlot < 0)
189 send(sid, "jointoolate");
190 else {
191 // Join a game (started by some other player)
192 games[obj.gid].players[emptySlot] = {sid: sid, name: obj.name};
193 launchGame(obj.gid);
194 }
41534b92
BA
195 }
196 break;
41534b92 197 // Relay a move + update games object
8a9f61ce 198 case "newmove":
c4e9bb92
BA
199 // NOTE: still potential racing issues, but... fingers crossed
200 const hash = Crypto.createHash("md5")
201 .update(JSON.stringify(obj.fen))
202 .digest("hex");
4cec374b 203 if (games[obj.gid].moveHash[hash])
c4e9bb92 204 break;
4cec374b 205 games[obj.gid].moveHash[hash] = true;
41534b92 206 games[obj.gid].fen = obj.fen;
4cec374b 207 games[obj.gid].time = Date.now(); //update useful if verrry slow game
41534b92
BA
208 const playingWhite = (games[obj.gid].players[0].sid == sid);
209 const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid;
f46a68b8 210 send(oppSid, "newmove", {moves: obj.moves});
41534b92 211 break;
41534b92 212 // Relay "game ends" message
f46a68b8
BA
213 case "gameover":
214 if (obj.relay) {
215 const playingWhite = (games[obj.gid].players[0].sid == sid);
216 const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid;
217 send(oppSid, "gameover", { gid: obj.gid });
218 }
219 // 2 minutes timeout for rematch:
220 setTimeout(() => delete games[obj.gid], 2 * 60000);
41534b92 221 break;
41534b92
BA
222 }
223 });
224 socket.on("close", () => {
225 delete sockets[sid];
226 for (const [key, value] of Object.entries(challenges)) {
227 if (value.sid == sid) {
228 delete challenges[key];
229 break; //only one challenge per player
230 }
231 }
3c61449b 232 for (let g of Object.values(games)) {
b4ae3ff6 233 const myIndex = g.players.findIndex(p => p && p.sid == sid);
3c61449b
BA
234 if (myIndex >= 0) {
235 if (g.rematch && g.rematch[myIndex] > 0) g.rematch[myIndex] = 0;
236 break; //only one game per player
237 }
238 }
41534b92
BA
239 });
240});
241
f46a68b8 242const heartbeat = setInterval(() => {
41534b92 243 wss.clients.forEach((ws) => {
b4ae3ff6
BA
244 if (ws.isAlive === false)
245 return ws.terminate();
41534b92
BA
246 ws.isAlive = false;
247 ws.ping();
248 });
249}, 30000);
f46a68b8
BA
250
251// Every 24 hours, scan games and remove if last move older than 24h
252const dayInMillisecs = 24 * 60 * 60 * 1000;
253const killOldGames = setInterval(() => {
254 const now = Date.now();
255 Object.keys(games).forEach(gid => {
b4ae3ff6
BA
256 if (now - games[gid].time >= dayInMillisecs)
257 delete games[gid];
f46a68b8
BA
258 });
259}, dayInMillisecs);
260
261// TODO: useful code here?
262wss.on("close", () => {
263 clearInterval(heartbeat);
264 clearInterval(killOldGames);
265});