Fix bug preventing a user from being able to "unselect" a word while judging
[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-update", event => {
49   const info = JSON.parse(event.data);
50
51   if (info.id === window.game.state.player_info.id)
52     window.game.set_player_info(info);
53   else
54     window.game.set_other_player_info(info);
55 });
56
57 events.addEventListener("game-state", event => {
58   const state = JSON.parse(event.data);
59
60   window.game.reset_game_state();
61
62   window.game.set_prompts(state.prompts);
63
64   window.game.set_active_prompt(state.active_prompt);
65
66   window.game.set_scores(state.scores);
67
68   window.game.set_ambiguities(state.ambiguities);
69 });
70
71 events.addEventListener("prompt", event => {
72   const prompt = JSON.parse(event.data);
73
74   window.game.add_or_update_prompt(prompt);
75 });
76
77 events.addEventListener("start", event => {
78   const prompt = JSON.parse(event.data);
79
80   window.game.set_active_prompt(prompt);
81 });
82
83 events.addEventListener("player-answered", event => {
84   const player = JSON.parse(event.data);
85
86   window.game.set_player_answered(player);
87 });
88
89 events.addEventListener("player-answering", event => {
90   const player = JSON.parse(event.data);
91
92   window.game.set_player_answering(player);
93 });
94
95 events.addEventListener("vote-end-answers", event => {
96   const player = JSON.parse(event.data);
97
98   window.game.set_player_vote_end_answers(player);
99 });
100
101 events.addEventListener("unvote-end-answers", event => {
102   const player = JSON.parse(event.data);
103
104   window.game.set_player_unvote_end_answers(player);
105 });
106
107 events.addEventListener("ambiguities", event => {
108   const ambiguities = JSON.parse(event.data);
109
110   window.game.set_ambiguities(ambiguities);
111 });
112
113 events.addEventListener("player-judged", event => {
114   const player = JSON.parse(event.data);
115
116   window.game.set_player_judged(player);
117 });
118
119 events.addEventListener("player-judging", event => {
120   const player = JSON.parse(event.data);
121
122   window.game.set_player_judging(player);
123 });
124
125 events.addEventListener("vote-end-judging", event => {
126   const player = JSON.parse(event.data);
127
128   window.game.set_player_vote_end_judging(player);
129 });
130
131 events.addEventListener("unvote-end-judging", event => {
132   const player = JSON.parse(event.data);
133
134   window.game.set_player_unvote_end_judging(player);
135 });
136
137 events.addEventListener("scores", event => {
138   const scores = JSON.parse(event.data);
139
140   window.game.set_scores(scores);
141 });
142
143 /*********************************************************
144  * Game and supporting classes                           *
145  *********************************************************/
146
147 function copy_to_clipboard(id)
148 {
149   const tmp = document.createElement("input");
150   tmp.setAttribute("value", document.getElementById(id).innerHTML);
151   document.body.appendChild(tmp);
152   tmp.select();
153   document.execCommand("copy");
154   document.body.removeChild(tmp);
155 }
156
157 const GameInfo = React.memo(props => {
158   if (! props.id)
159     return null;
160
161   return (
162     <div className="game-info">
163       <span className="game-id">{props.id}</span>
164       {" "}
165       Share this link to invite friends:{" "}
166       <span id="game-share-url">{props.url}</span>
167       {" "}
168       <button
169         className="inline"
170         onClick={() => copy_to_clipboard('game-share-url')}
171       >Copy Link</button>
172     </div>
173   );
174 });
175
176 const PlayerInfo = React.memo(props => {
177   if (! props.player.id)
178     return null;
179
180   return (
181     <div className="player-info">
182       <span className="players-header">Players: </span>
183       {props.player.name}
184       {props.player.score > 0 ? ` (${props.player.score})` : ""}
185       {props.other_players.map(other => (
186         <span key={other.id}>
187           {", "}
188           {other.name}
189           {other.score > 0 ? ` (${other.score})` : ""}
190         </span>
191       ))}
192     </div>
193   );
194 });
195
196 function fetch_method_json(method, api = '', data = {}) {
197   const response = fetch(api, {
198     method: method,
199     headers: {
200       'Content-Type': 'application/json'
201     },
202     body: JSON.stringify(data)
203   });
204   return response;
205 }
206
207 function fetch_post_json(api = '', data = {}) {
208   return fetch_method_json('POST', api, data);
209 }
210
211 async function fetch_put_json(api = '', data = {}) {
212   return fetch_method_json('PUT', api, data);
213 }
214
215 class CategoryRequest extends React.PureComponent {
216   constructor(props) {
217     super(props);
218     this.category = React.createRef();
219
220     this.handle_change = this.handle_change.bind(this);
221     this.handle_submit = this.handle_submit.bind(this);
222   }
223
224   handle_change(event) {
225     const category_input = this.category.current;
226     const category = category_input.value;
227
228     const match = category.match(/[0-9]+/);
229     if (match) {
230       const num_items = parseInt(match[0], 10);
231       if (num_items <= MAX_PROMPT_ITEMS)
232         category_input.setCustomValidity("");
233     }
234   }
235
236   async handle_submit(event) {
237     const form = event.currentTarget;
238     const category_input = this.category.current;
239     const category = category_input.value;
240
241     /* Prevent the default page-changing form-submission behavior. */
242     event.preventDefault();
243
244     const match = category.match(/[0-9]+/);
245     if (match === null) {
246       category_input.setCustomValidity("Category must include a number");
247       form.reportValidity();
248       return;
249     }
250
251     const num_items = parseInt(match[0], 10);
252
253     if (num_items > MAX_PROMPT_ITEMS) {
254       category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`);
255       form.reportValidity();
256       return;
257     }
258
259     const response = await fetch_post_json("prompts", {
260       items: num_items,
261       prompt: category
262     });
263
264     if (response.status === 200) {
265       const result = await response.json();
266       if (! result.valid) {
267         add_message("danger", result.message);
268         return;
269       }
270     } else {
271       add_message("danger", "An error occurred submitting your category");
272     }
273
274     form.reset();
275   }
276
277   render() {
278     return (
279       <div className="category-request">
280         <h2>Submit a Category</h2>
281         <p>
282           Suggest a category to play. Don't forget to include the
283           number of items for each person to submit.
284         </p>
285
286         <form onSubmit={this.handle_submit} >
287           <div className="form-field large">
288             <input
289               type="text"
290               id="category"
291               placeholder="6 things at the beach"
292               required
293               autoComplete="off"
294               onChange={this.handle_change}
295               ref={this.category}
296             />
297           </div>
298
299           <div className="form-field large">
300             <button type="submit">
301               Send
302             </button>
303           </div>
304
305         </form>
306       </div>
307     );
308   }
309 }
310
311 const PromptOptions = React.memo(props => {
312
313   if (props.prompts.length === 0)
314     return null;
315
316   return (
317     <div className="prompt-options">
318       <h2>Vote on Categories</h2>
319       <p>
320         Select any categories below that you'd like to play.
321         You can choose as many as you'd like.
322       </p>
323       {props.prompts.map(p => {
324         return (
325           <button
326             className="vote-button"
327             key={p.id}
328             onClick={() => fetch_post_json(`vote/${p.id}`) }
329           >
330             {p.prompt}
331             <div className="vote-choices">
332               {p.votes.map(v => {
333                 return (
334                   <div
335                     key={v}
336                     className="vote-choice"
337                   >
338                     {v}
339                   </div>
340                 );
341               })}
342             </div>
343           </button>
344         );
345       })}
346     </div>
347   );
348 });
349
350 const LetsPlay = React.memo(props => {
351
352   const quorum = Math.round((props.num_players + 1) / 2);
353   const max_votes = props.prompts.reduce(
354     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
355
356   if (max_votes < quorum)
357     return null;
358
359   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
360   const index = Math.floor(Math.random() * candidates.length);
361   const winner = candidates[index];
362
363   return (
364     <div className="lets-play">
365       <h2>Let's Play</h2>
366       <p>
367         That should be enough voting. If you're not waiting for any
368         other players to join, then let's start.
369       </p>
370       <button
371         className="lets-play"
372         onClick={() => fetch_post_json(`start/${winner.id}`) }
373       >
374         Start Game
375       </button>
376     </div>
377   );
378 });
379
380 class Ambiguities extends React.PureComponent {
381
382   constructor(props) {
383     super(props);
384
385     const word_sets = props.words.map(word => {
386       const set = new Set();
387       set.add(word);
388       return set;
389     });
390
391     this.state = {
392       word_sets: word_sets,
393       submitted: false,
394       selected: null
395     };
396
397     this.judging_sent_recently = false;
398   }
399
400   async handle_submit() {
401     const response = await fetch_post_json(
402       `judged/${this.props.prompt.id}`,{
403         word_groups: this.state.word_sets.map(set => Array.from(set))
404       }
405     );
406
407     if (response.status === 200) {
408       const result = await response.json();
409       if (! result.valid) {
410         add_message("danger", result.message);
411         return;
412       }
413     } else {
414       add_message("danger", "An error occurred submitting the results of your judging");
415       return;
416     }
417
418     this.setState({
419       submitted: true
420     });
421   }
422
423   handle_click(word) {
424
425     /* Let the server know we are doing some judging, (but rate limit
426      * this so we don't send a "judging" notification more frquently
427      * than necessary.
428      */
429     if (! this.judging_sent_recently) {
430       fetch_post_json(`judging/${this.props.prompt.id}`);
431       this.judging_sent_recently = true;
432       setTimeout(() => { this.judging_sent_recently = false; }, 1000);
433     }
434
435     if (this.state.selected == word) {
436       /* Second click on same word removes the word from the group. */
437       const idx = this.state.word_sets.findIndex(s => s.has(word));
438       const set = this.state.word_sets[idx];
439       if (set.size === 1) {
440         /* When the word is already alone, there's nothing to do but
441          * to un-select it. */
442         this.setState({
443           selected: null
444         });
445         return;
446       }
447
448       const new_set = new Set([...set].filter(w => w !== word));
449       this.setState({
450         selected: null,
451         word_sets: [...this.state.word_sets.slice(0, idx),
452                     new_set,
453                     new Set().add(word),
454                     ...this.state.word_sets.slice(idx+1)]
455       });
456     } else if (this.state.selected) {
457       /* Click of a second word groups the two together. */
458       const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
459       const idx2 = this.state.word_sets.findIndex(s => s.has(word));
460       const set1 = this.state.word_sets[idx1];
461       const set2 = this.state.word_sets[idx2];
462       const new_set = new Set([...set2, ...set1]);
463       if (idx1 < idx2) {
464         this.setState({
465           selected: null,
466           word_sets: [...this.state.word_sets.slice(0, idx1),
467                       ...this.state.word_sets.slice(idx1 + 1, idx2),
468                       new_set,
469                       ...this.state.word_sets.slice(idx2 + 1)]
470         });
471       } else {
472         this.setState({
473           selected: null,
474           word_sets: [...this.state.word_sets.slice(0, idx2),
475                       new_set,
476                       ...this.state.word_sets.slice(idx2 + 1, idx1),
477                       ...this.state.word_sets.slice(idx1 + 1)]
478         });
479       }
480     } else {
481       /* First click of a word selects it. */
482       this.setState({
483         selected: word
484       });
485     }
486   }
487
488   render() {
489     if (this.state.submitted)
490       return (
491         <div className="please-wait">
492           <h2>Submission received</h2>
493           <p>
494             The following players have completed judging:
495             {[...this.props.players_judged].join(', ')}
496           </p>
497           <p>
498             Still waiting for the following players:
499           </p>
500           <ul>
501             {Object.keys(this.props.players_judging).map(player => {
502               return (
503                 <li
504                   key={player}
505                 >
506                   {player}
507                   {this.props.players_judging[player] ?
508                    <span className="typing"/> : null }
509                 </li>
510               );
511             })}
512           </ul>
513           <button
514             className="vote-button"
515             onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
516           >
517             Move On
518             <div className="vote-choices">
519               {[...this.props.votes].map(v => {
520                 return (
521                   <div
522                     key={v}
523                     className="vote-choice"
524                   >
525                     {v}
526                   </div>
527                 );
528               })}
529             </div>
530           </button>
531
532         </div>
533       );
534
535     const btn_class = "ambiguity-button";
536     const btn_selected_class = btn_class + " selected";
537
538     return (
539       <div className="ambiguities">
540         <h2>Judging Answers</h2>
541         <p>
542           Click on each pair of answers that should be scored as equivalent,
543           (and click any word twice to split it out from a group). Remember,
544           what goes around comes around, so it's best to be generous when
545           judging.
546         </p>
547         {this.state.word_sets.map(set => {
548           return (
549             <div
550               className="ambiguity-group"
551               key={Array.from(set)[0]}
552             >
553               {Array.from(set).map(word => {
554                 return (
555                   <button
556                     className={this.state.selected === word ?
557                                btn_selected_class : btn_class }
558                     key={word}
559                     onClick={() => this.handle_click(word)}
560                   >
561                     {word}
562                   </button>
563                 );
564               })}
565             </div>
566           );
567         })}
568         <p>
569           Click here when done judging:<br/>
570           <button
571             onClick={() => this.handle_submit()}
572           >
573             Send
574           </button>
575         </p>
576       </div>
577     );
578   }
579 }
580
581 class ActivePrompt extends React.PureComponent {
582
583   constructor(props) {
584     super(props);
585     const items = props.prompt.items;
586
587     this.state = {
588       submitted: false
589     };
590
591     this.answers = [...Array(items)].map(() => React.createRef());
592     this.answering_sent_recently = false;
593
594     this.handle_submit = this.handle_submit.bind(this);
595     this.handle_change = this.handle_change.bind(this);
596   }
597
598   handle_change(event) {
599     /* We don't care (or even look) at what the player is typing at
600      * this point. We simply want to be informed that the player _is_
601      * typing so that we can tell the server (which will tell other
602      * players) that there is activity here.
603      */
604
605     /* Rate limit so that we don't send an "answering" notification
606      * more frequently than necessary.
607      */
608     if (! this.answering_sent_recently) {
609       fetch_post_json(`answering/${this.props.prompt.id}`);
610       this.answering_sent_recently = true;
611       setTimeout(() => { this.answering_sent_recently = false; }, 1000);
612     }
613   }
614
615   async handle_submit(event) {
616     const form = event.currentTarget;
617
618     /* Prevent the default page-changing form-submission behavior. */
619     event.preventDefault();
620
621     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
622       answers: this.answers.map(r => r.current.value)
623     });
624     if (response.status === 200) {
625       const result = await response.json();
626       if (! result.valid) {
627         add_message("danger", result.message);
628         return;
629       }
630     } else {
631       add_message("danger", "An error occurred submitting your answers");
632       return;
633     }
634
635     /* Everything worked. Server is happy with our answers. */
636     form.reset();
637     this.setState({
638         submitted: true
639     });
640   }
641
642   render() {
643     if (this.state.submitted)
644       return (
645         <div className="please-wait">
646           <h2>Submission received</h2>
647           <p>
648             The following players have submitted their answers:
649             {[...this.props.players_answered].join(', ')}
650           </p>
651           <p>
652           Still waiting for the following players:
653           </p>
654           <ul>
655             {Object.keys(this.props.players_answering).map(player => {
656               return (
657                 <li
658                   key={player}
659                 >
660                   {player}
661                   {this.props.players_answering[player] ?
662                    <span className="typing"/> : null }
663                 </li>
664               );
665             })}
666           </ul>
667           <button
668             className="vote-button"
669             onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
670           >
671             Move On
672             <div className="vote-choices">
673               {[...this.props.votes].map(v => {
674                 return (
675                   <div
676                     key={v}
677                     className="vote-choice"
678                   >
679                     {v}
680                   </div>
681                 );
682               })}
683             </div>
684           </button>
685
686         </div>
687       );
688
689     return (
690       <div className="active-prompt">
691         <h2>The Game of Empathy</h2>
692         <p>
693           Remember, you're trying to match your answers with
694           what the other players submit.
695           Give {this.props.prompt.items} answers for the following prompt:
696         </p>
697         <h2>{this.props.prompt.prompt}</h2>
698         <form onSubmit={this.handle_submit}>
699           {[...Array(this.props.prompt.items)].map((whocares,i) => {
700             return (
701               <div
702                 key={i}
703                 className="form-field large">
704                 <input
705                   type="text"
706                   name={`answer_${i}`}
707                   required
708                   autoComplete="off"
709                   onChange={this.handle_change}
710                   ref={this.answers[i]}
711                 />
712               </div>
713             );
714           })}
715
716           <div
717             key="submit-button"
718             className="form-field large">
719             <button type="submit">
720               Send
721             </button>
722           </div>
723
724         </form>
725       </div>
726     );
727   }
728 }
729
730 class Game extends React.PureComponent {
731   constructor(props) {
732     super(props);
733     this.state = {
734       game_info: {},
735       player_info: {},
736       other_players: [],
737       prompts: [],
738       active_prompt: null,
739       players_answered: new Set(),
740       players_answering: {},
741       end_answers_votes: new Set(),
742       ambiguities: null,
743       players_judged: new Set(),
744       players_judging: {},
745       end_judging_votes: new Set(),
746       scores: null
747     };
748   }
749
750   set_game_info(info) {
751     this.setState({
752       game_info: info
753     });
754   }
755
756   set_player_info(info) {
757     this.setState({
758       player_info: info
759     });
760   }
761
762   set_other_player_info(info) {
763     const other_players_copy = [...this.state.other_players];
764     const idx = other_players_copy.findIndex(o => o.id === info.id);
765     if (idx >= 0) {
766       other_players_copy[idx] = info;
767     } else {
768       other_players_copy.push(info);
769     }
770     this.setState({
771       other_players: other_players_copy
772     });
773   }
774
775   reset_game_state() {
776     this.setState({
777       prompts: [],
778       active_prompt: null,
779       players_answered: new Set(),
780       players_answering: {},
781       end_answers_votes: new Set(),
782       ambiguities: null,
783       players_judged: new Set(),
784       players_judging: {},
785       end_judging_votes: new Set(),
786       scores: null
787     });
788   }
789
790   set_prompts(prompts) {
791     this.setState({
792       prompts: prompts
793     });
794   }
795
796   add_or_update_prompt(prompt) {
797     const prompts_copy = [...this.state.prompts];
798     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
799     if (idx >= 0) {
800       prompts_copy[idx] = prompt;
801     } else {
802       prompts_copy.push(prompt);
803     }
804     this.setState({
805       prompts: prompts_copy
806     });
807   }
808
809   set_active_prompt(prompt) {
810     this.setState({
811       active_prompt: prompt
812     });
813   }
814
815   set_player_answered(player) {
816     const new_players_answering = {...this.state.players_answering};
817     delete new_players_answering[player];
818
819     this.setState({
820       players_answered: new Set([...this.state.players_answered, player]),
821       players_answering: new_players_answering
822     });
823   }
824
825   set_player_answering(player) {
826     this.setState({
827       players_answering: {
828         ...this.state.players_answering,
829         [player]: {active: true}
830       }
831     });
832   }
833
834   set_player_vote_end_answers(player) {
835     this.setState({
836       end_answers_votes: new Set([...this.state.end_answers_votes, player])
837     });
838   }
839
840   set_player_unvote_end_answers(player) {
841     this.setState({
842       end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
843     });
844   }
845
846   set_ambiguities(ambiguities) {
847     this.setState({
848       ambiguities: ambiguities
849     });
850   }
851
852   set_player_judged(player) {
853     const new_players_judging = {...this.state.players_judging};
854     delete new_players_judging[player];
855
856     this.setState({
857       players_judged: new Set([...this.state.players_judged, player]),
858       players_judging: new_players_judging
859     });
860   }
861
862   set_player_judging(player) {
863     this.setState({
864       players_judging: {
865         ...this.state.players_judging,
866         [player]: {active: true}
867       }
868     });
869   }
870
871
872   set_player_vote_end_judging(player) {
873     this.setState({
874       end_judging_votes: new Set([...this.state.end_judging_votes, player])
875     });
876   }
877
878   set_player_unvote_end_judging(player) {
879     this.setState({
880       end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
881     });
882   }
883
884   set_scores(scores) {
885     this.setState({
886       scores: scores
887     });
888   }
889
890   render() {
891     const state = this.state;
892     const players_total = 1 + state.other_players.length;
893
894     if (state.scores) {
895       return (
896         <div className="scores">
897           <h2>Scores</h2>
898           <ul>
899             {state.scores.scores.map(score => {
900               return (
901                 <li key={score.player}>
902                   {score.player}: {score.score}
903                 </li>
904               );
905             })}
906           </ul>
907           <h2>Words submitted</h2>
908           <ul>
909             {state.scores.words.map(word => {
910               return (
911                 <li key={word.word}>
912                   {`${word.word}: ${word.players.join(', ')}`}
913                 </li>
914               );
915             })}
916           </ul>
917           <button
918             className="new-game"
919             onClick={() => fetch_post_json('reset') }
920           >
921             New Game
922           </button>
923         </div>
924       );
925     }
926
927     if (state.ambiguities){
928       return <Ambiguities
929                prompt={state.active_prompt}
930                words={state.ambiguities}
931                players_judged={state.players_judged}
932                players_judging={state.players_judging}
933                votes={state.end_judging_votes}
934              />;
935     }
936
937     if (state.active_prompt) {
938       return <ActivePrompt
939                prompt={state.active_prompt}
940                players_answered={state.players_answered}
941                players_answering={state.players_answering}
942                votes={state.end_answers_votes}
943              />;
944     }
945
946     return [
947       <GameInfo
948         key="game-info"
949         id={state.game_info.id}
950         url={state.game_info.url}
951       />,
952       <PlayerInfo
953         key="player-info"
954         game={this}
955         player={state.player_info}
956         other_players={state.other_players}
957       />,
958       <p key="spacer"></p>,
959       <CategoryRequest
960         key="category-request"
961       />,
962       <PromptOptions
963         key="prompts"
964         prompts={state.prompts}
965       />,
966       <LetsPlay
967         key="lets-play"
968         num_players={1+state.other_players.length}
969         prompts={state.prompts}
970       />
971     ];
972   }
973 }
974
975 ReactDOM.render(<Game
976                   ref={(me) => window.game = me}
977                 />, document.getElementById("empathy"));