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