]> git.cworth.org Git - lmno.games/blob - empathy/empathy.jsx
4ef6807fef559834be1fed0be1a6f9cb2f2ed889
[lmno.games] / empathy / empathy.jsx
1 const MAX_PROMPT_ITEMS = 20;
2
3 function undisplay(element) {
4   element.style.display="none";
5 }
6
7 function add_message(severity, message) {
8   message = `<div class="message ${severity}" onclick="undisplay(this)">
9 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
10 ${message}
11 </div>`;
12   const message_area = document.getElementById('message-area');
13   message_area.insertAdjacentHTML('beforeend', message);
14 }
15
16 /*********************************************************
17  * Handling server-sent event stream                     *
18  *********************************************************/
19
20 const events = new EventSource("events");
21
22 events.onerror = function(event) {
23   if (event.target.readyState === EventSource.CLOSED) {
24     setTimeout(() => {
25       add_message("danger", "Connection to server lost.");
26     }, 1000);
27   }
28 };
29
30 events.addEventListener("game-info", event => {
31   const info = JSON.parse(event.data);
32
33   window.game.set_game_info(info);
34 });
35
36 events.addEventListener("player-info", event => {
37   const info = JSON.parse(event.data);
38
39   window.game.set_player_info(info);
40 });
41
42 events.addEventListener("player-enter", event => {
43   const info = JSON.parse(event.data);
44
45   window.game.set_other_player_info(info);
46 });
47
48 events.addEventListener("player-exit", event => {
49   const info = JSON.parse(event.data);
50
51   window.game.remove_player(info);
52 });
53
54 events.addEventListener("player-update", event => {
55   const info = JSON.parse(event.data);
56
57   if (info.id === window.game.state.player_info.id)
58     window.game.set_player_info(info);
59   else
60     window.game.set_other_player_info(info);
61 });
62
63 events.addEventListener("game-state", event => {
64   const state = JSON.parse(event.data);
65
66   window.game.reset_game_state();
67
68   window.game.set_prompts(state.prompts);
69
70   window.game.set_active_prompt(state.active_prompt);
71
72   window.game.set_players_answered(state.players_answered);
73
74   window.game.set_players_answering(state.players_answering);
75
76   window.game.set_answering_idle(state.answering_idle);
77
78   window.game.set_end_answers(state.end_answers);
79
80   window.game.set_ambiguities(state.ambiguities);
81
82   window.game.set_players_judged(state.players_judged);
83
84   window.game.set_players_judging(state.players_judging);
85
86   window.game.set_judging_idle(state.judging_idle);
87
88   window.game.set_end_judging(state.end_judging);
89
90   window.game.set_scores(state.scores);
91
92   window.game.set_new_game_votes(state.new_game_votes);
93
94   window.game.state_ready();
95 });
96
97 events.addEventListener("prompt", event => {
98   const prompt = JSON.parse(event.data);
99
100   window.game.add_or_update_prompt(prompt);
101 });
102
103 events.addEventListener("start", event => {
104   const prompt = JSON.parse(event.data);
105
106   window.game.set_active_prompt(prompt);
107 });
108
109 events.addEventListener("player-answered", event => {
110   const player = JSON.parse(event.data);
111
112   window.game.set_player_answered(player);
113 });
114
115 events.addEventListener("player-answering", event => {
116   const player = JSON.parse(event.data);
117
118   window.game.set_player_answering(player);
119 });
120
121 events.addEventListener("answering-idle", event => {
122   const value = JSON.parse(event.data);
123
124   window.game.set_answering_idle(value);
125 });
126
127 events.addEventListener("vote-end-answers", event => {
128   const player = JSON.parse(event.data);
129
130   window.game.set_player_vote_end_answers(player);
131 });
132
133 events.addEventListener("unvote-end-answers", event => {
134   const player = JSON.parse(event.data);
135
136   window.game.set_player_unvote_end_answers(player);
137 });
138
139 events.addEventListener("ambiguities", event => {
140   const ambiguities = JSON.parse(event.data);
141
142   window.game.set_ambiguities(ambiguities);
143 });
144
145 events.addEventListener("player-judged", event => {
146   const player = JSON.parse(event.data);
147
148   window.game.set_player_judged(player);
149 });
150
151 events.addEventListener("player-judging", event => {
152   const player = JSON.parse(event.data);
153
154   window.game.set_player_judging(player);
155 });
156
157 events.addEventListener("judging-idle", event => {
158   const value = JSON.parse(event.data);
159
160   window.game.set_judging_idle(value);
161 });
162
163 events.addEventListener("vote-end-judging", event => {
164   const player = JSON.parse(event.data);
165
166   window.game.set_player_vote_end_judging(player);
167 });
168
169 events.addEventListener("unvote-end-judging", event => {
170   const player = JSON.parse(event.data);
171
172   window.game.set_player_unvote_end_judging(player);
173 });
174
175 events.addEventListener("scores", event => {
176   const scores = JSON.parse(event.data);
177
178   window.game.set_scores(scores);
179 });
180
181 events.addEventListener("vote-new-game", event => {
182   const player = JSON.parse(event.data);
183
184   window.game.set_player_vote_new_game(player);
185 });
186
187 events.addEventListener("unvote-new-game", event => {
188   const player = JSON.parse(event.data);
189
190   window.game.set_player_unvote_new_game(player);
191 });
192
193 /*********************************************************
194  * Game and supporting classes                           *
195  *********************************************************/
196
197 function copy_to_clipboard(id)
198 {
199   const tmp = document.createElement("input");
200   tmp.setAttribute("value", document.getElementById(id).innerHTML);
201   document.body.appendChild(tmp);
202   tmp.select();
203   document.execCommand("copy");
204   document.body.removeChild(tmp);
205 }
206
207 const GameInfo = React.memo(props => {
208   if (! props.id)
209     return null;
210
211   return (
212     <div className="game-info">
213       <span className="game-id">{props.id}</span>
214       {" "}
215       Share this link to invite friends:{" "}
216       <span id="game-share-url">{props.url}</span>
217       {" "}
218       <button
219         className="inline"
220         onClick={() => copy_to_clipboard('game-share-url')}
221       >Copy Link</button>
222     </div>
223   );
224 });
225
226 const PlayerInfo = React.memo(props => {
227   if (! props.player.id)
228     return null;
229
230   const all_players = [props.player, ...props.other_players];
231
232   const sorted_players = all_players.sort((a,b) => {
233     return b.score - a.score;
234   });
235
236   const names_and_scores = sorted_players.map(player => {
237     if (player.score)
238       return `${player.name} (${player.score})`;
239     else
240       return player.name;
241   }).join(', ');
242
243   return (
244     <div className="player-info">
245       <span className="players-header">Players: </span>
246       <span>{names_and_scores}</span>
247     </div>
248   );
249 });
250
251 function fetch_method_json(method, api = '', data = {}) {
252   const response = fetch(api, {
253     method: method,
254     headers: {
255       'Content-Type': 'application/json'
256     },
257     body: JSON.stringify(data)
258   });
259   return response;
260 }
261
262 function fetch_post_json(api = '', data = {}) {
263   return fetch_method_json('POST', api, data);
264 }
265
266 async function fetch_put_json(api = '', data = {}) {
267   return fetch_method_json('PUT', api, data);
268 }
269
270 class CategoryRequest extends React.PureComponent {
271   constructor(props) {
272     super(props);
273     this.category = React.createRef();
274
275     this.handle_change = this.handle_change.bind(this);
276     this.handle_submit = this.handle_submit.bind(this);
277   }
278
279   handle_change(event) {
280     const category_input = this.category.current;
281     const category = category_input.value;
282
283     const match = category.match(/[0-9]+/);
284     if (match) {
285       const num_items = parseInt(match[0], 10);
286       if (num_items > 0 && num_items <= MAX_PROMPT_ITEMS)
287         category_input.setCustomValidity("");
288     }
289   }
290
291   async handle_submit(event) {
292     const form = event.currentTarget;
293     const category_input = this.category.current;
294     const category = category_input.value;
295
296     /* Prevent the default page-changing form-submission behavior. */
297     event.preventDefault();
298
299     const match = category.match(/[0-9]+/);
300     if (match === null) {
301       category_input.setCustomValidity("Category must include a number");
302       form.reportValidity();
303       return;
304     }
305
306     const num_items = parseInt(match[0], 10);
307
308     if (num_items > MAX_PROMPT_ITEMS) {
309       category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`);
310       form.reportValidity();
311       return;
312     }
313
314     if (num_items < 1) {
315       category_input.setCustomValidity("Category must require at least one item.");
316       form.reportValidity();
317       return;
318     }
319
320     const response = await fetch_post_json("prompts", {
321       items: num_items,
322       prompt: category
323     });
324
325     if (response.status === 200) {
326       const result = await response.json();
327       if (! result.valid) {
328         add_message("danger", result.message);
329         return;
330       }
331     } else {
332       add_message("danger", "An error occurred submitting your category");
333     }
334
335     form.reset();
336   }
337
338   render() {
339     return (
340       <div className="category-request">
341         <h2>Submit a Category</h2>
342         <p>
343           Suggest a category to play. Don't forget to include the
344           number of items for each person to submit.
345         </p>
346
347         <form onSubmit={this.handle_submit} >
348           <div className="form-field large">
349             <input
350               type="text"
351               id="category"
352               placeholder="6 things at the beach"
353               required
354               autoComplete="off"
355               onChange={this.handle_change}
356               ref={this.category}
357             />
358           </div>
359
360           <div className="form-field large">
361             <button type="submit">
362               Send
363             </button>
364           </div>
365
366         </form>
367       </div>
368     );
369   }
370 }
371
372 const PromptOption = React.memo(props => {
373
374   const prompt = props.prompt;
375
376   if (prompt.votes_against.find(v => v === props.player.name))
377     return false;
378
379   return (
380     <button
381       className="vote-button"
382       key={prompt.id}
383       onClick={() => fetch_post_json(`vote/${prompt.id}`) }
384     >
385       <span
386         className="hide-button"
387         onClick={(event) => {
388           event.stopPropagation();
389           fetch_post_json(`vote_against/${prompt.id}`);
390         }}
391       >
392         &times;
393       </span>
394       {prompt.prompt}
395       <div className="vote-choices">
396         {prompt.votes.map(v => {
397           return (
398             <div
399               key={v}
400               className="vote-choice"
401             >
402               {v}
403             </div>
404           );
405         })}
406       </div>
407     </button>
408   );
409 });
410
411 const PromptOptions = React.memo(props => {
412
413   if (props.prompts.length === 0)
414     return null;
415
416   return (
417     <div className="prompt-options">
418       <h2>Vote on Categories</h2>
419       <p>
420         Select any categories below that you'd like to play.
421         You can choose as many as you'd like.
422       </p>
423       {props.prompts.map(
424         prompt => <PromptOption
425                     key={prompt.id}
426                     prompt={prompt}
427                     player={props.player}
428                   />
429       )}
430     </div>
431   );
432 });
433
434 const LetsPlay = React.memo(props => {
435
436   const quorum = Math.max(0, props.num_players - props.prompts.length);
437   const max_votes = props.prompts.reduce(
438     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
439
440   if (max_votes < quorum) {
441     let text = `Before we play, we should collect a bit
442                 more information about what category would
443                 be interesting for this group. So, either
444                 type a new category option above, or else`;
445     if (props.prompts.length) {
446       if (props.prompts.length > 1)
447         text += " vote on some of the categories below.";
448       else
449         text += " vote on the category below.";
450     } else {
451       text += " wait for others to submit, and then vote on them below.";
452     }
453
454     return (
455       <div className="before-we-play">
456         <p>
457           {text}
458         </p>
459       </div>
460     );
461   }
462
463   const candidates = props.prompts.filter(p => p.votes.length >= max_votes);
464   const index = Math.floor(Math.random() * candidates.length);
465   const winner = candidates[index];
466
467   return (
468     <div className="lets-play">
469       <h2>Let's Play</h2>
470       <p>
471         That should be enough voting. If you're not waiting for any
472         other players to join, then let's start.
473       </p>
474       <button
475         className="lets-play"
476         onClick={() => fetch_post_json(`start/${winner.id}`) }
477       >
478         Start Game
479       </button>
480     </div>
481   );
482 });
483
484 class Ambiguities extends React.PureComponent {
485
486   constructor(props) {
487     super(props);
488
489     function canonize(word) {
490       return word.replace(/((a|an|the) )?(.*?)s?$/i, '$3');
491     }
492
493     const word_sets = [];
494
495     for (let word of props.words) {
496       const word_canon = canonize(word);
497       let found_match = false;
498       for (let set of word_sets) {
499         const set_canon = canonize(set.values().next().value);
500         if (word_canon === set_canon) {
501           set.add(word);
502           found_match = true;;
503           break;
504         }
505       }
506       if (! found_match) {
507         const set = new Set();
508         set.add(word);
509         word_sets.push(set);
510       }
511     }
512
513     this.state = {
514       word_sets: word_sets,
515       selected: null,
516       starred: null
517     };
518
519     this.submitted = false;
520     this.judging_sent_recently = false;
521   }
522
523   async handle_submit() {
524
525     /* Don't submit a second time. */
526     if (this.submitted)
527       return;
528
529     const response = await fetch_post_json(
530       `judged/${this.props.prompt.id}`,{
531         word_groups: this.state.word_sets.map(
532           set => ({
533             words: Array.from(set),
534             kudos: this.state.starred === set ? true : false
535           }))
536       }
537     );
538
539     if (response.status === 200) {
540       const result = await response.json();
541       if (! result.valid) {
542         add_message("danger", result.message);
543         return;
544       }
545     } else {
546       add_message("danger", "An error occurred submitting the results of your judging");
547       return;
548     }
549
550     this.submitted = true;
551   }
552
553   handle_click(word) {
554
555     /* Let the server know we are doing some judging, (but rate limit
556      * this so we don't send a "judging" notification more frquently
557      * than necessary.
558      */
559     if (! this.judging_sent_recently) {
560       fetch_post_json(`judging/${this.props.prompt.id}`);
561       this.judging_sent_recently = true;
562       setTimeout(() => { this.judging_sent_recently = false; }, 1000);
563     }
564
565     if (this.state.selected == word) {
566       /* Second click on same word removes the word from the group. */
567       const idx = this.state.word_sets.findIndex(s => s.has(word));
568       const set = this.state.word_sets[idx];
569       if (set.size === 1) {
570         /* When the word is already alone, there's nothing to do but
571          * to un-select it. */
572         this.setState({
573           selected: null
574         });
575         return;
576       }
577
578       const new_set = new Set([...set].filter(w => w !== word));
579       this.setState({
580         selected: null,
581         word_sets: [...this.state.word_sets.slice(0, idx),
582                     new_set,
583                     new Set().add(word),
584                     ...this.state.word_sets.slice(idx+1)]
585       });
586     } else if (this.state.selected) {
587       /* Click of a second word groups the two together. */
588       const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
589       const idx2 = this.state.word_sets.findIndex(s => s.has(word));
590       const set1 = this.state.word_sets[idx1];
591       const set2 = this.state.word_sets[idx2];
592       const new_set = new Set([...set2, ...set1]);
593       if (idx1 < idx2) {
594         this.setState({
595           selected: null,
596           word_sets: [...this.state.word_sets.slice(0, idx1),
597                       ...this.state.word_sets.slice(idx1 + 1, idx2),
598                       new_set,
599                       ...this.state.word_sets.slice(idx2 + 1)]
600         });
601       } else {
602         this.setState({
603           selected: null,
604           word_sets: [...this.state.word_sets.slice(0, idx2),
605                       new_set,
606                       ...this.state.word_sets.slice(idx2 + 1, idx1),
607                       ...this.state.word_sets.slice(idx1 + 1)]
608         });
609       }
610     } else {
611       /* First click of a word selects it. */
612       this.setState({
613         selected: word
614       });
615     }
616   }
617
618   render() {
619     let move_on_button = null;
620
621     if (this.props.idle) {
622       move_on_button = (
623         <button
624           className="vote-button"
625           onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
626         >
627           Move On Without Their Input
628           <div className="vote-choices">
629             {[...this.props.votes].map(v => {
630               return (
631                 <div
632                   key={v}
633                   className="vote-choice"
634                 >
635                   {v}
636                   </div>
637               );
638             })}
639           </div>
640         </button>
641       );
642     }
643
644     let still_waiting = null;
645     const judging_players = Object.keys(this.props.players_judging);
646     if (judging_players.length) {
647       still_waiting = (
648         <div>
649           <p>
650             Still waiting for the following player
651             {judging_players.length > 1 ? 's' : '' }
652             :
653           </p>
654           <ul>
655             {judging_players.map(player => {
656               return (
657                 <li
658                   key={player}
659                 >
660                   {player}{' '}
661                   <span className=
662                   {this.props.players_judging[player].active ?
663                    "typing active"
664                    :
665                    "typing idle"}>
666                     <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
667                   </span>
668                 </li>
669               );
670             })}
671           </ul>
672         </div>
673       );
674     }
675
676     if (this.props.players_judged.has(this.props.player.name)) {
677       return (
678         <div className="please-wait">
679           <h2>Submission received</h2>
680           <p>
681             The following players have completed judging:{' '}
682             {[...this.props.players_judged].join(', ')}
683           </p>
684           {still_waiting}
685           {move_on_button}
686
687         </div>
688       );
689     }
690
691     const btn_class = "ambiguity-button";
692     const btn_selected_class = btn_class + " selected";
693
694     return (
695       <div className="ambiguities">
696         <h2>Judging Answers</h2>
697         <p>
698           Click/tap on each pair of answers that should be scored as equivalent,
699           (or click a word twice to split it out from a group). Remember,
700           what goes around comes around, so it's best to be generous when
701           judging.
702         </p>
703         <p>
704           Also, for an especially fun or witty answer, you can give kudos
705           by clicking the star on the right. You may only do this for one
706           word/group.
707         </p>
708         <h2>{this.props.prompt.prompt}</h2>
709         {this.state.word_sets.map(set => {
710           return (
711             <div
712               className="ambiguity-group"
713               key={Array.from(set)[0]}
714             >
715               {Array.from(set).map(word => {
716                 return (
717                   <button
718                     className={this.state.selected === word ?
719                                btn_selected_class : btn_class }
720                     key={word}
721                     onClick={() => this.handle_click(word)}
722                   >
723                     {word}
724                   </button>
725                 );
726               })}
727               <span
728                 className={this.state.starred === set ?
729                            "star-button selected" : "star-button"
730                           }
731                 onClick={(event) => {
732                   event.stopPropagation();
733                   if (this.state.starred === set) {
734                     this.setState({
735                       starred: null
736                     });
737                   } else {
738                     this.setState({
739                       starred: set
740                     });
741                   }
742                 }}
743               >
744               {this.state.starred === set ?
745                '★' : '☆'
746               }
747               </span>
748             </div>
749           );
750         })}
751         <p>
752           Click here when done judging:<br/>
753           <button
754             onClick={() => this.handle_submit()}
755           >
756             Send
757           </button>
758         </p>
759       </div>
760     );
761   }
762 }
763
764 class ActivePrompt extends React.PureComponent {
765
766   constructor(props) {
767     super(props);
768     const items = props.prompt.items;
769
770     this.submitted = false;
771
772     this.answers = [...Array(items)].map(() => React.createRef());
773     this.answering_sent_recently = false;
774
775     this.handle_submit = this.handle_submit.bind(this);
776     this.handle_change = this.handle_change.bind(this);
777   }
778
779   handle_change(event) {
780     /* We don't care (or even look) at what the player is typing at
781      * this point. We simply want to be informed that the player _is_
782      * typing so that we can tell the server (which will tell other
783      * players) that there is activity here.
784      */
785
786     /* Rate limit so that we don't send an "answering" notification
787      * more frequently than necessary.
788      */
789     if (! this.answering_sent_recently) {
790       fetch_post_json(`answering/${this.props.prompt.id}`);
791       this.answering_sent_recently = true;
792       setTimeout(() => { this.answering_sent_recently = false; }, 1000);
793     }
794   }
795
796   async handle_submit(event) {
797     const form = event.currentTarget;
798
799     /* Prevent the default page-changing form-submission behavior. */
800     event.preventDefault();
801
802     /* And don't submit a second time. */
803     if (this.submitted)
804       return;
805
806     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
807       answers: this.answers.map(r => r.current.value)
808     });
809     if (response.status === 200) {
810       const result = await response.json();
811       if (! result.valid) {
812         add_message("danger", result.message);
813         return;
814       }
815     } else {
816       add_message("danger", "An error occurred submitting your answers");
817       return;
818     }
819
820     /* Everything worked. Server is happy with our answers. */
821     form.reset();
822     this.submitted = true;
823   }
824
825   render() {
826
827     let still_waiting = null;
828     const answering_players = Object.keys(this.props.players_answering);;
829     if (answering_players.length) {
830       still_waiting = (
831         <div>
832           <p>
833             Still waiting for the following player
834             {answering_players.length > 1 ? 's' : ''}
835             :
836           </p>
837           <ul>
838             {answering_players.map(player => {
839               return (
840                 <li
841                   key={player}
842                 >
843                   {player}{' '}
844                   <span className=
845                   {this.props.players_answering[player].active ?
846                    "typing active"
847                    :
848                    "typing idle"}>
849                     <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
850                   </span>
851                 </li>
852               );
853             })}
854           </ul>
855         </div>
856       );
857     }
858
859     let move_on_button = null;
860     if (this.props.idle) {
861       move_on_button =(
862         <button
863           className="vote-button"
864           onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
865         >
866           {answering_players.length ?
867            "Move On Without Their Answers" :
868            "Move On Without Anyone Else"}
869           <div className="vote-choices">
870             {[...this.props.votes].map(v => {
871               return (
872                 <div
873                   key={v}
874                   className="vote-choice"
875                 >
876                   {v}
877                 </div>
878               );
879             })}
880           </div>
881         </button>
882       );
883     }
884
885     if (this.props.players_answered.has(this.props.player.name)) {
886       return (
887         <div className="please-wait">
888           <h2>Submission received</h2>
889           <p>
890             The following players have submitted their answers:{' '}
891             {[...this.props.players_answered].join(', ')}
892           </p>
893           {still_waiting}
894           {move_on_button}
895
896         </div>
897       );
898     }
899
900     return (
901       <div className="active-prompt">
902         <h2>The Game of Empathy</h2>
903         <p>
904           Remember, you're trying to match your answers with
905           what the other players submit.
906           Give {this.props.prompt.items} answer
907           {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:
908         </p>
909         <h2>{this.props.prompt.prompt}</h2>
910         <form onSubmit={this.handle_submit}>
911           {[...Array(this.props.prompt.items)].map((whocares,i) => {
912             return (
913               <div
914                 key={i}
915                 className="form-field large">
916                 <input
917                   type="text"
918                   name={`answer_${i}`}
919                   required
920                   autoComplete="off"
921                   onChange={this.handle_change}
922                   ref={this.answers[i]}
923                 />
924               </div>
925             );
926           })}
927
928           <div
929             key="submit-button"
930             className="form-field large">
931             <button type="submit">
932               Send
933             </button>
934           </div>
935
936         </form>
937       </div>
938     );
939   }
940 }
941
942 class Game extends React.PureComponent {
943   constructor(props) {
944     super(props);
945     this.state = {
946       game_info: {},
947       player_info: {},
948       other_players: [],
949       prompts: [],
950       active_prompt: null,
951       players_answered: new Set(),
952       players_answering: {},
953       answering_idle: false,
954       end_answers_votes: new Set(),
955       ambiguities: null,
956       players_judged: new Set(),
957       players_judging: {},
958       judging_idle: false,
959       end_judging_votes: new Set(),
960       scores: null,
961       new_game_votes: new Set(),
962       ready: false
963     };
964   }
965
966   set_game_info(info) {
967     this.setState({
968       game_info: info
969     });
970   }
971
972   set_player_info(info) {
973     this.setState({
974       player_info: info
975     });
976   }
977
978   set_other_player_info(info) {
979     const other_players_copy = [...this.state.other_players];
980     const idx = other_players_copy.findIndex(o => o.id === info.id);
981     if (idx >= 0) {
982       other_players_copy[idx] = info;
983     } else {
984       other_players_copy.push(info);
985     }
986     this.setState({
987       other_players: other_players_copy
988     });
989   }
990
991   remove_player(info) {
992     this.setState({
993       other_players: this.state.other_players.filter(o => o.id !== info.id)
994     });
995   }
996
997   reset_game_state() {
998     this.setState({
999       prompts: [],
1000       active_prompt: null,
1001       players_answered: new Set(),
1002       players_answering: {},
1003       answering_idle: false,
1004       end_answers_votes: new Set(),
1005       ambiguities: null,
1006       players_judged: new Set(),
1007       players_judging: {},
1008       judging_idle: false,
1009       end_judging_votes: new Set(),
1010       scores: null,
1011       new_game_votes: new Set(),
1012       ready: false
1013     });
1014   }
1015
1016   set_prompts(prompts) {
1017     this.setState({
1018       prompts: prompts
1019     });
1020   }
1021
1022   add_or_update_prompt(prompt) {
1023     const prompts_copy = [...this.state.prompts];
1024     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
1025     if (idx >= 0) {
1026       prompts_copy[idx] = prompt;
1027     } else {
1028       prompts_copy.push(prompt);
1029     }
1030     this.setState({
1031       prompts: prompts_copy
1032     });
1033   }
1034
1035   set_active_prompt(prompt) {
1036     this.setState({
1037       active_prompt: prompt
1038     });
1039   }
1040
1041   set_players_answered(players) {
1042     this.setState({
1043       players_answered: new Set(players)
1044     });
1045   }
1046
1047   set_player_answered(player) {
1048     const new_players_answering = {...this.state.players_answering};
1049     delete new_players_answering[player];
1050
1051     this.setState({
1052       players_answered: new Set([...this.state.players_answered, player]),
1053       players_answering: new_players_answering
1054     });
1055   }
1056
1057   set_players_answering(players) {
1058     const players_answering = {};
1059     for (let player of players) {
1060       players_answering[player] = {active: false};
1061     }
1062     this.setState({
1063       players_answering: players_answering
1064     });
1065   }
1066
1067   set_player_answering(player) {
1068     /* Set the player as actively answering now. */
1069     this.setState({
1070       players_answering: {
1071         ...this.state.players_answering,
1072         [player]: {active: true}
1073       }
1074     });
1075     /* And arrange to have them marked idle very shortly.
1076      *
1077      * Note: This timeout is intentionally very, very short. We only
1078      * need it long enough that the browser has latched onto the state
1079      * change to "active" above. We actually use a CSS transition
1080      * delay to control the user-perceptible length of time after
1081      * which an active player appears inactive.
1082      */
1083     setTimeout(() => {
1084       this.setState({
1085         players_answering: {
1086           ...this.state.players_answering,
1087           [player]: {active: false}
1088         }
1089       });
1090     }, 100);
1091   }
1092
1093   set_answering_idle(value) {
1094     this.setState({
1095       answering_idle: value
1096     });
1097   }
1098
1099   set_end_answers(players) {
1100     this.setState({
1101       end_answers_votes: new Set(players)
1102     });
1103   }
1104
1105   set_player_vote_end_answers(player) {
1106     this.setState({
1107       end_answers_votes: new Set([...this.state.end_answers_votes, player])
1108     });
1109   }
1110
1111   set_player_unvote_end_answers(player) {
1112     this.setState({
1113       end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
1114     });
1115   }
1116
1117   set_ambiguities(ambiguities) {
1118     this.setState({
1119       ambiguities: ambiguities
1120     });
1121   }
1122
1123   set_players_judged(players) {
1124     this.setState({
1125       players_judged: new Set(players)
1126     });
1127   }
1128
1129   set_player_judged(player) {
1130     const new_players_judging = {...this.state.players_judging};
1131     delete new_players_judging[player];
1132
1133     this.setState({
1134       players_judged: new Set([...this.state.players_judged, player]),
1135       players_judging: new_players_judging
1136     });
1137   }
1138
1139   set_players_judging(players) {
1140     const players_judging = {};
1141     for (let player of players) {
1142       players_judging[player] = {active: false};
1143     }
1144     this.setState({
1145       players_judging: players_judging
1146     });
1147   }
1148
1149   set_player_judging(player) {
1150     /* Set the player as actively judging now. */
1151     this.setState({
1152       players_judging: {
1153         ...this.state.players_judging,
1154         [player]: {active: true}
1155       }
1156     });
1157     /* And arrange to have them marked idle very shortly.
1158      *
1159      * Note: This timeout is intentionally very, very short. We only
1160      * need it long enough that the browser has latched onto the state
1161      * change to "active" above. We actually use a CSS transition
1162      * delay to control the user-perceptible length of time after
1163      * which an active player appears inactive.
1164      */
1165     setTimeout(() => {
1166       this.setState({
1167         players_judging: {
1168           ...this.state.players_judging,
1169           [player]: {active: false}
1170         }
1171       });
1172     }, 100);
1173
1174   }
1175
1176   set_judging_idle(value) {
1177     this.setState({
1178       judging_idle: value
1179     });
1180   }
1181
1182   set_end_judging(players) {
1183     this.setState({
1184       end_judging_votes: new Set(players)
1185     });
1186   }
1187
1188   set_player_vote_end_judging(player) {
1189     this.setState({
1190       end_judging_votes: new Set([...this.state.end_judging_votes, player])
1191     });
1192   }
1193
1194   set_player_unvote_end_judging(player) {
1195     this.setState({
1196       end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
1197     });
1198   }
1199
1200   set_scores(scores) {
1201     this.setState({
1202       scores: scores
1203     });
1204   }
1205
1206   set_new_game_votes(players) {
1207     this.setState({
1208       new_game_votes: new Set(players)
1209     });
1210   }
1211
1212   set_player_vote_new_game(player) {
1213     this.setState({
1214       new_game_votes: new Set([...this.state.new_game_votes, player])
1215     });
1216   }
1217
1218   set_player_unvote_new_game(player) {
1219     this.setState({
1220       new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player))
1221     });
1222   }
1223
1224   state_ready() {
1225     this.setState({
1226       ready: true
1227     });
1228   }
1229
1230   render() {
1231     const state = this.state;
1232     const players_total = 1 + state.other_players.length;
1233
1234     if (state.scores) {
1235
1236       let perfect_score = 0;
1237       for (let i = 0;
1238            i < state.active_prompt.items &&
1239            i < state.scores.words.length;
1240            i++)
1241       {
1242         perfect_score += state.scores.words[i].players.length;
1243       }
1244
1245       return (
1246         <div className="scores">
1247           <h2>{state.active_prompt.prompt}</h2>
1248           <h2>Scores</h2>
1249           <ul>
1250             {state.scores.scores.map(score => {
1251               let perfect = null;
1252               if (score.score === perfect_score) {
1253                 perfect = <span className="achievement">Perfect!</span>;
1254               }
1255               let quirkster = null;
1256               if (score.score === state.active_prompt.items) {
1257                 quirkster = <span className="achievement">Quirkster!</span>;
1258               }
1259               let kudos_slam = null;
1260               if (score.kudos > 0 && score.kudos >= players_total - 1) {
1261                 kudos_slam = <span className="achievement">Kudos Slam!</span>;
1262               }
1263               return (
1264                 <li key={score.players[0]}>
1265                   {score.players.join("/")}: {score.score}
1266                   {score.kudos ? `, ${'★'.repeat(score.kudos)}` : ""}
1267                   {' '}{perfect} {quirkster} {kudos_slam}
1268                 </li>
1269               );
1270             })}
1271           </ul>
1272           <h2>Words submitted</h2>
1273           <ul>
1274             {state.scores.words.map(word => {
1275               let great_minds = null;
1276               if (word.kudos.length && word.players.length > 1) {
1277                 great_minds = <span className="achievement">Great Minds!</span>;
1278               }
1279               let kudos_slam = null;
1280               if (word.kudos.length > 0 && word.kudos.length >= players_total - 1) {
1281                 kudos_slam = <span className="achievement">Kudos Slam!</span>;
1282               }
1283               return (
1284                 <li key={word.word}>
1285                   {word.word} ({word.players.length}
1286                   {word.kudos.length ? `, ${'★'.repeat(word.kudos.length)}` : ""}
1287                   ): {word.players.join(', ')}
1288                   {' '}{great_minds}{kudos_slam}
1289                 </li>
1290               );
1291             })}
1292           </ul>
1293           <button
1294             className="vote-button"
1295             onClick={() => fetch_post_json(`new-game/${state.active_prompt.id}`) }
1296           >
1297             New Game
1298             <div className="vote-choices">
1299               {[...state.new_game_votes].map(v => {
1300                 return (
1301                   <div
1302                     key={v}
1303                     className="vote-choice"
1304                   >
1305                     {v}
1306                   </div>
1307                 );
1308               })}
1309             </div>
1310           </button>
1311         </div>
1312       );
1313     }
1314
1315     if (state.ambiguities){
1316       return <Ambiguities
1317                prompt={state.active_prompt}
1318                words={state.ambiguities}
1319                player={state.player_info}
1320                players_judged={state.players_judged}
1321                players_judging={state.players_judging}
1322                idle={state.judging_idle}
1323                votes={state.end_judging_votes}
1324              />;
1325     }
1326
1327     if (state.active_prompt) {
1328       return <ActivePrompt
1329                prompt={state.active_prompt}
1330                player={state.player_info}
1331                players_answered={state.players_answered}
1332                players_answering={state.players_answering}
1333                idle={state.answering_idle}
1334                votes={state.end_answers_votes}
1335              />;
1336     }
1337
1338     if (! state.ready)
1339       return null;
1340
1341     return [
1342       <GameInfo
1343         key="game-info"
1344         id={state.game_info.id}
1345         url={state.game_info.url}
1346       />,
1347       <PlayerInfo
1348         key="player-info"
1349         game={this}
1350         player={state.player_info}
1351         other_players={state.other_players}
1352       />,
1353       <p key="spacer"></p>,
1354       <CategoryRequest
1355         key="category-request"
1356       />,
1357       <LetsPlay
1358         key="lets-play"
1359         num_players={1+state.other_players.length}
1360         prompts={state.prompts}
1361       />,
1362       <PromptOptions
1363         key="prompts"
1364         prompts={state.prompts}
1365         player={state.player_info}
1366       />
1367     ];
1368   }
1369 }
1370
1371 ReactDOM.render(<Game
1372                   ref={(me) => window.game = me}
1373                 />, document.getElementById("empathy"));