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