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