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