]> git.cworth.org Git - lmno-server/blob - game.js
game/tictactoe: Expand player to include a team property
[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.next_player_id = 1;
48     this.teams = [];
49
50     /* Send a comment to every connected client every 15 seconds. */
51     setInterval(() => {this.broadcast_string(":");}, 15000);
52   }
53
54   /* Suport for game meta-data.
55    *
56    * What we want here is an effectively static field that is
57    * accessible through either the class name (SomeGame.meta) or an
58    * instance (some_game.meta). To pull this off we do keep two copies
59    * of the data. But the game classes can just set SomeGame.meta once
60    * and then reference it either way.
61    */
62   static set meta(data) {
63     /* This allows class access (SomeGame.meta) via the get method below. */
64     this._meta = data;
65
66     /* While this allows access via an instance (some_game.meta). */
67     this.prototype.meta = data;
68   }
69
70   static get meta() {
71     return this._meta;
72   }
73
74   add_player(session, connection) {
75     /* First see if we already have a player object for this session. */
76     const existing_index = this.players.findIndex(
77       player => player.session_id === session.id);
78     if (existing_index >= 0) {
79       const player = this.players[existing_index];
80       player.add_connection(connection);
81       return player;
82     }
83
84     /* No existing player. Add a new one. */
85     const id = this.next_player_id;
86     const player = new Player(id, session.id, session.nickname, connection);
87
88     /* Broadcast before adding player to list (to avoid announcing the
89      * new player to itself). */
90     const player_data = JSON.stringify({ id: player.id, name: player.name });
91     this.broadcast_event("player-enter", player_data);
92
93     this.players.push(player);
94     this.next_player_id++;
95
96     return player;
97   }
98
99   find_player(session) {
100     const existing_index = this.players.findIndex(
101       player => player.session_id === session.id);
102     if (existing_index >= 0)
103       return this.players[existing_index];
104
105     return null;
106   }
107
108   /* Drop a connection object from a player, and if it's the last one,
109    * then drop that player from the game's list of players. */
110   remove_player_connection(player, connection) {
111     const remaining = player.remove_connection(connection);
112     if (remaining === 0) {
113       const player_data = JSON.stringify({ id: player.id });
114       this.players.filter(p => p !== player);
115       this.broadcast_event("player-exit", player_data);
116     }
117   }
118
119   /* Send a string to all players */
120   broadcast_string(str) {
121     this.players.forEach(player => player.send(str + '\n'));
122   }
123
124   /* Send an event to all players.
125    *
126    * An event has both a declared type and a separate data block.
127    * It also ends with two newlines (to mark the end of the event).
128    */
129   broadcast_event(type, data) {
130     this.broadcast_string(`event: ${type}\ndata: ${data}\n`);
131   }
132
133   handle_events(request, response) {
134     /* These headers will keep the connection open so we can stream events. */
135     const headers = {
136       "Content-type": "text/event-stream",
137       "Connection": "keep-alive",
138       "Cache-Control": "no-cache"
139     };
140     response.writeHead(200, headers);
141
142     /* Add this new player. */
143     const player = this.add_player(request.session, response);
144
145     /* And queue up cleanup to be triggered on client close. */
146     request.on('close', () => {
147       this.remove_player_connection(player, response);
148     });
149
150     /* Give the client the game-info event. */
151     const game_info_json = JSON.stringify({
152       id: this.id,
153       url: `${request.protocol}://${request.hostname}/${this.id}`
154     });
155     response.write(`event: game-info\ndata: ${game_info_json}\n\n`);
156
157     /* And the player-info event. */
158     response.write(`event: player-info\ndata: ${player.info_json()}\n\n`);
159
160     /* Finally, if this game class has a "state" property, stream that
161      * current state to the client. */
162     if (this.state) {
163       const state_json = JSON.stringify(this.state);
164       response.write(`event: game-state\ndata: ${state_json}\n\n`);
165     }
166   }
167
168   handle_player(request, response) {
169     const player = this.find_player(request.session);
170     const name = request.body.name;
171     const team = request.body.team;
172     var updated = false;
173     if (! player) {
174       response.sendStatus(404);
175       return;
176     }
177
178     if (name && (player.name !== name)) {
179       player.name = name;
180
181       /* In addition to setting the name within this game's player
182        * object, also set the name in the session. */
183       request.session.nickname = name;
184       request.session.save();
185
186       updated = true;
187     }
188
189     if (team !== null && (player.team !== team) &&
190         (team === "" || this.teams.includes(team)))
191     {
192       player.team = team;
193
194       updated = true;
195     }
196
197     if (updated)
198       this.broadcast_event("player-update", player.info_json());
199
200     response.send("");
201   }
202
203   broadcast_move(move) {
204     this.broadcast_event("move", move);
205   }
206
207 }
208
209 module.exports = Game;