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