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