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