]> git.cworth.org Git - lmno-server/blob - empathy.js
Tweak game-ID generation toward more frequently used letters
[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   /* Returns true if vote toggled, false for player or prompt not found */
146   toggle_end_answers(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     if (this.state.end_answers.has(player.name)) {
154       this.state.end_answers.delete(player.name);
155       this.broadcast_event_object('unvote-end-answers', player.name);
156     } else {
157       this.state.end_answers.add(player.name);
158       this.broadcast_event_object('vote-end-answers', player.name);
159     }
160
161     return true;
162   }
163
164   perform_judging() {
165     const word_map = {};
166
167     for (let a of this.answers) {
168       for (let word of a.answers) {
169         const key = this.canonize(word);
170         word_map[key] = word;
171       }
172     }
173
174     this.state.ambiguities = Object.values(word_map).sort((a,b) => {
175       return a.toLowerCase().localeCompare(b.toLowerCase());
176     });
177
178     this.broadcast_event_object('ambiguities', this.state.ambiguities);
179   }
180
181   receive_judging(prompt_id, session_id, word_groups) {
182     const player = this.players_by_session[session_id];
183     if (! player)
184       return { valid: false, message: "Player not found" };
185
186     const prompt = this.state.prompts.find(p => p.id === prompt_id);
187     if (! prompt)
188       return { valid: false, message: "Prompt not found" };
189
190     if (prompt !== this.state.active_prompt)
191       return { valid: false, message: "Prompt no longer active" };
192
193     /* Each player submits some number of groups of answers that
194      * should be considered equivalent. The server expands that into
195      * the set of pair-wise equivalencies that are expressed. The
196      * reason for that is so that the server can determine which
197      * pair-wise equivalencies have majority support.
198      */
199     for (let group of word_groups) {
200
201       for (let i = 0; i < group.length - 1; i++) {
202         for (let j = i + 1; j < group.length; j++) {
203           let eq = [group[i], group[j]];
204
205           /* Put the two words into a reliable order so that we don't
206            * miss a pair of equivalent equivalencies just because they
207            * happen to be in the opposite order. */
208           if (eq[0].localeCompare(eq[1]) > 0) {
209             eq = [group[j], group[i]];
210           }
211
212           const key=`${this.canonize(eq[0])}:${this.canonize(eq[1])}`;
213
214           const exist = this.equivalencies[key];
215           if (exist) {
216             exist.count++;
217           } else {
218             this.equivalencies[key] = {
219               count: 1,
220               words: eq
221             };
222           }
223
224         }
225       }
226     }
227
228     /* And notify all players this this player has judged. */
229     this.state.players_judged.push(player.name);
230     this.broadcast_event_object('player-judged', player.name);
231
232     return { valid: true };
233   }
234
235   /* Returns true if vote toggled, false for player or prompt not found */
236   toggle_end_judging(prompt_id, session_id) {
237     const player = this.players_by_session[session_id];
238
239     const prompt = this.state.prompts.find(p => p.id === prompt_id);
240     if (! prompt || ! player)
241       return false;
242
243     if (this.state.end_judging.has(player.name)) {
244       this.state.end_judging.delete(player.name);
245       this.broadcast_event_object('unvote-end-judging', player.name);
246     } else {
247       this.state.end_judging.add(player.name);
248       this.broadcast_event_object('vote-end-judging', player.name);
249     }
250
251     return true;
252   }
253
254   canonize(word) {
255     return word.toLowerCase();
256   }
257
258   compute_scores() {
259     const word_submitters = {};
260
261     /* Perform a (non-strict) majority ruling on equivalencies,
262      * dropping all that didn't get enough votes. */
263     const quorum = Math.floor((this.players.length + 1)/2);
264     const agreed_equivalencies = Object.values(this.equivalencies).filter(
265       eq => eq.count >= quorum);
266
267     /* And with that agreed set of equivalencies, construct the full
268      * groups. */
269     const word_maps = {};
270
271     for (let e of agreed_equivalencies) {
272       const word0_canon = this.canonize(e.words[0]);
273       const word1_canon = this.canonize(e.words[1]);
274       let group = word_maps[word0_canon];
275       if (! group)
276         group = word_maps[word1_canon];
277       if (! group)
278         group = { words: [], players: new Set()};
279
280       if (! word_maps[word0_canon]) {
281         word_maps[word0_canon] = group;
282         group.words.push(e.words[0]);
283       }
284
285       if (! word_maps[word1_canon]) {
286         word_maps[word1_canon] = group;
287         group.words.push(e.words[1]);
288       }
289     }
290
291     /* Now go through answers from each player and add the player
292      * to the set corresponding to each word group. */
293     for (let a of this.answers) {
294       for (let word of a.answers) {
295         const word_canon = this.canonize(word);
296         /* If there's no group yet, this is a singleton word. */
297         if (word_maps[word_canon]) {
298           word_maps[word_canon].players.add(a.player);
299         } else {
300           const group = { words: [word], players: new Set() };
301           group.players.add(a.player);
302           word_maps[word_canon] = group;
303         }
304       }
305     }
306
307     /* Now that we've assigned the players to these word maps, we now
308      * want to collapse the groups down to a single array of
309      * word_groups.
310      *
311      * The difference between "word_maps" and "word_groups" is that
312      * the former has a property for every word that maps to a group,
313      * (so if you iterate over the keys you will see the same group
314      * multiple times). In contrast, iterating over"word_groups" will
315      * have you visit each group only once. */
316     const word_groups = Object.entries(word_maps).filter(
317       entry => entry[0] === this.canonize(entry[1].words[0]))
318           .map(entry => entry[1]);
319
320     /* Now, go through each word group and assign the scores out to
321      * the corresponding players.
322      *
323      * Note: We do this by going through the word groups, (as opposed
324      * to the list of words from the players again), specifically to
325      * avoid giving a player points for a wrod group twice (in the
326      * case where a player submits two different words that the group
327      * ends up judging as equivalent).
328      */
329     this.players.forEach(p => p.round_score = 0);
330     for (let group of word_groups) {
331       group.players.forEach(p => p.round_score += group.players.size);
332     }
333
334     const scores = this.players.map(p => {
335       return {
336         player: p.name,
337         score: p.round_score
338       };
339     });
340
341     scores.sort((a,b) => {
342       return b.score - a.score;
343     });
344
345     /* Put the word groups into a form the client can consume.
346      */
347     const words_submitted = word_groups.map(
348       group => {
349         return {
350           word: group.words.join('/'),
351           players: Array.from(group.players).map(p => p.name)
352         };
353       }
354     );
355
356     words_submitted.sort((a,b) => {
357       return b.players.length - a.players.length;
358     });
359
360     /* Put this round's scores into the game state object so it will
361      * be sent to any new clients that join. */
362     this.state.scores = {
363       scores: scores,
364       words: words_submitted
365     };
366
367     /* And broadcast the scores to all connected clients. */
368     this.broadcast_event_object('scores', this.state.scores);
369   }
370 }
371
372 Empathy.router = express.Router();
373 const router = Empathy.router;
374
375 class Prompt {
376   constructor(id, items, prompt) {
377     this.id = id;
378     this.items = items;
379     this.prompt = prompt;
380     this.votes = [];
381   }
382
383   toggle_vote(player_name) {
384     if (this.votes.find(v => v === player_name))
385       this.votes = this.votes.filter(v => v !== player_name);
386     else
387       this.votes.push(player_name);
388   }
389 }
390
391 router.post('/prompts', (request, response) => {
392   const game = request.game;
393
394   const result = game.add_prompt(request.body.items, request.body.prompt);
395
396   response.json(result);
397 });
398
399 router.post('/vote/:prompt_id([0-9]+)', (request, response) => {
400   const game = request.game;
401   const prompt_id = parseInt(request.params.prompt_id, 10);
402
403   if (game.toggle_vote(prompt_id, request.session.id))
404     response.send('');
405   else
406     response.sendStatus(404);
407 });
408
409 router.post('/start/:prompt_id([0-9]+)', (request, response) => {
410   const game = request.game;
411   const prompt_id = parseInt(request.params.prompt_id, 10);
412
413   if (game.start(prompt_id))
414     response.send('');
415   else
416     response.sendStatus(404);
417 });
418
419 router.post('/answer/:prompt_id([0-9]+)', (request, response) => {
420   const game = request.game;
421   const prompt_id = parseInt(request.params.prompt_id, 10);
422
423   const result = game.receive_answer(prompt_id,
424                                      request.session.id,
425                                      request.body.answers);
426   response.json(result);
427 });
428
429 router.post('/end-answers/:prompt_id([0-9]+)', (request, response) => {
430   const game = request.game;
431   const prompt_id = parseInt(request.params.prompt_id, 10);
432
433   if (game.toggle_end_answers(prompt_id, request.session.id))
434     response.send('');
435   else
436     response.sendStatus(404);
437
438   if (game.state.end_answers.size > (game.state.players_answered.length / 2))
439     game.perform_judging();
440 });
441
442 router.post('/judging/:prompt_id([0-9]+)', (request, response) => {
443   const game = request.game;
444   const prompt_id = parseInt(request.params.prompt_id, 10);
445
446   const result = game.receive_judging(prompt_id,
447                                       request.session.id,
448                                       request.body.word_groups);
449   response.json(result);
450 });
451
452 router.post('/end-judging/:prompt_id([0-9]+)', (request, response) => {
453   const game = request.game;
454   const prompt_id = parseInt(request.params.prompt_id, 10);
455
456   if (game.toggle_end_judging(prompt_id, request.session.id))
457     response.send('');
458   else
459     response.sendStatus(404);
460
461   if (game.state.end_judging.size > (game.state.players_judged.length / 2))
462     game.compute_scores();
463 });
464
465 router.post('/reset', (request, response) => {
466   const game = request.game;
467   game.reset();
468 });
469
470 Empathy.meta = {
471   name: "Empathy",
472   identifier: "empathy",
473 };
474
475 exports.Game = Empathy;