trivabble/server/trivabble-server.js

1257 lines
38 KiB
JavaScript
Raw Normal View History

2020-04-04 16:27:18 +02:00
/* eslint-disable no-process-env */
2016-02-28 20:23:41 +01:00
/**
2020-04-02 19:07:03 +02:00
* Copyright (C) 2016-2020 Raphaël Jakse <raphael.trivabble@jakse.fr>
2016-02-28 20:23:41 +01:00
*
* @licstart
* This file is part of Trivabble.
*
* Trivabble is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License (GNU AGPL)
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* Trivabble is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Trivabble. If not, see <http://www.gnu.org/licenses/>.
* @licend
*
* @source: https://trivabble.1s.fr/
* @source: https://gitlab.com/raphj/trivabble/
*/
2020-04-12 20:14:16 +02:00
/*eslint strict: [2, "global"]*/
2020-04-04 16:27:18 +02:00
"use strict";
const port = parseInt(process.env.TRIVABBLE_PORT || "3000");
2020-05-23 22:13:18 +02:00
const host = process.env.TRIVABBLE_HOST || "localhost";
2020-10-05 23:47:06 +02:00
const disableCSP = process.env.DISABLE_CSP || false;
2020-05-06 19:47:10 +02:00
2016-02-28 20:23:41 +01:00
const SAVE_TIMEOUT = 5000;
2020-04-04 16:19:57 +02:00
const KEEP_ALIVE = 30000;
const GAMES_BACKUP = process.env.TRIVABBLE_GAMES_BACKUP || "games.backup.json";
2020-05-16 10:02:18 +02:00
const DEFAULT_BOARD_LANG = process.env.TRIVABBLE_DEFAULT_BOARD_LANG || "fr";
const DEFAULT_BOARD_LABEL = process.env.TRIVABBLE_DEFAULT_BOARD_LABEL || "15x15-7";
2021-04-26 08:44:59 +02:00
const DEFAULT_BAG_FACTOR = process.env.TRIVABBLE_DEFAULT_BAG_FACTOR || 1;
2016-02-28 20:23:41 +01:00
2020-05-07 01:08:59 +02:00
const VERSION = 202005070100;
2020-04-28 19:27:37 +02:00
2020-04-04 16:57:44 +02:00
function envTrue(name) {
return (process.env[name] || "").toLowerCase() === "true";
}
2020-04-05 12:34:30 +02:00
const DEV_ENABLE_SERVING_FILES = envTrue("DEV_ENABLE_SERVING_FILES");
const DEBUG_LOG = DEV_ENABLE_SERVING_FILES || envTrue("DEBUG_LOG");
2020-04-04 16:27:18 +02:00
if (DEV_ENABLE_SERVING_FILES) {
console.log("DEV_ENABLE_SERVING_FILES: Serving files in the current directory. Please never do this on a production server, this is for development purposes only.");
}
2020-04-05 15:49:48 +02:00
const debuglog = DEBUG_LOG ? console.log.bind(console) : () => null;
2020-04-04 16:57:44 +02:00
2020-05-16 10:02:18 +02:00
const http = require("http");
const path = require("path");
const fs = require("fs");
2020-04-12 20:14:39 +02:00
const crypto = require("crypto");
const REQUEST_TYPE_LONG_POLLING = 1;
const REQUEST_TYPE_SSE = 2;
const REQUEST_TYPE_WEBSOCKET = 3;
2016-02-28 20:23:41 +01:00
2020-05-07 00:53:37 +02:00
/* eslint no-sync: ["error", { allowAtRootLevel: true }] */
2020-04-28 19:29:36 +02:00
2020-05-04 00:28:33 +02:00
/* Manage multi language board */
2020-05-16 10:02:18 +02:00
const boardTilesPerLang = {};
const availableBoardLangs = {};
2016-02-28 20:23:41 +01:00
2020-05-16 10:02:18 +02:00
for (const lang of fs.readdirSync(path.join(__dirname, "lang"))) {
const data = require(path.join(__dirname, "lang", lang)); // eslint-disable-line global-require
boardTilesPerLang[data.code] = data;
availableBoardLangs[data.code] = data.name;
2020-05-06 23:28:09 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
const games = {};
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
let saveTimeout = null;
let running = true;
2020-04-05 21:05:48 +02:00
const server = http.createServer(handleRequest);
2020-04-12 20:14:22 +02:00
server.setTimeout(0); // The default in node 13 is 0. Earlier versions have 120.
2016-02-28 20:23:41 +01:00
function saveGames(callback) {
fs.writeFile(GAMES_BACKUP, JSON.stringify(games), function (err) {
2016-02-28 20:23:41 +01:00
if (err) {
2020-04-04 16:27:18 +02:00
console.error("ERROR: Cannot save games!");
2020-04-05 12:51:47 +02:00
}
if (typeof callback === "function") {
return callback(err);
2016-02-28 20:23:41 +01:00
}
});
2020-04-05 12:34:30 +02:00
saveTimeout = null;
2016-02-28 20:23:41 +01:00
}
function shuffleInPlace(a) {
// https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const x = a[i];
a[i] = a[j];
a[j] = x;
}
}
2016-02-28 20:23:41 +01:00
function Game() {
this.init();
this.listeningPlayers = [];
this.pendingEvents = [];
}
2020-04-12 20:14:39 +02:00
function writeMessage(responseAndType, data, terminate) {
2020-04-13 21:18:40 +02:00
if (!responseAndType[0]) {
return;
}
2020-04-12 20:14:39 +02:00
if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET) {
webSocketWrite(responseAndType[0], data);
} else {
responseAndType[0][terminate ? "end" : "write"](
responseAndType[1] === REQUEST_TYPE_SSE
? "data:" + data + "\n\n"
: data.length + data
);
}
2020-04-04 16:19:57 +02:00
2020-04-05 12:51:47 +02:00
if (terminate) {
2020-04-12 20:14:39 +02:00
stopKeepAlive(responseAndType);
if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET && responseAndType[0] && !responseAndType[0].isDestroyed) {
responseAndType[0].end(webSocketCloseBuffer);
responseAndType[0] = null;
}
2020-04-05 12:51:47 +02:00
} else {
2020-04-12 20:14:39 +02:00
keepAlive(responseAndType);
2020-04-04 16:19:57 +02:00
}
}
2020-04-05 12:51:47 +02:00
function stop() {
console.log("Saving games a first time");
if (saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = null;
}
2020-04-05 12:51:47 +02:00
saveGames(function () {
running = false;
console.log("Closing connections and saving the game state...");
2020-04-05 12:51:47 +02:00
let listeningPlayerCount = 0;
let gamesCount = 0;
for (const gameID of Object.keys(games)) {
const game = games[gameID];
for (const listeningPlayer of game.listeningPlayers) {
2020-04-12 20:14:39 +02:00
writeMessage(listeningPlayer, '{"stopping": 2000}', true);
}
if (game.listeningPlayers.length) {
listeningPlayerCount += game.listeningPlayers.length;
gamesCount++;
}
}
2020-04-05 12:51:47 +02:00
2020-04-13 21:18:40 +02:00
console.log(
"Stopped", gamesCount, "game" + (gamesCount === 1 ? "" : "s"),
"and", listeningPlayerCount, "player connection" + (listeningPlayerCount === 1 ? "" : "s") +
"."
);
server.close(saveGames);
});
2020-04-05 12:51:47 +02:00
}
2020-04-12 20:14:39 +02:00
const webSocketPingBuffer = Buffer.from([
0b10001001, 0b00000000
]);
const webSocketCloseBuffer = Buffer.from([
0b10001000, 0b00000000
]);
function stopKeepAlive(responseAndType) {
if (responseAndType.keepAliveTimeout) {
clearTimeout(responseAndType.keepAliveTimeout);
responseAndType.keepAliveTimeout = 0;
2020-04-04 16:19:57 +02:00
}
}
2020-04-12 20:14:39 +02:00
function keepAlive(responseAndType) {
stopKeepAlive(responseAndType);
responseAndType.keepAliveTimeout = setTimeout(function keepAliveTimeout() {
if (responseAndType[0]) {
responseAndType[0].write(
responseAndType[1] === REQUEST_TYPE_WEBSOCKET
? webSocketPingBuffer
: (
responseAndType[1] === REQUEST_TYPE_SSE
? ":\n\n"
: "2[]"
)
);
}
2020-04-04 16:19:57 +02:00
}, KEEP_ALIVE);
2016-02-28 20:23:41 +01:00
}
function getBoardDimensions(boardLabel) {
return boardLabel
.match(/[0-9]*/gu)
.filter(function (x) {return (x.length !== 0);})
.map(function (x) {return parseInt(x);});
}
function newBoard(label) {
const [nbRows, nbColumns] = getBoardDimensions(label);
const res = new Array(nbRows * nbColumns);
2016-02-28 20:23:41 +01:00
for (let i = 0; i < nbRows * nbColumns; i++) {
2016-02-28 20:23:41 +01:00
res[i] = "";
}
return res;
}
2021-04-26 08:44:59 +02:00
Game.prototype.init = function (lang, label, factor) {
this.label = label || DEFAULT_BOARD_LABEL;
2021-04-26 08:44:59 +02:00
this.factor = factor || DEFAULT_BAG_FACTOR;
this.board = newBoard(this.label);
2020-05-16 10:02:18 +02:00
this.lang = lang || DEFAULT_BOARD_LANG;
2021-04-26 08:44:59 +02:00
const bag = boardTilesPerLang[this.lang].bag.slice();
2020-05-16 10:02:18 +02:00
this.letterValues = boardTilesPerLang[this.lang].letterValues;
2016-02-28 20:23:41 +01:00
this.racks = {};
this.scores = {};
2020-04-04 16:51:45 +02:00
this.lastUpdated = new Date();
2020-04-19 10:46:53 +02:00
this.currentPlayer = "";
2021-04-26 08:44:59 +02:00
factor = this.factor;
this.bag = [];
while (factor > 1) {
this.bag = this.bag.concat(bag.slice());
factor--;
}
if (Math.round(bag.length * factor) > 0) {
shuffleInPlace(bag);
this.bag = this.bag.concat(bag.slice(0, Math.round(bag.length * factor)));
}
shuffleInPlace(this.bag);
2020-04-04 16:27:18 +02:00
};
2016-02-28 20:23:41 +01:00
Game.prototype.toJSON = function () {
return {
board: this.board,
2020-05-04 23:27:36 +02:00
lang: this.lang,
label: this.label,
2021-04-26 08:44:59 +02:00
factor: this.factor,
2016-02-28 20:23:41 +01:00
bag: this.bag,
2020-05-12 07:50:29 +02:00
letterValues: this.letterValues,
2016-02-28 20:23:41 +01:00
racks: this.racks,
2020-04-04 16:51:45 +02:00
scores: this.scores,
2020-04-19 10:46:53 +02:00
lastUpdated: this.lastUpdated.toISOString(),
currentPlayer: this.currentPlayer
2016-02-28 20:23:41 +01:00
};
};
Game.fromJSON = function (obj) {
2020-04-05 15:49:48 +02:00
const game = new Game();
game.label = obj.label || DEFAULT_BOARD_LABEL;
2021-04-26 08:44:59 +02:00
game.factor = obj.factor || DEFAULT_BAG_FACTOR;
game.board = obj.board || newBoard(game.label);
2020-05-16 10:02:18 +02:00
game.lang = obj.lang || DEFAULT_BOARD_LANG;
game.bag = boardTilesPerLang[game.lang].bag.slice();
game.letterValues = boardTilesPerLang[game.lang].letterValues;
2016-02-28 20:23:41 +01:00
game.racks = obj.racks || {};
2020-04-04 16:27:18 +02:00
game.scores = obj.scores || {};
2020-04-04 16:51:45 +02:00
game.lastUpdated = obj.lastUpdated ? new Date(obj.lastUpdated) : new Date();
2020-04-19 10:46:53 +02:00
game.currentPlayer = obj.currentPlayer || "";
2016-02-28 20:23:41 +01:00
return game;
};
Game.prototype.getPlayerRack = function (player) {
2020-04-05 15:49:48 +02:00
const playerID = "#" + player;
2016-02-28 20:23:41 +01:00
return (this.racks[playerID] || (this.racks[playerID] = []));
};
Game.prototype.getPlayerScore = function (player) {
2020-04-05 15:49:48 +02:00
const playerID = "#" + player;
2016-02-28 20:23:41 +01:00
return (this.scores[playerID] || (this.scores[playerID] = 0));
};
Game.prototype.setPlayerScore = function (player, score) {
2020-04-05 15:49:48 +02:00
const playerID = "#" + player;
2016-02-28 20:23:41 +01:00
2020-04-04 16:27:18 +02:00
if (!Object.prototype.hasOwnProperty.call(this.racks, playerID) || typeof score !== "number") {
2016-02-28 20:23:41 +01:00
return;
}
this.scores[playerID] = score;
2020-04-12 20:14:35 +02:00
this.pendingEvents.push({
players: [{
player: player,
score: score
}]
});
2016-02-28 20:23:41 +01:00
};
2020-04-19 10:46:53 +02:00
Game.prototype.setCurrentPlayer = function (player) {
this.currentPlayer = player;
this.pendingEvents.push({currentPlayer: player});
};
2016-02-28 20:23:41 +01:00
Game.prototype.playerJoined = function (playerName) {
if (playerName) {
2020-04-04 16:27:18 +02:00
this.getPlayerRack(playerName); // Create the player's rack
2016-02-28 20:23:41 +01:00
}
2020-04-05 15:49:48 +02:00
const players = [];
2016-02-28 20:23:41 +01:00
2020-05-16 10:02:18 +02:00
for (let player of Object.keys(this.racks)) {
player = player.slice(1); // '#'
players.push({
player: player,
score: this.getPlayerScore(player),
rackCount: countTiles(this.getPlayerRack(player))
});
2016-02-28 20:23:41 +01:00
}
this.pendingEvents.push({players: players});
2016-02-28 20:23:41 +01:00
};
2020-04-12 20:14:39 +02:00
Game.prototype.addListeningPlayer = function (playerName, responseAndType) {
2020-04-05 15:49:48 +02:00
const that = this;
2016-02-28 20:23:41 +01:00
2020-04-12 20:14:39 +02:00
that.listeningPlayers.push(responseAndType);
keepAlive(responseAndType);
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
let closed = false;
2020-05-16 10:02:18 +02:00
function close() {
if (closed) {
return;
}
closed = true;
2020-04-12 20:14:39 +02:00
stopKeepAlive(responseAndType);
2020-04-13 21:18:40 +02:00
responseAndType[0] = null;
2020-04-12 20:14:39 +02:00
const index = that.listeningPlayers.indexOf(responseAndType);
2016-02-28 20:23:41 +01:00
if (index !== -1) {
that.listeningPlayers[index] = that.listeningPlayers[that.listeningPlayers.length - 1];
that.listeningPlayers.pop();
}
}
2020-04-12 20:14:39 +02:00
responseAndType[0].on("error", close);
responseAndType[0].on("close", close);
responseAndType[0].on("finish", close);
responseAndType[0].on("prefinish", close);
2016-02-28 20:23:41 +01:00
this.playerJoined(playerName);
this.commit();
};
Game.prototype.commit = function () {
2020-04-12 20:14:35 +02:00
const msg = JSON.stringify(this.pendingEvents);
2016-02-28 20:23:41 +01:00
this.pendingEvents = [];
2020-04-05 15:49:48 +02:00
for (let i = 0; i < this.listeningPlayers.length; i++) {
2016-02-28 20:23:41 +01:00
while (i < this.listeningPlayers.length && !this.listeningPlayers[i]) {
this.listeningPlayers[i] = this.listeningPlayers[this.listeningPlayers.length - 1];
2020-04-04 16:27:18 +02:00
this.listeningPlayers.pop();
2016-02-28 20:23:41 +01:00
}
if (this.listeningPlayers[i]) {
2020-04-12 20:14:39 +02:00
writeMessage(this.listeningPlayers[i], msg);
2016-02-28 20:23:41 +01:00
}
}
2020-04-05 12:34:30 +02:00
if (saveTimeout === null) {
saveTimeout = setTimeout(saveGames, SAVE_TIMEOUT);
2016-02-28 20:23:41 +01:00
}
};
Game.prototype.bagPopLetter = function (player) {
if (this.bag.length) {
const letter = this.bag.pop();
2020-04-04 16:27:18 +02:00
this.pendingEvents.push({
player: player,
action: "popBag",
remainingLetters: this.bag.length
2020-04-04 16:27:18 +02:00
});
2016-02-28 20:23:41 +01:00
return letter;
}
return "";
};
Game.prototype.getCell = function (index) {
return this.board[index];
2020-04-04 16:27:18 +02:00
};
2016-02-28 20:23:41 +01:00
Game.prototype.setCell = function (index, letter, player) {
this.board[index] = letter;
this.pendingEvents.push({
2020-04-28 19:29:36 +02:00
player: player,
action: "setCell",
indexTo: index,
letter: letter
});
2016-02-28 20:23:41 +01:00
};
Game.prototype.bagPushLetter = function (letter, player) {
if (letter) {
this.bag.push(letter);
shuffleInPlace(this.bag);
2016-02-28 20:23:41 +01:00
this.pendingEvents.push({
player: player,
action: "pushBag",
remainingLetters: this.bag.length
});
2016-02-28 20:23:41 +01:00
}
};
Game.prototype.reset = function (player) {
2021-04-26 08:44:59 +02:00
this.init(this.lang, this.label, this.factor);
2020-04-04 16:27:18 +02:00
this.pendingEvents.push({
player: player,
action: "reset",
board: this.board,
label: this.label,
2021-04-26 08:44:59 +02:00
factor: this.factor,
remainingLetters: this.bag.length,
rack: []
2020-04-04 16:27:18 +02:00
});
2016-02-28 20:23:41 +01:00
this.playerJoined();
};
function newGameId() {
2020-04-05 15:49:48 +02:00
let number;
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
let k = 10000;
let retries = 0;
2016-02-28 20:23:41 +01:00
do {
number = Math.floor(Math.random() * k).toString();
if (retries > 10) {
retries = 0;
k *= 10;
} else {
retries++;
}
} while (games[number]);
return number.toString();
}
function countTiles(rack) {
2020-04-05 15:49:48 +02:00
let count = 0;
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
for (let i = 0; i < rack.length; i++) {
2016-02-28 20:23:41 +01:00
if (rack[i]) {
count++;
}
}
return count;
}
2020-10-07 21:30:04 +02:00
function joinGame(gameNumber, message) {
2020-04-05 10:35:53 +02:00
if (!gameNumber) {
gameNumber = newGameId();
2020-10-07 21:30:04 +02:00
if (message) {
message.gameNumber = gameNumber;
}
2020-04-05 10:35:53 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-05 12:32:31 +02:00
const game = games[gameNumber] || (games[gameNumber] = new Game());
2016-02-28 20:23:41 +01:00
2020-04-05 10:35:53 +02:00
return {gameNumber, game};
}
function reply(message, response, cmdNumber, r) {
response.write(
JSON.stringify({...r, id: message.id || message.cmds[cmdNumber].id})
);
}
2020-04-25 23:16:34 +02:00
function handleCommand(cmdNumber, message, response) {
const {gameNumber, game} = joinGame(message.gameNumber);
2020-04-05 10:35:53 +02:00
2020-04-04 16:51:45 +02:00
game.lastUpdated = new Date();
2020-04-25 23:16:34 +02:00
const playerName = message.playerName;
const [nbRows, nbColumns, rackLength] = getBoardDimensions(game.label);
2020-04-25 23:16:34 +02:00
2020-04-05 15:49:48 +02:00
let rack = null;
2020-04-25 23:16:34 +02:00
const cmd = message.cmds[cmdNumber];
2016-02-28 20:23:41 +01:00
switch (cmd.cmd) {
case "sync": // DEPRECATED. Here for old clients.
2020-04-05 15:49:48 +02:00
case "joinGame": {
reply(message, response, cmdNumber, {
error: 0,
gameNumber: gameNumber,
playerName: playerName,
2020-05-04 23:27:36 +02:00
boardLang: game.lang,
boardLabel: game.label,
2021-04-26 08:44:59 +02:00
bagFactor: game.factor,
2020-05-16 10:02:18 +02:00
availableBoardLangs: availableBoardLangs,
currentPlayer: game.currentPlayer,
rack: game.getPlayerRack(playerName),
board: game.board,
remainingLetters: game.bag.length,
2020-05-05 08:30:39 +02:00
letterletterValues: game.letterValues,
2020-04-28 19:27:37 +02:00
version: VERSION
});
2020-04-04 17:19:28 +02:00
break;
2020-04-05 15:49:48 +02:00
}
2020-04-04 16:27:18 +02:00
2020-04-05 15:49:48 +02:00
case "hello": {
2020-04-04 17:19:28 +02:00
game.playerJoined(playerName);
2021-04-26 08:44:59 +02:00
reply(message, response, cmdNumber, {error: 0, boardLang: game.lang, boardLabel: game.label, bagFactor: game.factor, version: VERSION});
2020-04-04 17:19:28 +02:00
break;
2020-04-05 15:49:48 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
case "score": {
2020-04-04 17:19:28 +02:00
game.setPlayerScore(cmd.player, cmd.score);
reply(message, response, cmdNumber, {error: 0});
2020-04-19 10:46:53 +02:00
break;
}
case "currentPlayer": {
game.setCurrentPlayer(cmd.player);
reply(message, response, cmdNumber, {error: 0});
2020-04-04 17:19:28 +02:00
break;
2020-04-05 15:49:48 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
case "moveLetter": {
let letter = "";
2016-02-28 20:23:41 +01:00
2020-04-22 23:26:45 +02:00
// This case can fail in various ways. Instead altering the state
// of the game immediately, we store the operations to run them
// at the very end, if nothing failed.
const operations = [];
2020-04-04 17:19:28 +02:00
switch (cmd.from) {
case "rack":
rack = game.getPlayerRack(playerName);
2016-02-28 20:23:41 +01:00
2021-04-28 22:40:47 +02:00
if (cmd.indexFrom > rackLength - 1 || cmd.indexFrom < 0) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"});
2020-04-22 23:26:45 +02:00
return false;
}
2020-04-04 17:19:28 +02:00
letter = rack[cmd.indexFrom];
if (!letter) {
reply(message, response, cmdNumber, {error: 1, reason: "Moving from an empty location"});
return false;
}
2020-04-27 19:57:45 +02:00
operations.push(() => {
rack[cmd.indexFrom] = "";
});
2020-04-04 17:19:28 +02:00
break;
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
case "board":
if (cmd.indexFrom < 0 || cmd.indexFrom >= nbRows * nbColumns) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"});
2020-04-22 23:26:45 +02:00
return false;
2020-04-04 17:19:28 +02:00
}
2020-04-22 23:26:45 +02:00
letter = game.getCell(cmd.indexFrom);
operations.push(game.setCell.bind(game, cmd.indexFrom, "", playerName));
2020-04-04 17:19:28 +02:00
break;
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
case "bag":
2020-04-22 23:26:45 +02:00
if (!game.bag.length) {
reply(message, response, cmdNumber, {error: 1, reason: "Empty bag"});
2020-04-22 23:26:45 +02:00
return false;
}
2020-04-27 19:57:45 +02:00
operations.push(() => {
letter = game.bagPopLetter(playerName);
});
2020-04-04 17:19:28 +02:00
break;
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
default:
reply(message, response, cmdNumber, {error: 1, reason: "Moving letter from an unknown place"});
2020-04-22 23:26:45 +02:00
return false;
2020-04-04 17:19:28 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
switch (cmd.to) {
case "rack":
2021-04-28 22:40:47 +02:00
if (cmd.indexTo < 0 || cmd.indexTo > rackLength - 1) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"});
2020-04-22 23:26:45 +02:00
return false;
2020-04-04 17:19:28 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
rack = rack || game.getPlayerRack(playerName);
2020-04-22 23:26:45 +02:00
if (rack[cmd.indexTo]) {
reply(message, response, cmdNumber, {error: 1, reason: "Moving a tile to a non-empty location"});
2020-04-22 23:26:45 +02:00
return false;
}
2020-04-27 19:57:45 +02:00
operations.push(() => {
rack[cmd.indexTo] = letter;
});
2020-04-04 17:19:28 +02:00
break;
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
case "board":
if (cmd.indexTo < 0 || cmd.indexTo >= nbRows * nbColumns) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"});
2020-04-22 23:26:45 +02:00
return false;
2020-04-04 17:19:28 +02:00
}
2020-04-22 23:26:45 +02:00
operations.push(game.setCell.bind(game, cmd.indexTo, letter, playerName));
2020-04-04 17:19:28 +02:00
break;
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
case "bag":
2020-04-22 23:26:45 +02:00
operations.push(game.bagPushLetter.bind(game, letter, playerName));
2020-04-04 17:19:28 +02:00
break;
2016-02-28 20:23:41 +01:00
2020-04-04 17:19:28 +02:00
default:
response.write("{\"error\":1, \"reason\":\"Moving letter to an unknown place\"}");
2020-04-22 23:26:45 +02:00
return false;
}
for (const operation of operations) {
operation();
2020-04-04 17:19:28 +02:00
}
2016-02-28 20:23:41 +01:00
reply(message, response, cmdNumber, {
error: 0,
rack: (
(cmd.from === "bag" && cmd.to === "rack")
? rack
: undefined // eslint-disable-line no-undefined
),
remainingLetters: game.bag.length
});
2020-04-04 17:19:28 +02:00
if (rack) {
game.pendingEvents.push({
players: [{
player: playerName,
rackCount: countTiles(rack)
}]
2020-04-04 17:19:28 +02:00
});
}
2016-02-28 20:23:41 +01:00
break;
2020-04-05 15:49:48 +02:00
}
2016-02-28 20:23:41 +01:00
2020-04-05 15:49:48 +02:00
case "setRack": {
if (cmd.rack.length > rackLength) {
reply(message, response, cmdNumber, {
error: 1,
rack: rack,
reason: "the new rack is not at the right size"
});
2020-04-22 23:26:45 +02:00
return false;
2020-04-04 17:19:28 +02:00
}
2020-04-22 23:26:45 +02:00
rack = game.getPlayerRack(playerName);
2020-04-25 23:16:34 +02:00
const oldRackSorted = rack.filter((l) => l);
2020-04-22 23:26:45 +02:00
oldRackSorted.sort();
2020-04-25 23:16:34 +02:00
const newRackSorted = cmd.rack.filter((l) => l);
2020-04-22 23:26:45 +02:00
newRackSorted.sort();
for (let i = 0; i < rackLength; i++) {
2020-04-22 23:26:45 +02:00
if ((oldRackSorted[i] !== newRackSorted[i]) && (oldRackSorted[i] || newRackSorted[i])) {
reply(message, response, cmdNumber, {
error: 1,
rack: rack,
reason: "the new rack doesn't contain the same number of letters"
});
2020-04-22 23:26:45 +02:00
return false;
2016-02-28 20:23:41 +01:00
}
2020-04-04 16:27:18 +02:00
}
2016-02-28 20:23:41 +01:00
for (let i = 0; i < rackLength; i++) {
2020-04-04 17:19:28 +02:00
rack[i] = cmd.rack[i];
}
2020-04-04 16:27:18 +02:00
reply(message, response, cmdNumber, {error: 0});
2020-04-04 16:27:18 +02:00
2020-04-04 17:19:28 +02:00
break;
2020-04-05 15:49:48 +02:00
}
case "resetGame": {
2020-04-04 17:19:28 +02:00
game.reset();
reply(message, response, cmdNumber, {error: 0});
2020-04-04 17:19:28 +02:00
break;
2020-04-05 15:49:48 +02:00
}
2020-04-04 16:27:18 +02:00
2020-05-04 00:28:33 +02:00
case "changeBoard": {
2020-05-16 10:02:18 +02:00
game.lang = cmd.lang || DEFAULT_BOARD_LANG;
game.label = cmd.label || DEFAULT_BOARD_LABEL;
2021-04-26 08:44:59 +02:00
game.factor = cmd.factor || DEFAULT_BAG_FACTOR;
2020-05-04 00:28:33 +02:00
game.reset();
reply(message, response, cmdNumber, {
error: 0,
boardLang: game.lang,
boardLabel: game.label,
2021-04-26 08:44:59 +02:00
bagFactor: game.factor,
letterValues: game.letterValues
});
game.pendingEvents.push({
msg: {
sender: playerName,
content: "I changed the board to " + game.label + " in language " + game.lang,
specialMsg: {
2021-04-23 00:01:03 +02:00
type: "changeBoardDef",
newBoardLang: game.lang,
2021-04-26 08:44:59 +02:00
newBoardLabel: game.label,
newBagFactor: game.factor
}
}
});
2020-05-04 00:28:33 +02:00
break;
}
2020-04-05 15:49:48 +02:00
case "msg": {
2020-04-04 17:19:28 +02:00
game.pendingEvents.push({
msg: {
sender: playerName,
content: cmd.msg,
specialMsg: cmd.specialMsg
}
2020-04-04 17:19:28 +02:00
});
reply(message, response, cmdNumber, {error: 0});
2020-04-04 17:19:28 +02:00
break;
2020-04-05 15:49:48 +02:00
}
2020-04-04 17:19:28 +02:00
2020-04-05 15:49:48 +02:00
default: {
reply(message, response, cmdNumber, {error: 1, reason: "Unknown command"});
2020-04-22 23:26:45 +02:00
return false;
2020-04-05 15:49:48 +02:00
}
2016-02-28 20:23:41 +01:00
}
2020-04-22 23:26:45 +02:00
return true;
2016-02-28 20:23:41 +01:00
}
2020-04-25 23:16:34 +02:00
function handleCommands(message, responseAndType) {
if (!message.cmds || !message.cmds.length) {
2020-10-07 21:30:04 +02:00
const {gameNumber, game} = joinGame(message.gameNumber, message);
2016-02-28 20:23:41 +01:00
2020-04-12 20:14:39 +02:00
writeMessage(responseAndType,
2020-04-04 16:27:18 +02:00
JSON.stringify({
2020-05-16 10:02:18 +02:00
playerName: message.playerName,
currentPlayer: game.currentPlayer,
gameNumber: gameNumber,
boardLang: game.lang,
boardLabel: game.label,
2021-04-26 08:44:59 +02:00
bagFactor: game.factor,
2020-05-16 10:02:18 +02:00
availableBoardLangs: availableBoardLangs,
letterValues: game.letterValues,
rack: game.getPlayerRack(message.playerName),
board: game.board,
remainingLetters: game.bag.length,
version: VERSION
2020-04-04 16:27:18 +02:00
})
2016-02-28 20:23:41 +01:00
);
2020-04-25 23:16:34 +02:00
game.addListeningPlayer(message.playerName, responseAndType);
2016-02-28 20:23:41 +01:00
return;
}
2020-04-25 23:16:34 +02:00
let wsMessage = "";
2020-04-12 20:14:39 +02:00
const response = (
responseAndType[1] === REQUEST_TYPE_WEBSOCKET
? {
write: function (s) {
2020-04-25 23:16:34 +02:00
wsMessage += s;
2020-04-12 20:14:39 +02:00
},
end: function (s) {
if (s) {
2020-04-25 23:16:34 +02:00
wsMessage += s;
2020-04-12 20:14:39 +02:00
}
2020-04-25 23:16:34 +02:00
webSocketWrite(responseAndType[0], wsMessage);
2020-04-12 20:14:39 +02:00
}
}
: responseAndType[0]
);
2020-04-04 16:17:23 +02:00
2016-02-28 20:23:41 +01:00
response.write("[");
2020-04-25 23:16:34 +02:00
for (let i = 0; i < message.cmds.length; i++) {
2016-02-28 20:23:41 +01:00
if (i) {
response.write(",");
}
2020-04-22 23:26:45 +02:00
2020-04-25 23:16:34 +02:00
if (!handleCommand(i, message, response)) {
2020-04-22 23:26:45 +02:00
break;
}
2016-02-28 20:23:41 +01:00
}
response.end("]");
2020-04-25 23:16:34 +02:00
if (games[message.gameNumber]) {
games[message.gameNumber].commit();
2016-02-28 20:23:41 +01:00
}
}
2020-04-12 20:14:39 +02:00
// Thx https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8
function webSocketWrite(socket, data) {
/* eslint-disable capitalized-comments */
// Copy the data into a buffer
data = Buffer.from(data);
const byteLength = data.length;
// Note: we're not supporting > 65535 byte payloads at this stage
const lengthByteCount = byteLength < 126 ? 0 : 2;
const payloadLength = lengthByteCount === 0 ? byteLength : 126;
const buffer = Buffer.alloc(2 + lengthByteCount + byteLength);
// Write out the first byte, using opcode `1` to indicate that the message
// payload contains text data
buffer.writeUInt8(0b10000001, 0);
buffer.writeUInt8(payloadLength, 1);
// Write the length of the payload to the second byte
if (lengthByteCount === 2) {
buffer.writeUInt16BE(byteLength, 2);
}
// Write the data to the data buffer
data.copy(buffer, 2 + lengthByteCount);
socket.write(buffer);
}
function webSocketGetMessage(buffer) {
/* eslint-disable no-bitwise, capitalized-comments */
let dataAfter = "";
let finalOffset = -1;
if (buffer.length < 2) {
return ["", 0];
}
const firstByte = buffer.readUInt8(0);
const isFinalFrame = Boolean((firstByte >>> 7) & 0x1);
// const [reserved1, reserved2, reserved3] = [
// Boolean((firstByte >>> 6) & 0x1),
// Boolean((firstByte >>> 5) & 0x1),
// Boolean((firstByte >>> 4) & 0x1)
// ];
const opCode = firstByte & 0xF;
// We can return null to signify that this is a connection termination frame
if (opCode === 0x8) {
return null;
}
const secondByte = buffer.readUInt8(1);
const isMasked = (secondByte >>> 7) & 0x1;
// Keep track of our current position as we advance through the buffer
let currentOffset = 2;
let payloadLength = secondByte & 0x7F;
if (payloadLength > 125) {
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(currentOffset);
currentOffset += 2;
} else {
// 127
// If this has a value, the frame size is ridiculously huge!
// const leftPart = buffer.readUInt32BE(currentOffset);
// const rightPart = buffer.readUInt32BE(currentOffset += 4);
// Honestly, if the frame length requires 64 bits, you're probably doing it wrong.
// In Node.js you'll require the BigInt type, or a special library to handle this.
throw new Error("Large websocket payloads not currently implemented");
}
}
if (payloadLength + currentOffset > buffer.length) {
return ["", 0];
}
if (!isFinalFrame) {
const message = webSocketGetMessage(buffer.slice(payloadLength + currentOffset));
if (message) {
if (message[1]) {
dataAfter = message[0];
finalOffset = message[1];
} else {
return ["", 0];
}
} else if (message === null) {
// ??!?
return null;
}
}
let maskingKey;
if (isMasked) {
maskingKey = buffer.readUInt32BE(currentOffset);
currentOffset += 4;
}
// Allocate somewhere to store the final message data
const data = Buffer.alloc(payloadLength);
// Only unmask the data if the masking bit was set to 1
if (isMasked) {
// Loop through the source buffer one byte at a time, keeping track of which
// byte in the masking key to use in the next XOR calculation
for (let i = 0, j = 0; i < payloadLength; ++i, j = i % 4) {
// Extract the correct byte mask from the masking key
const shift = (j === 3) ? 0 : (3 - j) << 3;
const mask = (shift === 0 ? maskingKey : (maskingKey >>> shift)) & 0xFF;
// Read a byte from the source buffer
const source = buffer.readUInt8(currentOffset++);
// XOR the source byte and write the result to the data buffer
data.writeUInt8(mask ^ source, i);
}
} else {
// Not masked - we can just read the data as-is
buffer.copy(data, 0, currentOffset);
}
return [
opCode === 0x1
? (data.toString("utf8") + dataAfter)
: "",
finalOffset === -1
? currentOffset
: finalOffset
];
}
2016-02-28 20:23:41 +01:00
function handleRequest(request, response) {
2020-04-05 15:49:48 +02:00
let post = "";
2020-04-12 20:14:39 +02:00
const responseAndType = [response, REQUEST_TYPE_LONG_POLLING];
let upgradedToWebSocket = false;
2020-04-05 12:34:30 +02:00
response.on("error", function connectionError(e) {
console.error("An error occurred while trying to write on a socket", e);
2020-04-12 20:14:39 +02:00
stopKeepAlive(responseAndType);
});
2016-02-28 20:23:41 +01:00
2020-04-04 16:27:18 +02:00
// Thx http://stackoverflow.com/questions/4295782/how-do-you-extract-post-data-in-node-js
2016-02-28 20:23:41 +01:00
request.on("data", function (data) {
2020-04-12 20:14:39 +02:00
if (upgradedToWebSocket) {
return;
}
2016-02-28 20:23:41 +01:00
post += data;
// Too much POST data, kill the connection!
if (post.length > 1e6) {
request.connection.destroy();
}
});
2020-04-12 20:14:39 +02:00
request.on("upgrade", function () {
upgradedToWebSocket = true;
});
request.on("error", function (error) {
console.error("Error while handling this request", request, error);
});
2016-02-28 20:23:41 +01:00
request.on("end", function () {
2020-04-12 20:14:39 +02:00
if (upgradedToWebSocket) {
return;
}
2020-04-05 12:51:47 +02:00
if (!running) {
response.statusCode = 503;
response.statusMessage = "Server is stopping";
response.end("Server is stopping");
return;
}
2020-04-04 16:27:18 +02:00
if (DEV_ENABLE_SERVING_FILES && !request.url.startsWith("/:")) {
if (request.url === "/") {
request.url = "/index.html";
}
2020-04-04 16:57:44 +02:00
debuglog("Serving " + request.url);
2020-04-04 16:27:18 +02:00
2020-05-16 10:02:18 +02:00
const requestedPath = path.join(__dirname, "..", "public", request.url);
fs.exists(requestedPath, function (exists) {
2020-04-04 16:27:18 +02:00
if (exists) {
2020-05-16 10:02:18 +02:00
fs.readFile(requestedPath, function (err, contents) {
2020-04-04 16:27:18 +02:00
if (err) {
response.statusCode = 500;
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.end(err);
}
2020-04-05 15:49:48 +02:00
let mime = "application/xhtml+xml; charset=utf-8";
const mimes = {
2020-04-04 16:27:18 +02:00
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".dict": "text/plain"
2020-04-04 16:27:18 +02:00
};
2020-04-05 15:49:48 +02:00
for (const i in mimes) {
2020-04-12 20:14:35 +02:00
if (Object.prototype.hasOwnProperty.call(mimes, i) && request.url.endsWith(i)) {
2020-04-04 16:27:18 +02:00
mime = mimes[i];
if (i === ".js") {
contents = contents.toString().replace(
/(?<before>^|\s|\()(?:const|let)(?<after>\s)/gu,
"$1var$2"
);
}
2020-04-04 16:27:18 +02:00
break;
}
}
response.setHeader("Content-Type", mime);
2020-10-05 23:47:06 +02:00
response.setHeader(
"Content-Security-Policy",
"default-src 'none'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"img-src 'self'; " +
2020-10-27 08:20:57 +01:00
(disableCSP ? "connect-src *; " : "connect-src 'self' " + (
// uzbl (like Safari 9 / iPad 2) does not like insecure websockets on the
2020-10-05 23:47:06 +02:00
// same port with connect-src 'self'
// See https://github.com/w3c/webappsec-csp/issues/7
"ws://" + host + ":" + port + " " +
"ws://127.0.0.1:" + port + " " +
"ws://localhost:" + port + "; "
)) +
"media-src 'self'"
);
2020-04-04 16:27:18 +02:00
response.end(contents);
});
} else {
response.statusCode = 404;
response.end("404");
}
});
return;
}
const sseMatch = request.url.match(/\/(?::[^/]+\/)?sse\/(?<data>[\s\S]+)$/u);
2020-04-04 16:17:23 +02:00
2020-04-12 20:14:39 +02:00
if (sseMatch) {
2020-04-04 16:17:23 +02:00
response.setHeader("Content-Type", "text/event-stream");
2020-04-12 20:14:39 +02:00
post = decodeURIComponent(sseMatch.groups.data);
responseAndType[1] = REQUEST_TYPE_SSE;
2020-04-04 16:17:23 +02:00
} else {
response.setHeader("Content-Type", "text/plain");
response.setHeader("Transfer-Encoding", "chunked");
}
2020-04-12 20:14:39 +02:00
debuglog("REQ:", request.url, post, responseAndType[1]);
2020-04-05 21:05:48 +02:00
try {
post = post && JSON.parse(post);
} catch (e) {
response.statusCode = 400;
response.setHeader("application/json");
response.end('{"error":1, "reason": "Unable to parse your JSON data"}');
return;
}
2020-04-12 20:14:39 +02:00
handleCommands(post, responseAndType);
2016-02-28 20:23:41 +01:00
});
}
fs.readFile(GAMES_BACKUP, function (err, data) {
2016-02-28 20:23:41 +01:00
try {
if (err) {
console.error("WARNING: Could not restore previous backup of the games");
} else {
2020-04-05 15:49:48 +02:00
const backup = JSON.parse(data);
2016-02-28 20:23:41 +01:00
2020-05-16 10:02:18 +02:00
for (const gameNumber of Object.keys(backup)) {
games[gameNumber] = Game.fromJSON(backup[gameNumber]);
2016-02-28 20:23:41 +01:00
}
}
} catch (e) {
console.error("WARNING: Could not restore previous backup of the games: file is broken:");
console.error("WARNING: ", e);
}
2020-04-12 20:14:39 +02:00
// See https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
server.on("upgrade", function upgradeToWebsocket(request, socket) {
2020-04-13 21:18:40 +02:00
const responseAndType = [socket, REQUEST_TYPE_WEBSOCKET];
function closeSocket() {
responseAndType[0] = null;
}
2020-04-12 20:14:39 +02:00
socket.on("error", function (e) {
2020-04-13 21:18:40 +02:00
console.error("Socket error", e.toString());
closeSocket();
2020-04-12 20:14:39 +02:00
});
2020-04-13 21:18:40 +02:00
socket.on("close", closeSocket);
2020-04-12 20:14:39 +02:00
const webSocketMatch = request.url.match(/\/(?::[^/]+\/)?ws\/(?<data>[\s\S]+)$/u);
2020-04-12 20:14:39 +02:00
let decodedData;
try {
decodedData = decodeURIComponent(webSocketMatch.groups.data);
} catch (e) {
socket.end("HTTP/1.1 400 Bad Request");
console.error("WS: FAILED - Could not decode the given data " + webSocketMatch.groups.data);
return;
}
2020-04-25 23:16:34 +02:00
let message;
2020-04-12 20:14:39 +02:00
if (webSocketMatch) {
try {
2020-04-25 23:16:34 +02:00
message = JSON.parse(decodedData);
2020-04-12 20:14:39 +02:00
} catch (e) {
socket.end("HTTP/1.1 400 Bad Request");
console.error("WS: FAILED - Could not parse the given data " + decodedData);
return;
}
} else {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
if (request.headers.upgrade.toLowerCase() !== "websocket") {
2020-04-12 20:14:39 +02:00
socket.end("HTTP/1.1 400 Bad Request");
console.error("WS: FAILED - HTTP header 'Upgrade' is not 'websocket' " + decodedData);
return;
}
socket.write(
"HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
"Upgrade: WebSocket\r\n" +
"Connection: Upgrade\r\n"
);
const webSocketKey = request.headers["sec-websocket-key"];
if (webSocketKey) {
socket.write(
"Sec-WebSocket-Accept: " + (
crypto
.createHash("sha1")
.update(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "binary")
.digest("base64")
) + "\r\n"
);
}
socket.write("\r\n");
debuglog("WS: established " + decodedData);
let receivedBytes = null;
socket.on("data", function webSocketData(buffer) {
if (receivedBytes) {
buffer = Buffer.concat([receivedBytes, buffer]);
receivedBytes = null;
}
while (true) {
2020-04-25 23:16:34 +02:00
const wsMessage = webSocketGetMessage(buffer);
if (wsMessage && wsMessage[1]) {
if (wsMessage[0]) {
debuglog("WS message: " + wsMessage[0]);
2020-04-12 20:14:39 +02:00
try {
2020-04-25 23:16:34 +02:00
message.cmds = JSON.parse(wsMessage[0]);
2020-04-12 20:14:39 +02:00
} catch (e) {
writeMessage(responseAndType, '{"error":1, "reason": "Unable to parse your JSON data"}');
return;
}
2020-04-25 23:16:34 +02:00
handleCommands(message, responseAndType);
message.cmds = null;
2020-04-12 20:14:39 +02:00
}
2020-04-25 23:16:34 +02:00
} else if (wsMessage === null) {
2020-04-12 20:14:39 +02:00
if (responseAndType[0] && !socket.isDestroyed) {
responseAndType[0] = null;
socket.end(webSocketCloseBuffer);
}
return;
}
2020-04-25 23:16:34 +02:00
if (wsMessage[1] === buffer.length) {
2020-04-12 20:14:39 +02:00
break;
}
2020-04-25 23:16:34 +02:00
if (wsMessage[1] === 0) {
2020-04-12 20:14:39 +02:00
receivedBytes = buffer;
return;
}
2020-04-25 23:16:34 +02:00
buffer = buffer.slice(wsMessage[1]);
2020-04-12 20:14:39 +02:00
}
});
2020-04-25 23:16:34 +02:00
handleCommands(message, responseAndType);
2020-04-12 20:14:39 +02:00
});
2020-04-05 21:05:48 +02:00
server.on("error", function (error) {
console.error("An error happened in the HTTP server", error);
});
2020-05-23 22:13:18 +02:00
server.listen(port, host, function () {
console.log("Server listening on: http://%s:%s", host, port);
2016-02-28 20:23:41 +01:00
});
});
2020-04-05 12:51:47 +02:00
process.once("SIGINT", function () {
console.log("SIGINT received...");
stop();
});
process.once("SIGTERM", function () {
console.log("SIGTERM received...");
stop();
});
2020-04-05 21:05:48 +02:00
process.on("uncaughtException", function (err) {
console.log(err);
});