]> git.cworth.org Git - lmno-server/blob - game.js
game: Store players in both an array _and_ a session-indexed object
[lmno-server] / game.js
1 /* A single player can have multiple connections, (think, multiple
2  * browser windows with a common session cookie). */
3 class Player {
4   constructor(id, session_id, name, connection) {
5     this.id = id;
6     this.session_id = session_id;
7     this.name = name;
8     this.connections = [connection];
9     this.team = "";
10   }
11
12   add_connection(connection) {
13     /* Don't add a duplicate connection if this player already has it. */
14     for (let c of this.connections) {
15       if (c === connection)
16         return;
17     }
18
19     this.connections.push(connection);
20   }
21
22   /* Returns the number of remaining connections after this one is removed. */
23   remove_connection(connection) {
24     this.connections.filter(c => c !== connection);
25     return this.connections.length;
26   }
27
28   /* Send a string to all connections for this player. */
29   send(data) {
30     this.connections.forEach(connection => connection.write(data));
31   }
32
33   info_json() {
34     return JSON.stringify({
35       id: this.id,
36       name: this.name,
37       team: this.team
38     });
39   }
40 }
41
42 /* Base class providing common code for game engine implementations. */
43 class Game {
44   constructor(id) {
45     this.id = id;
46     this.players = [];
47     this.players_by_session = {};
48     this.next_player_id = 1;
49     this.teams = [];
50     this.state = {
51       team_to_play: ""
52     };
53
54     /* Send a comment to every connected client every 15 seconds. */
55     setInterval(() => {this.broadcast_string(":");}, 15000);
56   }
57
58   /* Suport for game meta-data.
59    *
60    * What we want here is an effectively static field that is
61    * accessible through either the class name (SomeGame.meta) or an
62    * instance (some_game.meta). To pull this off we do keep two copies
63    * of the data. But the game classes can just set SomeGame.meta once
64    * and then reference it either way.
65    */
66   static set meta(data) {
67     /* This allows class access (SomeGame.meta) via the get method below. */
68     this._meta = data;
69
70     /* While this allows access via an instance (some_game.meta). */
71     this.prototype.meta = data;
72   }
73
74   static get meta() {
75     return this._meta;
76   }
77
78   /* Just performs some checks for whether a move is definitely not
79    * legal (such as not the player's turn). A child class is expected
80    * to override this (and call super.add_move early!) to implement
81    * the actual logic for a move. */
82   add_move(player, move) {
83     /* Cannot move if you are not on a team. */
84     if (player.team === "")
85     {
86       return { legal: false,
87                message: "You must be on a team to take a turn" };
88     }
89
90     /* Cannot move if it's not this player's team's turn. */
91     if (player.team !== this.state.team_to_play)
92     {
93       return { legal: false,
94                message: "It's not your turn to move" };
95     }
96
97     return { legal: true };
98   }
99
100   add_player(session, connection) {
101     /* First see if we already have a player object for this session. */
102     const existing = this.players_by_session[session.id];
103     if (existing) {
104       existing.add_connection(connection);
105       return existing;
106     }
107
108     /* No existing player. Add a new one. */
109     const id = this.next_player_id;
110     const player = new Player(id, session.id, session.nickname, connection);
111
112     /* Broadcast before adding player to list (to avoid announcing the
113      * new player to itself). */
114     const player_data = JSON.stringify({ id: player.id, name: player.name });
115     this.broadcast_event("player-enter", player_data);
116
117     this.players.push(player);
118     this.players_by_session[session.id] = player;
119     this.next_player_id++;
120
121     return player;
122   }
123
124   /* Drop a connection object from a player, and if it's the last one,
125    * then drop that player from the game's list of players. */
126   remove_player_connection(player, connection) {
127     const remaining = player.remove_connection(connection);
128     if (remaining === 0) {
129       const player_data = JSON.stringify({ id: player.id });
130       this.players.filter(p => p !== player);
131       delete this.players_by_session[player.session_id];
132       this.broadcast_event("player-exit", player_data);
133     }
134   }
135
136   /* Send a string to all players */
137   broadcast_string(str) {
138     this.players.forEach(player => player.send(str + '\n'));
139   }
140
141   /* Send an event to all players.
142    *
143    * An event has both a declared type and a separate data block.
144    * It also ends with two newlines (to mark the end of the event).
145    */
146   broadcast_event(type, data) {
147     this.broadcast_string(`event: ${type}\ndata: ${data}\n`);
148   }
149
150   handle_events(request, response) {
151     /* These headers will keep the connection open so we can stream events. */
152     const headers = {
153       "Content-type": "text/event-stream",
154       "Connection": "keep-alive",
155       "Cache-Control": "no-cache"
156     };
157     response.writeHead(200, headers);
158
159     /* Add this new player. */
160     const player = this.add_player(request.session, response);
161
162     /* And queue up cleanup to be triggered on client close. */
163     request.on('close', () => {
164       this.remove_player_connection(player, response);
165     });
166
167     /* Give the client the game-info event. */
168     const game_info_json = JSON.stringify({
169       id: this.id,
170       url: `${request.protocol}://${request.hostname}/${this.id}`
171     });
172     response.write(`event: game-info\ndata: ${game_info_json}\n\n`);
173
174     /* And the player-info event. */
175     response.write(`event: player-info\ndata: ${player.info_json()}\n\n`);
176
177     /* Finally, if this game class has a "state" property, stream that
178      * current state to the client. */
179     if (this.state) {
180       const state_json = JSON.stringify(this.state);
181       response.write(`event: game-state\ndata: ${state_json}\n\n`);
182     }
183   }
184
185   handle_player(request, response) {
186     const player = this.players_by_session[request.session.id];
187     const name = request.body.name;
188     const team = request.body.team;
189     var updated = false;
190     if (! player) {
191       response.sendStatus(404);
192       return;
193     }
194
195     if (name && (player.name !== name)) {
196       player.name = name;
197
198       /* In addition to setting the name within this game's player
199        * object, also set the name in the session. */
200       request.session.nickname = name;
201       request.session.save();
202
203       updated = true;
204     }
205
206     if (team !== null && (player.team !== team) &&
207         (team === "" || this.teams.includes(team)))
208     {
209       player.team = team;
210
211       updated = true;
212     }
213
214     if (updated)
215       this.broadcast_event("player-update", player.info_json());
216
217     response.send("");
218   }
219
220   broadcast_move(move) {
221     this.broadcast_event("move", move);
222   }
223
224 }
225
226 module.exports = Game;