]> git.cworth.org Git - empires-server/blob - lmno.js
game: Store players in both an array _and_ a session-indexed object
[empires-server] / lmno.js
1 const express = require("express");
2 const cors = require("cors");
3 const body_parser = require("body-parser");
4 const session = require("express-session");
5 const bcrypt = require("bcrypt");
6 const path = require("path");
7 const nunjucks = require("nunjucks");
8
9 try {
10   var lmno_config = require("./lmno-config.json");
11 } catch (err) {
12   config_usage();
13   process.exit(1);
14 }
15
16 function config_usage() {
17   console.log(`Error: Refusing to run without configuration.
18
19 Please create a file named lmno-config.json that looks as follows:
20
21 {
22   "session_secret": "<this should be a long string of true-random characters>",
23   "users": {
24     "username": "<username>",
25     "password_hash_bcrypt": "<password_hash_made_by_bcrypt>"
26   }
27 }
28
29 Note: Of course, change all of <these-parts> to actual values desired.
30
31 The "node lmno-passwd.js" command can help generate password hashes.`);
32 }
33
34 const app = express();
35 app.use(cors());
36 app.use(body_parser.urlencoded({ extended: false }));
37 app.use(body_parser.json());
38 app.use(session({
39   secret: lmno_config.session_secret,
40   resave: false,
41   saveUninitialized: false
42 }));
43
44 nunjucks.configure("templates", {
45   autoescape: true,
46   express: app
47 });
48
49 /* Load each of our game mini-apps.
50  *
51  * Each "engine" we load here must have a property .Game on the
52  * exports object that should be a class that extends the common base
53  * class Game.
54  *
55  * In turn, each engine's Game must have the following properties:
56  *
57  *     .meta:   An object with .name and .identifier properties.
58  *
59  *              Here, .name is a string giving a human-readable name
60  *              for the game, such as "Tic Tac Toe" while .identifier
61  *              is the short, single-word, all-lowercase identifier
62  *              that is used in the path of the URL, such as
63  *              "tictactoe".
64  *
65  *     .router: An express Router object
66  *
67  *              Any game-specific routes should already be on the
68  *              router. Then, LMNO will add common routes including:
69  *
70  *                 /        Serves <identifier>-game.html template
71  *
72  *                 /player  Allows client to set name or team
73  *
74  *                 /events  Serves a stream of events. Game can override
75  *                          the handle_events method, call super() first,
76  *                          and then have code to add custom events.
77  *
78  *                 /moves   Receives move data from clients. This route
79  *                          is only added if the Game class has an
80  *                          add_move method.
81  */
82 const engines = {
83   empires: require("./empires").Game,
84   tictactoe: require("./tictactoe").Game
85 };
86
87 class LMNO {
88   constructor() {
89     this.games = {};
90   }
91
92   generate_id() {
93     return Array(4).fill(null).map(() => LMNO.letters.charAt(Math.floor(Math.random() * LMNO.letters.length))).join('');
94   }
95
96   create_game(engine_name) {
97     do {
98       var id = this.generate_id();
99     } while (id in this.games);
100
101     const engine = engines[engine_name];
102
103     const game = new engine(id);
104
105     this.games[id] = game;
106
107     return game;
108   }
109 }
110
111 /* Some letters we don't use in our IDs:
112  *
113  * 1. Vowels (AEIOU) to avoid accidentally spelling an unfortunate word
114  * 2. Lowercase letters (replace with corresponding capital on input)
115  * 3. N (replace with M on input)
116  * 4. P (replace with B on input)
117  * 5. S (replace with F on input)
118  */
119 LMNO.letters = "BCDFGHJKLMQRTVWXYZ";
120
121 const lmno = new LMNO();
122
123 /* Force a game ID into a canonical form as described above. */
124 function lmno_canonize(id) {
125   /* Capitalize */
126   id = id.toUpperCase();
127
128   /* Replace unused letters with nearest phonetic match. */
129   id = id.replace(/N/g, 'M');
130   id = id.replace(/P/g, 'B');
131   id = id.replace(/S/g, 'F');
132
133   /* Replace unused numbers nearest visual match. */
134   id = id.replace(/0/g, 'O');
135   id = id.replace(/1/g, 'I');
136   id = id.replace(/5/g, 'S');
137
138   return id;
139 }
140
141 app.post('/new/:game_engine', (request, response) =>  {
142   const game_engine = request.params.game_engine;
143   const game = lmno.create_game(game_engine);
144   response.send(JSON.stringify(game.id));
145 });
146
147 /* Redirect any requests to a game ID at the top-level.
148  *
149  * Specifically, after obtaining the game ID (from the path) we simply
150  * lookup the game engine for the corresponding game and then redirect
151  * to the engine- and game-specific path.
152  */
153 app.get('/[a-zA-Z0-9]{4}', (request, response) => {
154   const game_id = request.path.replace(/\//g, "");
155   const canon_id = lmno_canonize(game_id);
156
157   /* Redirect user to page with the canonical ID in it. */
158   if (game_id !== canon_id) {
159     response.redirect(301, `/${canon_id}/`);
160     return;
161   }
162
163   const game = lmno.games[game_id];
164   if (game === undefined) {
165       response.sendStatus(404);
166       return;
167   }
168   response.redirect(301, `/${game.meta.identifier}/${game.id}/`);
169 });
170
171 /* LMNO middleware to lookup the game. */
172 app.use('/:engine([^/]+)/:game_id([a-zA-Z0-9]{4})', (request, response, next) => {
173   const engine = request.params.engine;
174   const game_id = request.params.game_id;
175   const canon_id = lmno_canonize(game_id);
176
177   /* Redirect user to page with the canonical ID in it, also ensuring
178    * that the game ID is _always_ followed by a slash. */
179   const has_slash = new RegExp(`^/${engine}/${game_id}/`);
180   if (game_id !== canon_id ||
181       ! has_slash.test(request.originalUrl))
182   {
183     const old_path = new RegExp(`/${engine}/${game_id}/?`);
184     const new_path = `/${engine}/${canon_id}/`;
185     const new_url = request.originalUrl.replace(old_path, new_path);
186     response.redirect(301, new_url);
187     return;
188   }
189
190   /* See if there is any game with this ID. */
191   const game = lmno.games[game_id];
192   if (game === undefined) {
193     response.sendStatus(404);
194     return;
195   }
196
197   /* Stash the game onto the request to be used by the game-specific code. */
198   request.game = game;
199   next();
200 });
201
202 function auth_admin(request, response, next) {
203   /* If there is no user associated with this session, redirect to the login
204    * page (and set a "next" query parameter so we can come back here).
205    */
206   if (! request.session.user) {
207     response.redirect(302, "/login?next=" + request.path);
208     return;
209   }
210
211   /* If the user is logged in but not authorized to view the page then 
212    * we return that error. */
213   if (request.session.user.role !== "admin") {
214     response.status(401).send("Unauthorized");
215     return;
216   }
217   next();
218 }
219
220 app.get('/logout', (request, response) => {
221   request.session.user = undefined;
222   request.session.destroy();
223
224   response.send("You are now logged out.");
225 });
226
227 app.get('/login', (request, response) => {
228   if (request.session.user) {
229     response.send("Welcome, " + request.session.user + ".");
230     return;
231   }
232
233   response.render('login.html');
234 });
235
236 app.post('/login', async (request, response) => {
237   const username = request.body.username;
238   const password = request.body.password;
239   const user = lmno_config.users[username];
240   if (! user) {
241     response.sendStatus(404);
242     return;
243   }
244   const match = await bcrypt.compare(password, user.password_hash_bcrypt);
245   if (! match) {
246     response.sendStatus(404);
247     return;
248   }
249   request.session.user = { username: user.username, role: user.role };
250   response.sendStatus(200);
251   return;
252 });
253
254 /* API to set uer profile information */
255 app.put('/profile', (request, response) => {
256   const nickname = request.body.nickname;
257   if (nickname) {
258     request.session.nickname = nickname;
259     request.session.save();
260   }
261   response.send();
262 });
263
264 /* An admin page (only available to admin users, of course) */
265 app.get('/admin/', auth_admin, (request, response) => {
266   let active = [];
267   let idle = [];
268
269   for (let id in lmno.games) {
270     if (lmno.games[id].clients.length)
271       active.push(lmno.games[id]);
272     else
273       idle.push(lmno.games[id]);
274   }
275   response.render('admin.html', { test: "foobar", games: { active: active, idle: idle}});
276 });
277
278
279 /* Mount sub apps. only _after_ we have done all the middleware we need. */
280 for (let key in engines) {
281   const engine = engines[key];
282   const router = engine.router;
283
284   /* Add routes that are common to all games. */
285   router.get('/', (request, response) => {
286     const game = request.game;
287
288     if (! request.session.nickname)
289       response.render('choose-nickname.html', { game_name: game.meta.name });
290     else
291       response.render(`${game.meta.identifier}-game.html`);
292   });
293
294   router.put('/player', (request, response) => {
295     const game = request.game;
296
297     game.handle_player(request, response);
298   });
299
300   router.get('/events', (request, response) => {
301     const game = request.game;
302
303     game.handle_events(request, response);
304   });
305
306   /* Further, add some routes conditionally depending on whether the
307    * engine provides specific, necessary methods for the routes. */
308
309   /* Note: We have to use hasOwnProperty here since the base Game
310    * class has a geeric add_move function, and we don't want that to
311    * have any influence on our decision. Only if the child has
312    * overridden that do we want to create a "/move" route. */
313   if (engine.prototype.hasOwnProperty("add_move")) {
314     router.post('/move', (request, response) => {
315       const game = request.game;
316       const move = request.body.move;
317       const player = game.players_by_session[request.session.id];
318
319       /* Reject move if there is no player for this session. */
320       if (! player) {
321         response.json({legal: false, message: "No valid player from session"});
322         return;
323       }
324
325       const result = game.add_move(player, move);
326
327       /* Feed move response back to the client. */
328       response.json(result);
329
330       /* And only if legal, inform all clients. */
331       if (! result.legal)
332         return;
333
334       game.broadcast_move(move);
335     });
336   }
337
338   /* And mount the whole router at the path for the game. */
339   app.use(`/${engine.meta.identifier}/[a-zA-Z0-9]{4}/`, router);
340 }
341
342 app.listen(4000, function () {
343   console.log('LMNO server listening on localhost:4000');
344 });