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