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