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