]> git.cworth.org Git - lmno.games/blob - empathy/empathy.jsx
Add animated, bouncing/fading ellipses to indicate activity
[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 <= 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     const response = await fetch_post_json("prompts", {
315       items: num_items,
316       prompt: category
317     });
318
319     if (response.status === 200) {
320       const result = await response.json();
321       if (! result.valid) {
322         add_message("danger", result.message);
323         return;
324       }
325     } else {
326       add_message("danger", "An error occurred submitting your category");
327     }
328
329     form.reset();
330   }
331
332   render() {
333     return (
334       <div className="category-request">
335         <h2>Submit a Category</h2>
336         <p>
337           Suggest a category to play. Don't forget to include the
338           number of items for each person to submit.
339         </p>
340
341         <form onSubmit={this.handle_submit} >
342           <div className="form-field large">
343             <input
344               type="text"
345               id="category"
346               placeholder="6 things at the beach"
347               required
348               autoComplete="off"
349               onChange={this.handle_change}
350               ref={this.category}
351             />
352           </div>
353
354           <div className="form-field large">
355             <button type="submit">
356               Send
357             </button>
358           </div>
359
360         </form>
361       </div>
362     );
363   }
364 }
365
366 const PromptOptions = React.memo(props => {
367
368   if (props.prompts.length === 0)
369     return null;
370
371   return (
372     <div className="prompt-options">
373       <h2>Vote on Categories</h2>
374       <p>
375         Select any categories below that you'd like to play.
376         You can choose as many as you'd like.
377       </p>
378       {props.prompts.map(p => {
379         return (
380           <button
381             className="vote-button"
382             key={p.id}
383             onClick={() => fetch_post_json(`vote/${p.id}`) }
384           >
385             {p.prompt}
386             <div className="vote-choices">
387               {p.votes.map(v => {
388                 return (
389                   <div
390                     key={v}
391                     className="vote-choice"
392                   >
393                     {v}
394                   </div>
395                 );
396               })}
397             </div>
398           </button>
399         );
400       })}
401     </div>
402   );
403 });
404
405 const LetsPlay = React.memo(props => {
406
407   const quorum = Math.round((props.num_players + 1) / 2);
408   const max_votes = props.prompts.reduce(
409     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
410
411   if (max_votes < quorum)
412     return null;
413
414   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
415   const index = Math.floor(Math.random() * candidates.length);
416   const winner = candidates[index];
417
418   return (
419     <div className="lets-play">
420       <h2>Let's Play</h2>
421       <p>
422         That should be enough voting. If you're not waiting for any
423         other players to join, then let's start.
424       </p>
425       <button
426         className="lets-play"
427         onClick={() => fetch_post_json(`start/${winner.id}`) }
428       >
429         Start Game
430       </button>
431     </div>
432   );
433 });
434
435 class Ambiguities extends React.PureComponent {
436
437   constructor(props) {
438     super(props);
439
440     const word_sets = props.words.map(word => {
441       const set = new Set();
442       set.add(word);
443       return set;
444     });
445
446     this.state = {
447       word_sets: word_sets,
448       selected: null
449     };
450
451     this.submitted = false;
452     this.judging_sent_recently = false;
453   }
454
455   async handle_submit() {
456
457     /* Don't submit a second time. */
458     if (this.submitted)
459       return;
460
461     const response = await fetch_post_json(
462       `judged/${this.props.prompt.id}`,{
463         word_groups: this.state.word_sets.map(set => Array.from(set))
464       }
465     );
466
467     if (response.status === 200) {
468       const result = await response.json();
469       if (! result.valid) {
470         add_message("danger", result.message);
471         return;
472       }
473     } else {
474       add_message("danger", "An error occurred submitting the results of your judging");
475       return;
476     }
477
478     this.submitted = true;
479   }
480
481   handle_click(word) {
482
483     /* Let the server know we are doing some judging, (but rate limit
484      * this so we don't send a "judging" notification more frquently
485      * than necessary.
486      */
487     if (! this.judging_sent_recently) {
488       fetch_post_json(`judging/${this.props.prompt.id}`);
489       this.judging_sent_recently = true;
490       setTimeout(() => { this.judging_sent_recently = false; }, 1000);
491     }
492
493     if (this.state.selected == word) {
494       /* Second click on same word removes the word from the group. */
495       const idx = this.state.word_sets.findIndex(s => s.has(word));
496       const set = this.state.word_sets[idx];
497       if (set.size === 1) {
498         /* When the word is already alone, there's nothing to do but
499          * to un-select it. */
500         this.setState({
501           selected: null
502         });
503         return;
504       }
505
506       const new_set = new Set([...set].filter(w => w !== word));
507       this.setState({
508         selected: null,
509         word_sets: [...this.state.word_sets.slice(0, idx),
510                     new_set,
511                     new Set().add(word),
512                     ...this.state.word_sets.slice(idx+1)]
513       });
514     } else if (this.state.selected) {
515       /* Click of a second word groups the two together. */
516       const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
517       const idx2 = this.state.word_sets.findIndex(s => s.has(word));
518       const set1 = this.state.word_sets[idx1];
519       const set2 = this.state.word_sets[idx2];
520       const new_set = new Set([...set2, ...set1]);
521       if (idx1 < idx2) {
522         this.setState({
523           selected: null,
524           word_sets: [...this.state.word_sets.slice(0, idx1),
525                       ...this.state.word_sets.slice(idx1 + 1, idx2),
526                       new_set,
527                       ...this.state.word_sets.slice(idx2 + 1)]
528         });
529       } else {
530         this.setState({
531           selected: null,
532           word_sets: [...this.state.word_sets.slice(0, idx2),
533                       new_set,
534                       ...this.state.word_sets.slice(idx2 + 1, idx1),
535                       ...this.state.word_sets.slice(idx1 + 1)]
536         });
537       }
538     } else {
539       /* First click of a word selects it. */
540       this.setState({
541         selected: word
542       });
543     }
544   }
545
546   render() {
547     let move_on_button = null;
548
549     if (this.props.idle) {
550       move_on_button = (
551         <button
552           className="vote-button"
553           onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
554         >
555           Move On
556           <div className="vote-choices">
557             {[...this.props.votes].map(v => {
558               return (
559                 <div
560                   key={v}
561                   className="vote-choice"
562                 >
563                   {v}
564                   </div>
565               );
566             })}
567           </div>
568         </button>
569       );
570     }
571
572     let still_waiting = null;
573     const judging_players = Object.keys(this.props.players_judging);
574     if (judging_players.length) {
575       still_waiting = (
576         <div>
577           <p>
578             Still waiting for the following player
579             {judging_players.length > 1 ? 's' : '' }
580             :
581           </p>
582           <ul>
583             {judging_players.map(player => {
584               return (
585                 <li
586                   key={player}
587                 >
588                   {player}{' '}
589                   <span className=
590                   {this.props.players_judging[player].active ?
591                    "typing active"
592                    :
593                    "typing idle"}>
594                     <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
595                   </span>
596                 </li>
597               );
598             })}
599           </ul>
600         </div>
601       );
602     }
603
604     if (this.props.players_judged.has(this.props.player.name)) {
605       return (
606         <div className="please-wait">
607           <h2>Submission received</h2>
608           <p>
609             The following players have completed judging:{' '}
610             {[...this.props.players_judged].join(', ')}
611           </p>
612           {still_waiting}
613           {move_on_button}
614
615         </div>
616       );
617     }
618
619     const btn_class = "ambiguity-button";
620     const btn_selected_class = btn_class + " selected";
621
622     return (
623       <div className="ambiguities">
624         <h2>Judging Answers</h2>
625         <p>
626           Click on each pair of answers that should be scored as equivalent,
627           (and click any word twice to split it out from a group). Remember,
628           what goes around comes around, so it's best to be generous when
629           judging.
630         </p>
631         <h2>{this.props.prompt.prompt}</h2>
632         {this.state.word_sets.map(set => {
633           return (
634             <div
635               className="ambiguity-group"
636               key={Array.from(set)[0]}
637             >
638               {Array.from(set).map(word => {
639                 return (
640                   <button
641                     className={this.state.selected === word ?
642                                btn_selected_class : btn_class }
643                     key={word}
644                     onClick={() => this.handle_click(word)}
645                   >
646                     {word}
647                   </button>
648                 );
649               })}
650             </div>
651           );
652         })}
653         <p>
654           Click here when done judging:<br/>
655           <button
656             onClick={() => this.handle_submit()}
657           >
658             Send
659           </button>
660         </p>
661       </div>
662     );
663   }
664 }
665
666 class ActivePrompt extends React.PureComponent {
667
668   constructor(props) {
669     super(props);
670     const items = props.prompt.items;
671
672     this.submitted = false;
673
674     this.answers = [...Array(items)].map(() => React.createRef());
675     this.answering_sent_recently = false;
676
677     this.handle_submit = this.handle_submit.bind(this);
678     this.handle_change = this.handle_change.bind(this);
679   }
680
681   handle_change(event) {
682     /* We don't care (or even look) at what the player is typing at
683      * this point. We simply want to be informed that the player _is_
684      * typing so that we can tell the server (which will tell other
685      * players) that there is activity here.
686      */
687
688     /* Rate limit so that we don't send an "answering" notification
689      * more frequently than necessary.
690      */
691     if (! this.answering_sent_recently) {
692       fetch_post_json(`answering/${this.props.prompt.id}`);
693       this.answering_sent_recently = true;
694       setTimeout(() => { this.answering_sent_recently = false; }, 1000);
695     }
696   }
697
698   async handle_submit(event) {
699     const form = event.currentTarget;
700
701     /* Prevent the default page-changing form-submission behavior. */
702     event.preventDefault();
703
704     /* And don't submit a second time. */
705     if (this.submitted)
706       return;
707
708     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
709       answers: this.answers.map(r => r.current.value)
710     });
711     if (response.status === 200) {
712       const result = await response.json();
713       if (! result.valid) {
714         add_message("danger", result.message);
715         return;
716       }
717     } else {
718       add_message("danger", "An error occurred submitting your answers");
719       return;
720     }
721
722     /* Everything worked. Server is happy with our answers. */
723     form.reset();
724     this.submitted = true;
725   }
726
727   render() {
728     let move_on_button = null;
729     if (this.props.idle) {
730       move_on_button =(
731         <button
732           className="vote-button"
733           onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
734         >
735           Move On
736           <div className="vote-choices">
737             {[...this.props.votes].map(v => {
738               return (
739                 <div
740                   key={v}
741                   className="vote-choice"
742                 >
743                   {v}
744                 </div>
745               );
746             })}
747           </div>
748         </button>
749       );
750     }
751
752     let still_waiting = null;
753     const answering_players = Object.keys(this.props.players_answering);;
754     if (answering_players.length) {
755       still_waiting = (
756         <div>
757           <p>
758             Still waiting for the following player
759             {answering_players.length > 1 ? 's' : ''}
760             :
761           </p>
762           <ul>
763             {answering_players.map(player => {
764               return (
765                 <li
766                   key={player}
767                 >
768                   {player}{' '}
769                   <span className=
770                   {this.props.players_answering[player].active ?
771                    "typing active"
772                    :
773                    "typing idle"}>
774                     <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
775                   </span>
776                 </li>
777               );
778             })}
779           </ul>
780         </div>
781       );
782     }
783
784     if (this.props.players_answered.has(this.props.player.name)) {
785       return (
786         <div className="please-wait">
787           <h2>Submission received</h2>
788           <p>
789             The following players have submitted their answers:{' '}
790             {[...this.props.players_answered].join(', ')}
791           </p>
792           {still_waiting}
793           {move_on_button}
794
795         </div>
796       );
797     }
798
799     return (
800       <div className="active-prompt">
801         <h2>The Game of Empathy</h2>
802         <p>
803           Remember, you're trying to match your answers with
804           what the other players submit.
805           Give {this.props.prompt.items} answer
806           {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:
807         </p>
808         <h2>{this.props.prompt.prompt}</h2>
809         <form onSubmit={this.handle_submit}>
810           {[...Array(this.props.prompt.items)].map((whocares,i) => {
811             return (
812               <div
813                 key={i}
814                 className="form-field large">
815                 <input
816                   type="text"
817                   name={`answer_${i}`}
818                   required
819                   autoComplete="off"
820                   onChange={this.handle_change}
821                   ref={this.answers[i]}
822                 />
823               </div>
824             );
825           })}
826
827           <div
828             key="submit-button"
829             className="form-field large">
830             <button type="submit">
831               Send
832             </button>
833           </div>
834
835         </form>
836       </div>
837     );
838   }
839 }
840
841 class Game extends React.PureComponent {
842   constructor(props) {
843     super(props);
844     this.state = {
845       game_info: {},
846       player_info: {},
847       other_players: [],
848       prompts: [],
849       active_prompt: null,
850       players_answered: new Set(),
851       players_answering: {},
852       answering_idle: false,
853       end_answers_votes: new Set(),
854       ambiguities: null,
855       players_judged: new Set(),
856       players_judging: {},
857       judging_idle: false,
858       end_judging_votes: new Set(),
859       scores: null,
860       new_game_votes: new Set(),
861       ready: false
862     };
863   }
864
865   set_game_info(info) {
866     this.setState({
867       game_info: info
868     });
869   }
870
871   set_player_info(info) {
872     this.setState({
873       player_info: info
874     });
875   }
876
877   set_other_player_info(info) {
878     const other_players_copy = [...this.state.other_players];
879     const idx = other_players_copy.findIndex(o => o.id === info.id);
880     if (idx >= 0) {
881       other_players_copy[idx] = info;
882     } else {
883       other_players_copy.push(info);
884     }
885     this.setState({
886       other_players: other_players_copy
887     });
888   }
889
890   remove_player(info) {
891     this.setState({
892       other_players: this.state.other_players.filter(o => o.id !== info.id)
893     });
894   }
895
896   reset_game_state() {
897     this.setState({
898       prompts: [],
899       active_prompt: null,
900       players_answered: new Set(),
901       players_answering: {},
902       answering_idle: false,
903       end_answers_votes: new Set(),
904       ambiguities: null,
905       players_judged: new Set(),
906       players_judging: {},
907       judging_idle: false,
908       end_judging_votes: new Set(),
909       scores: null,
910       new_game_votes: new Set(),
911       ready: false
912     });
913   }
914
915   set_prompts(prompts) {
916     this.setState({
917       prompts: prompts
918     });
919   }
920
921   add_or_update_prompt(prompt) {
922     const prompts_copy = [...this.state.prompts];
923     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
924     if (idx >= 0) {
925       prompts_copy[idx] = prompt;
926     } else {
927       prompts_copy.push(prompt);
928     }
929     this.setState({
930       prompts: prompts_copy
931     });
932   }
933
934   set_active_prompt(prompt) {
935     this.setState({
936       active_prompt: prompt
937     });
938   }
939
940   set_players_answered(players) {
941     this.setState({
942       players_answered: new Set(players)
943     });
944   }
945
946   set_player_answered(player) {
947     const new_players_answering = {...this.state.players_answering};
948     delete new_players_answering[player];
949
950     this.setState({
951       players_answered: new Set([...this.state.players_answered, player]),
952       players_answering: new_players_answering
953     });
954   }
955
956   set_players_answering(players) {
957     const players_answering = {};
958     for (let player of players) {
959       players_answering[player] = {active: false};
960     }
961     this.setState({
962       players_answering: players_answering
963     });
964   }
965
966   set_player_answering(player) {
967     /* Set the player as actively answering now. */
968     this.setState({
969       players_answering: {
970         ...this.state.players_answering,
971         [player]: {active: true}
972       }
973     });
974     /* And arrange to have them marked idle very shortly.
975      *
976      * Note: This timeout is intentionally very, very short. We only
977      * need it long enough that the browser has latched onto the state
978      * change to "active" above. We actually use a CSS transition
979      * delay to control the user-perceptible length of time after
980      * which an active player appears inactive.
981      */
982     setTimeout(() => {
983       this.setState({
984         players_answering: {
985           ...this.state.players_answering,
986           [player]: {active: false}
987         }
988       });
989     }, 100);
990   }
991
992   set_answering_idle(value) {
993     this.setState({
994       answering_idle: value
995     });
996   }
997
998   set_end_answers(players) {
999     this.setState({
1000       end_answers_votes: new Set(players)
1001     });
1002   }
1003
1004   set_player_vote_end_answers(player) {
1005     this.setState({
1006       end_answers_votes: new Set([...this.state.end_answers_votes, player])
1007     });
1008   }
1009
1010   set_player_unvote_end_answers(player) {
1011     this.setState({
1012       end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
1013     });
1014   }
1015
1016   set_ambiguities(ambiguities) {
1017     this.setState({
1018       ambiguities: ambiguities
1019     });
1020   }
1021
1022   set_players_judged(players) {
1023     this.setState({
1024       players_judged: new Set(players)
1025     });
1026   }
1027
1028   set_player_judged(player) {
1029     const new_players_judging = {...this.state.players_judging};
1030     delete new_players_judging[player];
1031
1032     this.setState({
1033       players_judged: new Set([...this.state.players_judged, player]),
1034       players_judging: new_players_judging
1035     });
1036   }
1037
1038   set_players_judging(players) {
1039     const players_judging = {};
1040     for (let player of players) {
1041       players_judging[player] = {active: false};
1042     }
1043     this.setState({
1044       players_judging: players_judging
1045     });
1046   }
1047
1048   set_player_judging(player) {
1049     /* Set the player as actively judging now. */
1050     this.setState({
1051       players_judging: {
1052         ...this.state.players_judging,
1053         [player]: {active: true}
1054       }
1055     });
1056     /* And arrange to have them marked idle very shortly.
1057      *
1058      * Note: This timeout is intentionally very, very short. We only
1059      * need it long enough that the browser has latched onto the state
1060      * change to "active" above. We actually use a CSS transition
1061      * delay to control the user-perceptible length of time after
1062      * which an active player appears inactive.
1063      */
1064     setTimeout(() => {
1065       this.setState({
1066         players_judging: {
1067           ...this.state.players_judging,
1068           [player]: {active: false}
1069         }
1070       });
1071     }, 100);
1072
1073   }
1074
1075   set_judging_idle(value) {
1076     this.setState({
1077       judging_idle: value
1078     });
1079   }
1080
1081   set_end_judging(players) {
1082     this.setState({
1083       end_judging_votes: new Set(players)
1084     });
1085   }
1086
1087   set_player_vote_end_judging(player) {
1088     this.setState({
1089       end_judging_votes: new Set([...this.state.end_judging_votes, player])
1090     });
1091   }
1092
1093   set_player_unvote_end_judging(player) {
1094     this.setState({
1095       end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
1096     });
1097   }
1098
1099   set_scores(scores) {
1100     this.setState({
1101       scores: scores
1102     });
1103   }
1104
1105   set_new_game_votes(players) {
1106     this.setState({
1107       new_game_votes: new Set(players)
1108     });
1109   }
1110
1111   set_player_vote_new_game(player) {
1112     this.setState({
1113       new_game_votes: new Set([...this.state.new_game_votes, player])
1114     });
1115   }
1116
1117   set_player_unvote_new_game(player) {
1118     this.setState({
1119       new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player))
1120     });
1121   }
1122
1123   state_ready() {
1124     this.setState({
1125       ready: true
1126     });
1127   }
1128
1129   render() {
1130     const state = this.state;
1131     const players_total = 1 + state.other_players.length;
1132
1133     if (state.scores) {
1134       return (
1135         <div className="scores">
1136           <h2>{state.active_prompt.prompt}</h2>
1137           <h2>Scores</h2>
1138           <ul>
1139             {state.scores.scores.map(score => {
1140               return (
1141                 <li key={score.player}>
1142                   {score.players.join("/")}: {score.score}
1143                 </li>
1144               );
1145             })}
1146           </ul>
1147           <h2>Words submitted</h2>
1148           <ul>
1149             {state.scores.words.map(word => {
1150               return (
1151                 <li key={word.word}>
1152                   {word.word} ({word.players.length}): {word.players.join(', ')}
1153                 </li>
1154               );
1155             })}
1156           </ul>
1157           <button
1158             className="vote-button"
1159             onClick={() => fetch_post_json(`new-game/${state.active_prompt.id}`) }
1160           >
1161             New Game
1162             <div className="vote-choices">
1163               {[...state.new_game_votes].map(v => {
1164                 return (
1165                   <div
1166                     key={v}
1167                     className="vote-choice"
1168                   >
1169                     {v}
1170                   </div>
1171                 );
1172               })}
1173             </div>
1174           </button>
1175         </div>
1176       );
1177     }
1178
1179     if (state.ambiguities){
1180       return <Ambiguities
1181                prompt={state.active_prompt}
1182                words={state.ambiguities}
1183                player={state.player_info}
1184                players_judged={state.players_judged}
1185                players_judging={state.players_judging}
1186                idle={state.judging_idle}
1187                votes={state.end_judging_votes}
1188              />;
1189     }
1190
1191     if (state.active_prompt) {
1192       return <ActivePrompt
1193                prompt={state.active_prompt}
1194                player={state.player_info}
1195                players_answered={state.players_answered}
1196                players_answering={state.players_answering}
1197                idle={state.answering_idle}
1198                votes={state.end_answers_votes}
1199              />;
1200     }
1201
1202     if (! state.ready)
1203       return null;
1204
1205     return [
1206       <GameInfo
1207         key="game-info"
1208         id={state.game_info.id}
1209         url={state.game_info.url}
1210       />,
1211       <PlayerInfo
1212         key="player-info"
1213         game={this}
1214         player={state.player_info}
1215         other_players={state.other_players}
1216       />,
1217       <p key="spacer"></p>,
1218       <CategoryRequest
1219         key="category-request"
1220       />,
1221       <PromptOptions
1222         key="prompts"
1223         prompts={state.prompts}
1224       />,
1225       <LetsPlay
1226         key="lets-play"
1227         num_players={1+state.other_players.length}
1228         prompts={state.prompts}
1229       />
1230     ];
1231   }
1232 }
1233
1234 ReactDOM.render(<Game
1235                   ref={(me) => window.game = me}
1236                 />, document.getElementById("empathy"));