Add (rate-limited) posts to the /answering and /judging endpoints
[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         return;
441       const new_set = new Set([...set].filter(w => w !== word));
442       this.setState({
443         selected: null,
444         word_sets: [...this.state.word_sets.slice(0, idx),
445                     new_set,
446                     new Set().add(word),
447                     ...this.state.word_sets.slice(idx+1)]
448       });
449     } else if (this.state.selected) {
450       /* Click of a second word groups the two together. */
451       const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
452       const idx2 = this.state.word_sets.findIndex(s => s.has(word));
453       const set1 = this.state.word_sets[idx1];
454       const set2 = this.state.word_sets[idx2];
455       const new_set = new Set([...set2, ...set1]);
456       if (idx1 < idx2) {
457         this.setState({
458           selected: null,
459           word_sets: [...this.state.word_sets.slice(0, idx1),
460                       ...this.state.word_sets.slice(idx1 + 1, idx2),
461                       new_set,
462                       ...this.state.word_sets.slice(idx2 + 1)]
463         });
464       } else {
465         this.setState({
466           selected: null,
467           word_sets: [...this.state.word_sets.slice(0, idx2),
468                       new_set,
469                       ...this.state.word_sets.slice(idx2 + 1, idx1),
470                       ...this.state.word_sets.slice(idx1 + 1)]
471         });
472       }
473     } else {
474       /* First click of a word selects it. */
475       this.setState({
476         selected: word
477       });
478     }
479   }
480
481   render() {
482     if (this.state.submitted)
483       return (
484         <div className="please-wait">
485           <h2>Submission received</h2>
486           <p>
487             The following players have completed judging:
488             {[...this.props.players_judged].join(', ')}
489           </p>
490           <p>
491             Still waiting for the following players:
492           </p>
493           <ul>
494             {Object.keys(this.props.players_judging).map(player => {
495               return (
496                 <li
497                   key={player}
498                 >
499                   {player}
500                   {this.props.players_judging[player] ?
501                    <span className="typing"/> : null }
502                 </li>
503               );
504             })}
505           </ul>
506           <button
507             className="vote-button"
508             onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
509           >
510             Move On
511             <div className="vote-choices">
512               {[...this.props.votes].map(v => {
513                 return (
514                   <div
515                     key={v}
516                     className="vote-choice"
517                   >
518                     {v}
519                   </div>
520                 );
521               })}
522             </div>
523           </button>
524
525         </div>
526       );
527
528     const btn_class = "ambiguity-button";
529     const btn_selected_class = btn_class + " selected";
530
531     return (
532       <div className="ambiguities">
533         <h2>Judging Answers</h2>
534         <p>
535           Click on each pair of answers that should be scored as equivalent,
536           (and click any word twice to split it out from a group). Remember,
537           what goes around comes around, so it's best to be generous when
538           judging.
539         </p>
540         {this.state.word_sets.map(set => {
541           return (
542             <div
543               className="ambiguity-group"
544               key={Array.from(set)[0]}
545             >
546               {Array.from(set).map(word => {
547                 return (
548                   <button
549                     className={this.state.selected === word ?
550                                btn_selected_class : btn_class }
551                     key={word}
552                     onClick={() => this.handle_click(word)}
553                   >
554                     {word}
555                   </button>
556                 );
557               })}
558             </div>
559           );
560         })}
561         <p>
562           Click here when done judging:<br/>
563           <button
564             onClick={() => this.handle_submit()}
565           >
566             Send
567           </button>
568         </p>
569       </div>
570     );
571   }
572 }
573
574 class ActivePrompt extends React.PureComponent {
575
576   constructor(props) {
577     super(props);
578     const items = props.prompt.items;
579
580     this.state = {
581       submitted: false
582     };
583
584     this.answers = [...Array(items)].map(() => React.createRef());
585     this.answering_sent_recently = false;
586
587     this.handle_submit = this.handle_submit.bind(this);
588     this.handle_change = this.handle_change.bind(this);
589   }
590
591   handle_change(event) {
592     /* We don't care (or even look) at what the player is typing at
593      * this point. We simply want to be informed that the player _is_
594      * typing so that we can tell the server (which will tell other
595      * players) that there is activity here.
596      */
597
598     /* Rate limit so that we don't send an "answering" notification
599      * more frequently than necessary.
600      */
601     if (! this.answering_sent_recently) {
602       fetch_post_json(`answering/${this.props.prompt.id}`);
603       this.answering_sent_recently = true;
604       setTimeout(() => { this.answering_sent_recently = false; }, 1000);
605     }
606   }
607
608   async handle_submit(event) {
609     const form = event.currentTarget;
610
611     /* Prevent the default page-changing form-submission behavior. */
612     event.preventDefault();
613
614     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
615       answers: this.answers.map(r => r.current.value)
616     });
617     if (response.status === 200) {
618       const result = await response.json();
619       if (! result.valid) {
620         add_message("danger", result.message);
621         return;
622       }
623     } else {
624       add_message("danger", "An error occurred submitting your answers");
625       return;
626     }
627
628     /* Everything worked. Server is happy with our answers. */
629     form.reset();
630     this.setState({
631         submitted: true
632     });
633   }
634
635   render() {
636     if (this.state.submitted)
637       return (
638         <div className="please-wait">
639           <h2>Submission received</h2>
640           <p>
641             The following players have submitted their answers:
642             {[...this.props.players_answered].join(', ')}
643           </p>
644           <p>
645           Still waiting for the following players:
646           </p>
647           <ul>
648             {Object.keys(this.props.players_answering).map(player => {
649               return (
650                 <li
651                   key={player}
652                 >
653                   {player}
654                   {this.props.players_answering[player] ?
655                    <span className="typing"/> : null }
656                 </li>
657               );
658             })}
659           </ul>
660           <button
661             className="vote-button"
662             onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
663           >
664             Move On
665             <div className="vote-choices">
666               {[...this.props.votes].map(v => {
667                 return (
668                   <div
669                     key={v}
670                     className="vote-choice"
671                   >
672                     {v}
673                   </div>
674                 );
675               })}
676             </div>
677           </button>
678
679         </div>
680       );
681
682     return (
683       <div className="active-prompt">
684         <h2>The Game of Empathy</h2>
685         <p>
686           Remember, you're trying to match your answers with
687           what the other players submit.
688           Give {this.props.prompt.items} answers for the following prompt:
689         </p>
690         <h2>{this.props.prompt.prompt}</h2>
691         <form onSubmit={this.handle_submit}>
692           {[...Array(this.props.prompt.items)].map((whocares,i) => {
693             return (
694               <div
695                 key={i}
696                 className="form-field large">
697                 <input
698                   type="text"
699                   name={`answer_${i}`}
700                   required
701                   autoComplete="off"
702                   onChange={this.handle_change}
703                   ref={this.answers[i]}
704                 />
705               </div>
706             );
707           })}
708
709           <div
710             key="submit-button"
711             className="form-field large">
712             <button type="submit">
713               Send
714             </button>
715           </div>
716
717         </form>
718       </div>
719     );
720   }
721 }
722
723 class Game extends React.PureComponent {
724   constructor(props) {
725     super(props);
726     this.state = {
727       game_info: {},
728       player_info: {},
729       other_players: [],
730       prompts: [],
731       active_prompt: null,
732       players_answered: new Set(),
733       players_answering: {},
734       end_answers_votes: new Set(),
735       ambiguities: null,
736       players_judged: new Set(),
737       players_judging: {},
738       end_judging_votes: new Set(),
739       scores: null
740     };
741   }
742
743   set_game_info(info) {
744     this.setState({
745       game_info: info
746     });
747   }
748
749   set_player_info(info) {
750     this.setState({
751       player_info: info
752     });
753   }
754
755   set_other_player_info(info) {
756     const other_players_copy = [...this.state.other_players];
757     const idx = other_players_copy.findIndex(o => o.id === info.id);
758     if (idx >= 0) {
759       other_players_copy[idx] = info;
760     } else {
761       other_players_copy.push(info);
762     }
763     this.setState({
764       other_players: other_players_copy
765     });
766   }
767
768   reset_game_state() {
769     this.setState({
770       prompts: [],
771       active_prompt: null,
772       players_answered: new Set(),
773       players_answering: {},
774       end_answers_votes: new Set(),
775       ambiguities: null,
776       players_judged: new Set(),
777       players_judging: {},
778       end_judging_votes: new Set(),
779       scores: null
780     });
781   }
782
783   set_prompts(prompts) {
784     this.setState({
785       prompts: prompts
786     });
787   }
788
789   add_or_update_prompt(prompt) {
790     const prompts_copy = [...this.state.prompts];
791     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
792     if (idx >= 0) {
793       prompts_copy[idx] = prompt;
794     } else {
795       prompts_copy.push(prompt);
796     }
797     this.setState({
798       prompts: prompts_copy
799     });
800   }
801
802   set_active_prompt(prompt) {
803     this.setState({
804       active_prompt: prompt
805     });
806   }
807
808   set_player_answered(player) {
809     const new_players_answering = {...this.state.players_answering};
810     delete new_players_answering[player];
811
812     this.setState({
813       players_answered: new Set([...this.state.players_answered, player]),
814       players_answering: new_players_answering
815     });
816   }
817
818   set_player_answering(player) {
819     this.setState({
820       players_answering: {
821         ...this.state.players_answering,
822         [player]: {active: true}
823       }
824     });
825   }
826
827   set_player_vote_end_answers(player) {
828     this.setState({
829       end_answers_votes: new Set([...this.state.end_answers_votes, player])
830     });
831   }
832
833   set_player_unvote_end_answers(player) {
834     this.setState({
835       end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
836     });
837   }
838
839   set_ambiguities(ambiguities) {
840     this.setState({
841       ambiguities: ambiguities
842     });
843   }
844
845   set_player_judged(player) {
846     const new_players_judging = {...this.state.players_judging};
847     delete new_players_judging[player];
848
849     this.setState({
850       players_judged: new Set([...this.state.players_judged, player]),
851       players_judging: new_players_judging
852     });
853   }
854
855   set_player_judging(player) {
856     this.setState({
857       players_judging: {
858         ...this.state.players_judging,
859         [player]: {active: true}
860       }
861     });
862   }
863
864
865   set_player_vote_end_judging(player) {
866     this.setState({
867       end_judging_votes: new Set([...this.state.end_judging_votes, player])
868     });
869   }
870
871   set_player_unvote_end_judging(player) {
872     this.setState({
873       end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
874     });
875   }
876
877   set_scores(scores) {
878     this.setState({
879       scores: scores
880     });
881   }
882
883   render() {
884     const state = this.state;
885     const players_total = 1 + state.other_players.length;
886
887     if (state.scores) {
888       return (
889         <div className="scores">
890           <h2>Scores</h2>
891           <ul>
892             {state.scores.scores.map(score => {
893               return (
894                 <li key={score.player}>
895                   {score.player}: {score.score}
896                 </li>
897               );
898             })}
899           </ul>
900           <h2>Words submitted</h2>
901           <ul>
902             {state.scores.words.map(word => {
903               return (
904                 <li key={word.word}>
905                   {`${word.word}: ${word.players.join(', ')}`}
906                 </li>
907               );
908             })}
909           </ul>
910           <button
911             className="new-game"
912             onClick={() => fetch_post_json('reset') }
913           >
914             New Game
915           </button>
916         </div>
917       );
918     }
919
920     if (state.ambiguities){
921       return <Ambiguities
922                prompt={state.active_prompt}
923                words={state.ambiguities}
924                players_judged={state.players_judged}
925                players_judging={state.players_judging}
926                votes={state.end_judging_votes}
927              />;
928     }
929
930     if (state.active_prompt) {
931       return <ActivePrompt
932                prompt={state.active_prompt}
933                players_answered={state.players_answered}
934                players_answering={state.players_answering}
935                votes={state.end_answers_votes}
936              />;
937     }
938
939     return [
940       <GameInfo
941         key="game-info"
942         id={state.game_info.id}
943         url={state.game_info.url}
944       />,
945       <PlayerInfo
946         key="player-info"
947         game={this}
948         player={state.player_info}
949         other_players={state.other_players}
950       />,
951       <p key="spacer"></p>,
952       <CategoryRequest
953         key="category-request"
954       />,
955       <PromptOptions
956         key="prompts"
957         prompts={state.prompts}
958       />,
959       <LetsPlay
960         key="lets-play"
961         num_players={1+state.other_players.length}
962         prompts={state.prompts}
963       />
964     ];
965   }
966 }
967
968 ReactDOM.render(<Game
969                   ref={(me) => window.game = me}
970                 />, document.getElementById("empathy"));