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