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