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