]> git.cworth.org Git - lmno-server/blob - empathy.js
Empathy: Change /judging endpoint to expect a top-level word_groups property
[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     const scores = [];
200
201     /* Perform a (non-strict) majority ruling on equivalencies,
202      * dropping all that didn't get enough votes. */
203     const quorum = Math.floor((this.players.length + 1)/2);
204     const agreed_equivalencies = Object.values(this.equivalencies).filter(
205       eq => eq.count >= quorum);
206
207     /* And with that agreed set of equivalencies, construct the full
208      * groups. */
209     const word_groups = {};
210
211     for (let e of agreed_equivalencies) {
212       let group = word_groups[e.words[0]];
213       if (! group)
214         group = word_groups[e.words[1]];
215       if (! group)
216         group = { words: [], players: new Set()};
217
218       if (! word_groups[e.words[0]]) {
219         word_groups[e.words[0]] = group;
220         group.words.push(e.words[0]);
221       }
222
223       if (! word_groups[e.words[1]]) {
224         word_groups[e.words[1]] = group;
225         group.words.push(e.words[1]);
226       }
227     }
228
229     /* Now go through answers from each player and add the player name
230      * to the set corresponding to each word group. */
231     for (let a of this.answers) {
232       for (let word of a.answers) {
233         /* If there's no group yet, this is a singleton word. */
234         if (word_groups[word]) {
235           word_groups[word].players.add(a.player.name);
236         } else {
237           const group = { words: [word], players: new Set() };
238           group.players.add(a.player.name);
239           word_groups[word] = group;
240         }
241       }
242     }
243
244     /* Finally, go through the answers one more time, this time taking
245      * the length of the player set as a score for the player's
246      * submission of that word. */
247     for (let a of this.answers) {
248       let score = 0;
249       for (let word of a.answers) {
250         score += word_groups[word].players.size;
251       }
252       scores.push({
253         player: a.player.name,
254         score: score
255       });
256     }
257
258     scores.sort((a,b) => {
259       return b.score - a.score;
260     });
261
262     /* Put the word groups into a form the client can consume.
263      *
264      * Most significantly, we only want one entry for each group (even
265      * though our current "word_groups" object has a property for each
266      * word considered equivalent).
267      */
268     const words_submitted = Object.entries(word_groups).filter(
269       group => group[0] === group[1].words[0]).map(
270         group => {
271           return {
272             word: group[1].words.join('/'),
273             players: Array.from(group[1].players)
274           };
275         }
276       );
277
278     words_submitted.sort((a,b) => {
279       return b.players.length - a.players.length;
280     });
281
282     /* Put this round's scores into the game state object so it will
283      * be sent to any new clients that join. */
284     this.state.scores = {
285       scores: scores,
286       words: words_submitted
287     };
288
289     /* And broadcast the scores to all connected clients. */
290     this.broadcast_event_object('scores', this.state.scores);
291   }
292 }
293
294 Empathy.router = express.Router();
295 const router = Empathy.router;
296
297 class Prompt {
298   constructor(id, items, prompt) {
299     this.id = id;
300     this.items = items;
301     this.prompt = prompt;
302     this.votes = [];
303   }
304
305   toggle_vote(player_name) {
306     if (this.votes.find(v => v === player_name))
307       this.votes = this.votes.filter(v => v !== player_name);
308     else
309       this.votes.push(player_name);
310   }
311 }
312
313 router.post('/prompts', (request, response) => {
314   const game = request.game;
315
316   prompt = game.add_prompt(request.body.items, request.body.prompt);
317
318   response.json({ id: prompt.id});
319 });
320
321 router.post('/vote/:prompt_id([0-9]+)', (request, response) => {
322   const game = request.game;
323   const prompt_id = parseInt(request.params.prompt_id, 10);
324
325   if (game.toggle_vote(prompt_id, request.session.id))
326     response.send('');
327   else
328     response.sendStatus(404);
329 });
330
331 router.post('/start/:prompt_id([0-9]+)', (request, response) => {
332   const game = request.game;
333   const prompt_id = parseInt(request.params.prompt_id, 10);
334
335   if (game.start(prompt_id))
336     response.send('');
337   else
338     response.sendStatus(404);
339 });
340
341 router.post('/answer/:prompt_id([0-9]+)', (request, response) => {
342   const game = request.game;
343   const prompt_id = parseInt(request.params.prompt_id, 10);
344
345   const result = game.receive_answer(prompt_id,
346                                      request.session.id,
347                                      request.body.answers);
348   response.json(result);
349
350   if (game.state.players_answered >= game.players.length)
351     game.perform_judging();
352 });
353
354 router.post('/judging/: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_judging(prompt_id,
359                                       request.session.id,
360                                       request.body.word_groups);
361   response.json(result);
362
363   if (game.state.players_judged >= game.players.length)
364     game.compute_scores();
365 });
366
367 router.post('/reset', (request, response) => {
368   const game = request.game;
369   game.reset();
370 });
371
372 Empathy.meta = {
373   name: "Empathy",
374   identifier: "empathy",
375 };
376
377 exports.Game = Empathy;