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