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