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