]> git.cworth.org Git - empires-server/blob - empathy.js
test: Maintain a list of activated players for automated cleanup
[empires-server] / empathy.js
1 const express = require("express");
2 const Game = require("./game.js");
3
4 const MAX_PROMPT_ITEMS = 20;
5
6 /* This time parameter specifies a time period in which a phase will
7  * not be considered idle in any circumstance. That is, this should be
8  * a reasonable time in which any "active" player should have at least
9  * started interacting with the current phase.
10  *
11  * Specified in seconds
12  */
13 const PHASE_MINIMUM_TIME = 30;
14
15 /* This parameter gives the amount of time that the game will wait to
16  * see activity from pending players. If this amount of time passes
17  * with no activity from any of them, the server will emit an "idle"
18  * event which will let clients issue a vote to end the current phase.
19  *
20  * Specified in seconds
21  */
22 const PHASE_IDLE_TIMEOUT = 30;
23
24 class Empathy extends Game {
25   constructor(id) {
26     super(id);
27     this.state = {
28       prompts: [],
29       active_prompt: null,
30       players_answered: [],
31       players_answering: new Set(),
32       answering_idle: false,
33       end_answers: new Set(),
34       ambiguities: null,
35       players_judged: [],
36       players_judging: new Set(),
37       judging_idle: false,
38       end_judging: new Set(),
39       scores: null
40     };
41     this.answers = [];
42     this.answering_idle_timer = 0;
43     this.answering_start_time_ms = 0;
44     this.judging_idle_timer = 0;
45     this.judging_start_time_ms = 0;
46     this.next_prompt_id = 1;
47     this.equivalencies = {};
48   }
49
50   reset() {
51
52     /* Before closing out the current round, we accumulate the score
53      * for each player into their runnning total. */
54     for (let score of this.state.scores.scores) {
55       const player = this.players.find(p => p.name === score.player);
56       if (player.score)
57         player.score += score.score;
58       else
59         player.score = score.score;
60
61       /* And broadcast that new score out. */
62       this.broadcast_event('player-update', player.info_json());
63     }
64
65     /* Now that we're done with the active prompt, we remove it from
66      * the list of prompts and also remove any prompts that received
67      * no votes. This keeps the list of prompts clean.
68      */
69     const active_id = this.state.active_prompt.id;
70     this.state.prompts =
71       this.state.prompts.filter(
72         p => p.id !== active_id && p.votes.length > 0
73       );
74
75     this.state.active_prompt = null;
76     this.state.players_answered = [];
77     this.state.players_answering = new Set();
78     this.state.answering_idle = false;
79     this.state.end_answers = new Set();
80     this.state.ambiguities = null;
81     this.state.players_judged = [];
82     this.state.players_judging = new Set();
83     this.state.judging_idle = false;
84     this.state.end_judging = new Set();
85     this.state.scores = null;
86
87     this.answers = [];
88     if (this.answering_idle_timer) {
89       clearTimeout(this.answering_idle_timer);
90     }
91     this.answering_idle_timer = 0;
92     this.answering_start_time_ms = 0;
93     if (this.judging_idle_timer) {
94       clearTimeout(this.judging_idle_timer);
95     }
96     this.judging_idle_timer = 0;
97     this.judging_start_time_ms = 0;
98     this.equivalencies = {};
99
100     this.broadcast_event_object('game-state', this.state);
101   }
102
103   add_prompt(items, prompt_string) {
104     if (items > MAX_PROMPT_ITEMS)
105       return {
106         valid: false,
107         message: `Maximum number of items is ${MAX_PROMPT_ITEMS}`
108       };
109
110     const prompt = new Prompt(this.next_prompt_id, items, prompt_string);
111     this.next_prompt_id++;
112
113     this.state.prompts.push(prompt);
114
115     this.broadcast_event_object('prompt', prompt);
116
117     return {
118       valid: true,
119       id: prompt.id
120     };
121   }
122
123   /* Returns true if vote toggled, false for player or prompt not found */
124   toggle_vote(prompt_id, session_id) {
125     const player = this.players_by_session[session_id];
126
127     const prompt = this.state.prompts.find(p => p.id === prompt_id);
128     if (! prompt || ! player)
129       return false;
130
131     prompt.toggle_vote(player.name);
132
133     this.broadcast_event_object('prompt', prompt);
134
135     return true;
136   }
137
138   /* Returns true on success, false for prompt not found. */
139   start(prompt_id) {
140     const prompt = this.state.prompts.find(p => p.id === prompt_id);
141     if (! prompt)
142       return false;
143
144     /* Ignore any start request that comes in while a prompt is
145      * already being played. */
146     if (this.state.active_prompt)
147       return false;
148
149     this.state.active_prompt = prompt;
150
151     this.broadcast_event_object('start', prompt);
152
153     return true;
154   }
155
156   receive_answer(prompt_id, session_id, answers) {
157     const player = this.players_by_session[session_id];
158     if (! player)
159       return { valid: false, message: "Player not found" };
160
161     const prompt = this.state.prompts.find(p => p.id === prompt_id);
162     if (! prompt)
163       return { valid: false, message: "Prompt not found" };
164
165     if (prompt !== this.state.active_prompt)
166       return { valid: false, message: "Prompt no longer active" };
167
168     /* Save the complete answers for our own use later. */
169     this.answers.push({
170       player: player,
171       answers: answers
172     });
173
174     /* Update state (to be sent out to any future clients) */
175     this.state.players_answering.delete(player.name);
176     this.state.players_answered.push(player.name);
177
178     /* And notify all players that this player has answered. */
179     this.broadcast_event_object('player-answered', player.name);
180
181     /* If no players are left in the answering list then we don't need
182      * to wait for the answering_idle_timer to fire, because a person
183      * who isn't there obviously can't be typing. So we can broadcast
184      * the idle event right away. Note that we do this only if the
185      * answering phase has been going for a while to avoid the case of
186      * the first player being super fast and emptying the
187      * players_answering list before anyone else even got in.
188      */
189     if (this.state.players_answering.size === 0 &&
190         ((Date.now() - this.answering_start_time_ms) / 1000) > PHASE_MINIMUM_TIME)
191     {
192       this.broadcast_event_object('answering-idle', true);
193     }
194
195     return { valid: true };
196   }
197
198   receive_answering(prompt_id, session_id) {
199     const player = this.players_by_session[session_id];
200     if (! player)
201       return { valid: false, message: "Player not found" };
202
203     const prompt = this.state.prompts.find(p => p.id === prompt_id);
204     if (! prompt)
205       return { valid: false, message: "Prompt not found" };
206
207     if (prompt !== this.state.active_prompt)
208       return { valid: false, message: "Prompt no longer active" };
209
210     if (this.answering_idle_timer) {
211       clearTimeout(this.answering_idle_timer);
212       this.ansering_idle_timer = 0;
213     }
214     if (! this.state.answering_idle) {
215       this.answering_idle_timer = setTimeout(() => {
216         this.state.answering_idle = true;
217         this.broadcast_event_object('answering-idle', true);
218       }, PHASE_IDLE_TIMEOUT * 1000);
219     }
220
221     if (this.answering_start_time_ms === 0)
222       this.answering_start_time_ms = Date.now();
223
224     /* Notify all players that this player is actively answering. */
225     this.state.players_answering.add(player.name);
226     this.broadcast_event_object('player-answering', player.name);
227
228     return { valid: true };
229   }
230
231   /* Returns true if vote toggled, false for player or prompt not found */
232   toggle_end_answers(prompt_id, session_id) {
233     const player = this.players_by_session[session_id];
234
235     const prompt = this.state.prompts.find(p => p.id === prompt_id);
236     if (! prompt || ! player)
237       return false;
238
239     if (this.state.end_answers.has(player.name)) {
240       this.state.end_answers.delete(player.name);
241       this.broadcast_event_object('unvote-end-answers', player.name);
242     } else {
243       this.state.end_answers.add(player.name);
244       this.broadcast_event_object('vote-end-answers', player.name);
245     }
246
247     return true;
248   }
249
250   perform_judging() {
251     const word_map = {};
252
253     for (let a of this.answers) {
254       for (let word of a.answers) {
255         const key = this.canonize(word);
256         word_map[key] = word;
257       }
258     }
259
260     this.state.ambiguities = Object.values(word_map).sort((a,b) => {
261       return a.toLowerCase().localeCompare(b.toLowerCase());
262     });
263
264     if (this.judging_start_time_ms === 0) {
265       this.judging_start_time_ms = Date.now();
266     }
267
268     this.broadcast_event_object('ambiguities', this.state.ambiguities);
269
270     /* Notify all players of every player that is judging. */
271     for (let player_name of this.state.players_answered) {
272       this.state.players_judging.add(player_name);
273       this.broadcast_event_object('player-judging', player_name);
274     }
275   }
276
277   reset_judging_timeout() {
278     if (this.judging_idle_timer) {
279       clearTimeout(this.judging_idle_timer);
280       this.judging_idle_timer = 0;
281     }
282     if (! this.state.judging_idle) {
283       this.judging_idle_timer = setTimeout(() => {
284         this.state.judging_idle = true;
285         this.broadcast_event_object('judging-idle', true);
286       }, PHASE_IDLE_TIMEOUT * 1000);
287     }
288   }
289
290   receive_judged(prompt_id, session_id, word_groups) {
291     const player = this.players_by_session[session_id];
292     if (! player)
293       return { valid: false, message: "Player not found" };
294
295     const prompt = this.state.prompts.find(p => p.id === prompt_id);
296     if (! prompt)
297       return { valid: false, message: "Prompt not found" };
298
299     if (prompt !== this.state.active_prompt)
300       return { valid: false, message: "Prompt no longer active" };
301
302     this.reset_judging_timeout();
303
304     /* Each player submits some number of groups of answers that
305      * should be considered equivalent. The server expands that into
306      * the set of pair-wise equivalencies that are expressed. The
307      * reason for that is so that the server can determine which
308      * pair-wise equivalencies have majority support.
309      */
310     for (let group of word_groups) {
311
312       for (let i = 0; i < group.length - 1; i++) {
313         for (let j = i + 1; j < group.length; j++) {
314           let eq = [group[i], group[j]];
315
316           /* Put the two words into a reliable order so that we don't
317            * miss a pair of equivalent equivalencies just because they
318            * happen to be in the opposite order. */
319           if (eq[0].localeCompare(eq[1]) > 0) {
320             eq = [group[j], group[i]];
321           }
322
323           const key=`${this.canonize(eq[0])}:${this.canonize(eq[1])}`;
324
325           const exist = this.equivalencies[key];
326           if (exist) {
327             exist.count++;
328           } else {
329             this.equivalencies[key] = {
330               count: 1,
331               words: eq
332             };
333           }
334
335         }
336       }
337     }
338
339     /* Update state (to be sent out to any future clients) */
340     this.state.players_judging.delete(player.name);
341     this.state.players_judged.push(player.name);
342
343     /* And notify all players that this player has judged. */
344     this.broadcast_event_object('player-judged', player.name);
345
346     /* If no players are left in the judging list then we don't need
347      * to wait for the judging_idle_timer to fire, because a person
348      * who isn't there obviously can't be judging. So we can broadcast
349      * the idle event right away. Note that we do this only if the
350      * judging phase has been going for a while to avoid the case of
351      * the first player being super fast and emptying the
352      * players_judging list before anyone else even got in.
353      */
354     if (this.state.players_judging.size === 0 &&
355         ((Date.now() - this.judging_start_time_ms) / 1000) > PHASE_MINIMUM_TIME)
356     {
357       this.broadcast_event_object('judging-idle', true);
358     }
359
360     return { valid: true };
361   }
362
363   receive_judging(prompt_id, session_id) {
364
365     const player = this.players_by_session[session_id];
366     if (! player)
367       return { valid: false, message: "Player not found" };
368
369     const prompt = this.state.prompts.find(p => p.id === prompt_id);
370     if (! prompt)
371       return { valid: false, message: "Prompt not found" };
372
373     if (prompt !== this.state.active_prompt)
374       return { valid: false, message: "Prompt no longer active" };
375
376     this.reset_judging_timeout();
377
378     /* Notify all players that this player is actively judging. */
379     this.state.players_judging.add(player.name);
380     this.broadcast_event_object('player-judging', player.name);
381
382     return { valid: true };
383   }
384
385   /* Returns true if vote toggled, false for player or prompt not found */
386   toggle_end_judging(prompt_id, session_id) {
387     const player = this.players_by_session[session_id];
388
389     const prompt = this.state.prompts.find(p => p.id === prompt_id);
390     if (! prompt || ! player)
391       return false;
392
393     if (this.state.end_judging.has(player.name)) {
394       this.state.end_judging.delete(player.name);
395       this.broadcast_event_object('unvote-end-judging', player.name);
396     } else {
397       this.state.end_judging.add(player.name);
398       this.broadcast_event_object('vote-end-judging', player.name);
399     }
400
401     return true;
402   }
403
404   canonize(word) {
405     return word.trim().toLowerCase();
406   }
407
408   compute_scores() {
409     const word_submitters = {};
410
411     /* Perform a (non-strict) majority ruling on equivalencies,
412      * dropping all that didn't get enough votes. */
413     const quorum = Math.floor((this.state.players_judged.length + 1)/2);
414     const agreed_equivalencies = Object.values(this.equivalencies).filter(
415       eq => eq.count >= quorum);
416
417     /* And with that agreed set of equivalencies, construct the full
418      * groups. */
419     const word_maps = {};
420
421     for (let e of agreed_equivalencies) {
422       const word0_canon = this.canonize(e.words[0]);
423       const word1_canon = this.canonize(e.words[1]);
424       let group = word_maps[word0_canon];
425       if (! group)
426         group = word_maps[word1_canon];
427       if (! group)
428         group = { words: [], players: new Set()};
429
430       if (! word_maps[word0_canon]) {
431         word_maps[word0_canon] = group;
432         group.words.push(e.words[0]);
433       }
434
435       if (! word_maps[word1_canon]) {
436         word_maps[word1_canon] = group;
437         group.words.push(e.words[1]);
438       }
439     }
440
441     /* Now go through answers from each player and add the player
442      * to the set corresponding to each word group. */
443     for (let a of this.answers) {
444       for (let word of a.answers) {
445         const word_canon = this.canonize(word);
446         /* If there's no group yet, this is a singleton word. */
447         if (word_maps[word_canon]) {
448           word_maps[word_canon].players.add(a.player);
449         } else {
450           const group = { words: [word], players: new Set() };
451           group.players.add(a.player);
452           word_maps[word_canon] = group;
453         }
454       }
455     }
456
457     /* Now that we've assigned the players to these word maps, we now
458      * want to collapse the groups down to a single array of
459      * word_groups.
460      *
461      * The difference between "word_maps" and "word_groups" is that
462      * the former has a property for every word that maps to a group,
463      * (so if you iterate over the keys you will see the same group
464      * multiple times). In contrast, iterating over"word_groups" will
465      * have you visit each group only once. */
466     const word_groups = Object.entries(word_maps).filter(
467       entry => entry[0] === this.canonize(entry[1].words[0]))
468           .map(entry => entry[1]);
469
470     /* Now, go through each word group and assign the scores out to
471      * the corresponding players.
472      *
473      * Note: We do this by going through the word groups, (as opposed
474      * to the list of words from the players again), specifically to
475      * avoid giving a player points for a wrod group twice (in the
476      * case where a player submits two different words that the group
477      * ends up judging as equivalent).
478      */
479     this.players.forEach(p => p.round_score = 0);
480     for (let group of word_groups) {
481       group.players.forEach(p => p.round_score += group.players.size);
482     }
483
484     const scores = this.players.filter(p => p.active).map(p => {
485       return {
486         player: p.name,
487         score: p.round_score
488       };
489     });
490
491     scores.sort((a,b) => {
492       return b.score - a.score;
493     });
494
495     /* Put the word groups into a form the client can consume.
496      */
497     const words_submitted = word_groups.map(
498       group => {
499         return {
500           word: group.words.join('/'),
501           players: Array.from(group.players).map(p => p.name)
502         };
503       }
504     );
505
506     words_submitted.sort((a,b) => {
507       return b.players.length - a.players.length;
508     });
509
510     /* Put this round's scores into the game state object so it will
511      * be sent to any new clients that join. */
512     this.state.scores = {
513       scores: scores,
514       words: words_submitted
515     };
516
517     /* And broadcast the scores to all connected clients. */
518     this.broadcast_event_object('scores', this.state.scores);
519   }
520 }
521
522 Empathy.router = express.Router();
523 const router = Empathy.router;
524
525 class Prompt {
526   constructor(id, items, prompt) {
527     this.id = id;
528     this.items = items;
529     this.prompt = prompt;
530     this.votes = [];
531   }
532
533   toggle_vote(player_name) {
534     if (this.votes.find(v => v === player_name))
535       this.votes = this.votes.filter(v => v !== player_name);
536     else
537       this.votes.push(player_name);
538   }
539 }
540
541 router.post('/prompts', (request, response) => {
542   const game = request.game;
543
544   const result = game.add_prompt(request.body.items, request.body.prompt);
545
546   response.json(result);
547 });
548
549 router.post('/vote/:prompt_id([0-9]+)', (request, response) => {
550   const game = request.game;
551   const prompt_id = parseInt(request.params.prompt_id, 10);
552
553   if (game.toggle_vote(prompt_id, request.session.id))
554     response.send('');
555   else
556     response.sendStatus(404);
557 });
558
559 router.post('/start/:prompt_id([0-9]+)', (request, response) => {
560   const game = request.game;
561   const prompt_id = parseInt(request.params.prompt_id, 10);
562
563   if (game.start(prompt_id))
564     response.send('');
565   else
566     response.sendStatus(404);
567 });
568
569 router.post('/answer/:prompt_id([0-9]+)', (request, response) => {
570   const game = request.game;
571   const prompt_id = parseInt(request.params.prompt_id, 10);
572
573   const result = game.receive_answer(prompt_id,
574                                      request.session.id,
575                                      request.body.answers);
576   response.json(result);
577
578   /* If every registered player has answered, then there's no need to
579    * wait for anything else. */
580   if (game.state.players_answered.length >= game.players.length)
581     game.perform_judging();
582 });
583
584 router.post('/answering/:prompt_id([0-9]+)', (request, response) => {
585   const game = request.game;
586   const prompt_id = parseInt(request.params.prompt_id, 10);
587
588   const result = game.receive_answering(prompt_id,
589                                         request.session.id);
590   response.json(result);
591 });
592
593 router.post('/end-answers/:prompt_id([0-9]+)', (request, response) => {
594   const game = request.game;
595   const prompt_id = parseInt(request.params.prompt_id, 10);
596
597   if (game.toggle_end_answers(prompt_id, request.session.id))
598     response.send('');
599   else
600     response.sendStatus(404);
601
602   /* The majority rule here includes all players that have answered as
603    * well as all that have started typing. */
604   const players_involved = (game.state.players_answered.length +
605                             game.state.players_answering.size);
606
607   if (game.state.end_answers.size > players_involved / 2)
608     game.perform_judging();
609 });
610
611 router.post('/judged/:prompt_id([0-9]+)', (request, response) => {
612   const game = request.game;
613   const prompt_id = parseInt(request.params.prompt_id, 10);
614
615   const result = game.receive_judged(prompt_id,
616                                      request.session.id,
617                                      request.body.word_groups);
618   response.json(result);
619
620   /* If every player who answered has also judged, then there's no
621    * need to wait for anything else. */
622   if (game.state.players_judged.length >= game.state.players_answered.length)
623     game.compute_scores();
624 });
625
626 router.post('/judging/:prompt_id([0-9]+)', (request, response) => {
627   const game = request.game;
628   const prompt_id = parseInt(request.params.prompt_id, 10);
629
630   const result = game.receive_judging(prompt_id,
631                                       request.session.id);
632   response.json(result);
633 });
634
635 router.post('/end-judging/:prompt_id([0-9]+)', (request, response) => {
636   const game = request.game;
637   const prompt_id = parseInt(request.params.prompt_id, 10);
638
639   if (game.toggle_end_judging(prompt_id, request.session.id))
640     response.send('');
641   else
642     response.sendStatus(404);
643
644   if (game.state.end_judging.size > (game.state.players_answered.length / 2))
645     game.compute_scores();
646 });
647
648 router.post('/reset', (request, response) => {
649   const game = request.game;
650   game.reset();
651
652   response.send('');
653 });
654
655 Empathy.meta = {
656   name: "Empathy",
657   identifier: "empathy",
658 };
659
660 exports.Game = Empathy;