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