Add support for judging of equivalent answers
[lmno.games] / empathy / empathy.jsx
1 function undisplay(element) {
2   element.style.display="none";
3 }
4
5 function add_message(severity, message) {
6   message = `<div class="message ${severity}" onclick="undisplay(this)">
7 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
8 ${message}
9 </div>`;
10   const message_area = document.getElementById('message-area');
11   message_area.insertAdjacentHTML('beforeend', message);
12 }
13
14 /*********************************************************
15  * Handling server-sent event stream                     *
16  *********************************************************/
17
18 const events = new EventSource("events");
19
20 events.onerror = function(event) {
21   if (event.target.readyState === EventSource.CLOSED) {
22     setTimeout(() => {
23       add_message("danger", "Connection to server lost.");
24     }, 1000);
25   }
26 };
27
28 events.addEventListener("game-info", event => {
29   const info = JSON.parse(event.data);
30
31   window.game.set_game_info(info);
32 });
33
34 events.addEventListener("player-info", event => {
35   const info = JSON.parse(event.data);
36
37   window.game.set_player_info(info);
38 });
39
40 events.addEventListener("player-enter", event => {
41   const info = JSON.parse(event.data);
42
43   window.game.set_other_player_info(info);
44 });
45
46 events.addEventListener("player-update", event => {
47   const info = JSON.parse(event.data);
48
49   if (info.id === window.game.state.player_info.id)
50     window.game.set_player_info(info);
51   else
52     window.game.set_other_player_info(info);
53 });
54
55 events.addEventListener("game-state", event => {
56   const state = JSON.parse(event.data);
57
58   window.game.set_prompts(state.prompts);
59
60   window.game.set_active_prompt(state.active_prompt);
61
62   window.game.set_scores(state.scores);
63
64   window.game.set_ambiguities(state.ambiguities);
65 });
66
67 events.addEventListener("prompt", event => {
68   const prompt = JSON.parse(event.data);
69
70   window.game.add_or_update_prompt(prompt);
71 });
72
73 events.addEventListener("start", event => {
74   const prompt = JSON.parse(event.data);
75
76   window.game.set_active_prompt(prompt);
77 });
78
79 events.addEventListener("answered", event => {
80   const players_answered = JSON.parse(event.data);
81
82   window.game.set_players_answered(players_answered);
83 });
84
85 events.addEventListener("ambiguities", event => {
86   const ambiguities = JSON.parse(event.data);
87
88   window.game.set_ambiguities(ambiguities);
89 });
90
91 events.addEventListener("judged", event => {
92   const players_judged = JSON.parse(event.data);
93
94   window.game.set_players_judged(players_judged);
95 });
96
97 events.addEventListener("scores", event => {
98   const scores = JSON.parse(event.data);
99
100   window.game.set_scores(scores);
101 });
102
103 /*********************************************************
104  * Game and supporting classes                           *
105  *********************************************************/
106
107 function copy_to_clipboard(id)
108 {
109   const tmp = document.createElement("input");
110   tmp.setAttribute("value", document.getElementById(id).innerHTML);
111   document.body.appendChild(tmp);
112   tmp.select();
113   document.execCommand("copy");
114   document.body.removeChild(tmp);
115 }
116
117 const GameInfo = React.memo(props => {
118   if (! props.id)
119     return null;
120
121   return (
122     <div className="game-info">
123       <span className="game-id">{props.id}</span>
124       {" "}
125       Share this link to invite friends:{" "}
126       <span id="game-share-url">{props.url}</span>
127       {" "}
128       <button
129         className="inline"
130         onClick={() => copy_to_clipboard('game-share-url')}
131       >Copy Link</button>
132     </div>
133   );
134 });
135
136 const PlayerInfo = React.memo(props => {
137   if (! props.player.id)
138     return null;
139
140   return (
141     <div className="player-info">
142       <span className="players-header">Players: </span>
143       {props.player.name}
144       {props.player.score > 0 ? ` (${props.player.score})` : ""}
145       {props.other_players.map(other => (
146         <span key={other.id}>
147           {", "}
148           {other.name}
149           {other.score > 0 ? ` (${other.score})` : ""}
150         </span>
151       ))}
152     </div>
153   );
154 });
155
156 function fetch_method_json(method, api = '', data = {}) {
157   const response = fetch(api, {
158     method: method,
159     headers: {
160       'Content-Type': 'application/json'
161     },
162     body: JSON.stringify(data)
163   });
164   return response;
165 }
166
167 function fetch_post_json(api = '', data = {}) {
168   return fetch_method_json('POST', api, data);
169 }
170
171 async function fetch_put_json(api = '', data = {}) {
172   return fetch_method_json('PUT', api, data);
173 }
174
175 class CategoryRequest extends React.PureComponent {
176   constructor(props) {
177     super(props);
178     this.category = React.createRef();
179
180     this.handle_change = this.handle_change.bind(this);
181     this.handle_submit = this.handle_submit.bind(this);
182   }
183
184   handle_change(event) {
185     const category_input = this.category.current;
186     const category = category_input.value;
187
188     if (/[0-9]/.test(category))
189       category_input.setCustomValidity("");
190   }
191
192   handle_submit(event) {
193     const form = event.currentTarget;
194     const category_input = this.category.current;
195     const category = category_input.value;
196
197     /* Prevent the default page-changing form-submission behavior. */
198     event.preventDefault();
199
200     const match = category.match(/[0-9]+/);
201     if (match === null) {
202       category_input.setCustomValidity("Category must include a number");
203       form.reportValidity();
204       return;
205     }
206
207     fetch_post_json("prompts", {
208       items: parseInt(match[0], 10),
209       prompt: category
210     });
211
212     form.reset();
213   }
214
215   render() {
216     return (
217       <div className="category-request">
218         <h2>Submit a Category</h2>
219         <p>
220           Suggest a category to play. Don't forget to include the
221           number of items for each person to submit.
222         </p>
223
224         <form onSubmit={this.handle_submit} >
225           <div className="form-field large">
226             <input
227               type="text"
228               id="category"
229               placeholder="6 things at the beach"
230               required
231               autoComplete="off"
232               onChange={this.handle_change}
233               ref={this.category}
234             />
235           </div>
236
237           <div className="form-field large">
238             <button type="submit">
239               Send
240             </button>
241           </div>
242
243         </form>
244       </div>
245     );
246   }
247 }
248
249 const PromptOptions = React.memo(props => {
250
251   if (props.prompts.length === 0)
252     return null;
253
254   return (
255     <div className="prompt-options">
256       <h2>Vote on Categories</h2>
257       <p>
258         Select any categories below that you'd like to play.
259         You can choose as many as you'd like.
260       </p>
261       {props.prompts.map(p => {
262         return (
263           <button
264             className="vote-button"
265             key={p.id}
266             onClick={() => fetch_post_json(`vote/${p.id}`) }
267           >
268             {p.prompt}
269             <div className="vote-choices">
270               {p.votes.map(v => {
271                 return (
272                   <div
273                     key={v}
274                     className="vote-choice"
275                   >
276                     {v}
277                   </div>
278                 );
279               })}
280             </div>
281           </button>
282         );
283       })}
284     </div>
285   );
286 });
287
288 const LetsPlay = React.memo(props => {
289
290   function handle_click(prompt_id) {
291     fetch_post_json
292   }
293
294   const quorum = Math.round((props.num_players + 1) / 2);
295   const max_votes = props.prompts.reduce(
296     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
297
298   if (max_votes < quorum)
299     return null;
300
301   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
302   const index = Math.floor(Math.random() * candidates.length);
303   const winner = candidates[index];
304
305   return (
306     <div className="lets-play">
307       <h2>Let's Play</h2>
308       <p>
309         That should be enough voting. If you're not waiting for any
310         other players to join, then let's start.
311       </p>
312       <button
313         className="lets-play"
314         onClick={() => fetch_post_json(`start/${winner.id}`) }
315       >
316         Start Game
317       </button>
318     </div>
319   );
320 });
321
322 class Ambiguities extends React.PureComponent {
323
324   constructor(props) {
325     super(props);
326
327     this.state = {
328       word_groups: props.words.map(word => [word]),
329       submitted: false,
330       selected: null
331     };
332   }
333
334   async handle_submit() {
335     const response = await fetch_post_json(`judging/${this.props.prompt.id}`,
336                                            this.state.word_groups);
337
338     if (response.status === 200) {
339       const result = await response.json();
340       if (! result.valid) {
341         add_message("danger", result.message);
342         return;
343       }
344     } else {
345       add_message("danger", "An error occurred submitting your answers");
346       return;
347     }
348
349     this.setState({
350       submitted: true
351     });
352   }
353
354   handle_click(word) {
355     if (this.state.selected == word) {
356       /* Second click on same word removes the word from the group. */
357       const new_groups = this.state.word_groups.filter(
358         group => (! group.includes(this.state.selected)) || (group.length > 1)).map(
359           group => {
360             return group.filter(w => w !== this.state.selected);
361           }
362         );
363       this.setState({
364         selected: null,
365         word_groups: [...new_groups, [word]]
366       });
367     } else if (this.state.selected) {
368       /* Click of a second word groups the two together. */
369       const new_groups = this.state.word_groups.filter(
370         group => (! group.includes(word)) || (group.length > 1)).map(
371           group => {
372             if (group.includes(this.state.selected)) {
373               if (! group.includes(word))
374                 return [...group, word];
375               else
376                 return group;
377             } else {
378               return group.filter(w => w !== word);
379             }
380           }
381         );
382       this.setState({
383         selected: null,
384         word_groups: new_groups
385       });
386     } else {
387       /* First click of a word selects it. */
388       this.setState({
389         selected: word
390       });
391     }
392   }
393
394   render() {
395     if (this.state.submitted)
396       return (
397         <div className="please-wait">
398           <h2>{this.props.players_judged}/
399             {this.props.players_total} players have responded</h2>
400           <p>
401             Please wait for the rest of the players to complete judging.
402           </p>
403         </div>
404       );
405
406     const btn_class = "ambiguity-button";
407     const btn_selected_class = btn_class + " selected";
408
409     return (
410       <div className="ambiguities">
411         <h2>Judging Answers</h2>
412         <p>
413           Click on each pair of answers that should be scored as equivalent,
414           (and click any word twice to split it out from a group). Remember,
415           what goes around comes around, so it's best to be generous when
416           judging.
417         </p>
418         {this.state.word_groups.map(word_group => {
419           return (
420             <div
421               className="ambiguity-group"
422               key={word_group[0]}
423             >
424             {word_group.map(word => {
425               return (
426                 <button
427                 className={this.state.selected === word ?
428                            btn_selected_class : btn_class }
429                 key={word}
430                 onClick={() => this.handle_click(word)}
431                   >
432                 {word}
433                 </button>
434               );
435             })}
436             </div>
437           );
438         })}
439         <p>
440           Click here when done judging:<br/>
441           <button
442             onClick={() => this.handle_submit()}
443           >
444             Send
445           </button>
446         </p>
447       </div>
448     );
449   }
450 }
451
452 class ActivePrompt extends React.PureComponent {
453
454   constructor(props) {
455     super(props);
456     const items = props.prompt.items;
457
458     this.state = {
459       submitted: false
460     };
461
462     this.answers = [...Array(items)].map(() => React.createRef());
463     this.handle_submit = this.handle_submit.bind(this);
464   }
465
466   async handle_submit(event) {
467     const form = event.currentTarget;
468
469     /* Prevent the default page-changing form-submission behavior. */
470     event.preventDefault();
471
472     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
473       answers: this.answers.map(r => r.current.value)
474     });
475     if (response.status === 200) {
476       const result = await response.json();
477       if (! result.valid) {
478         add_message("danger", result.message);
479         return;
480       }
481     } else {
482       add_message("danger", "An error occurred submitting your answers");
483       return;
484     }
485
486     /* Everything worked. Server is happy with our answers. */
487     form.reset();
488     this.setState({
489         submitted: true
490     });
491   }
492
493   render() {
494     if (this.state.submitted)
495       return (
496         <div className="please-wait">
497           <h2>{this.props.players_answered}/
498             {this.props.players_total} players have responded</h2>
499           <p>
500             Please wait for the rest of the players to submit their answers.
501           </p>
502         </div>
503       );
504
505     return (
506       <div className="active-prompt">
507         <h2>The Game of Empathy</h2>
508         <p>
509           Remember, you're trying to match your answers with
510           what the other players submit.
511           Give {this.props.prompt.items} answers for the following prompt:
512         </p>
513         <h2>{this.props.prompt.prompt}</h2>
514         <form onSubmit={this.handle_submit}>
515           {[...Array(this.props.prompt.items)].map((whocares,i) => {
516             return (
517               <div
518                 key={i}
519                 className="form-field large">
520                 <input
521                   type="text"
522                   name={`answer_${i}`}
523                   required
524                   autoComplete="off"
525                   ref={this.answers[i]}
526                 />
527               </div>
528             );
529           })}
530
531           <div
532             key="submit-button"
533             className="form-field large">
534             <button type="submit">
535               Send
536             </button>
537           </div>
538
539         </form>
540       </div>
541     );
542   }
543 }
544
545 class Game extends React.PureComponent {
546   constructor(props) {
547     super(props);
548     this.state = {
549       game_info: {},
550       player_info: {},
551       other_players: [],
552       prompts: [],
553       active_prompt: null,
554       players_answered: 0,
555       ambiguities: null,
556       players_judged: 0
557     };
558   }
559
560   set_game_info(info) {
561     this.setState({
562       game_info: info
563     });
564   }
565
566   set_player_info(info) {
567     this.setState({
568       player_info: info
569     });
570   }
571
572   set_other_player_info(info) {
573     const other_players_copy = [...this.state.other_players];
574     const idx = other_players_copy.findIndex(o => o.id === info.id);
575     if (idx >= 0) {
576       other_players_copy[idx] = info;
577     } else {
578       other_players_copy.push(info);
579     }
580     this.setState({
581       other_players: other_players_copy
582     });
583   }
584
585   set_prompts(prompts) {
586     this.setState({
587       prompts: prompts
588     });
589   }
590
591   add_or_update_prompt(prompt) {
592     const prompts_copy = [...this.state.prompts];
593     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
594     if (idx >= 0) {
595       prompts_copy[idx] = prompt;
596     } else {
597       prompts_copy.push(prompt);
598     }
599     this.setState({
600       prompts: prompts_copy
601     });
602   }
603
604   set_active_prompt(prompt) {
605     this.setState({
606       active_prompt: prompt
607     });
608   }
609
610   set_players_answered(players_answered) {
611     this.setState({
612       players_answered: players_answered
613     });
614   }
615
616   set_ambiguities(ambiguities) {
617     this.setState({
618       ambiguities: ambiguities
619     });
620   }
621
622   set_players_judged(players_judged) {
623     this.setState({
624       players_judged: players_judged
625     });
626   }
627
628   set_scores(scores) {
629     this.setState({
630       scores: scores
631     });
632   }
633
634   render() {
635     const state = this.state;
636     const players_total = 1 + state.other_players.length;
637
638     if (state.scores) {
639       return (
640         <div className="scores">
641           <h2>Scores</h2>
642           <ul>
643             {state.scores.scores.map(score => {
644               return (
645                 <li key={score.player}>
646                   {score.player}: {score.score}
647                 </li>
648               );
649             })}
650           </ul>
651           <h2>Words submitted</h2>
652           <ul>
653             {state.scores.words.map(word => {
654               return (
655                 <li key={word.word}>
656                   {word.word}:
657                   {word.players.map(p => {
658                     return (
659                       <span key={p}>{p}{" "}</span>
660                     );
661                   })}
662                 </li>
663               );
664             })}
665           </ul>
666           <button
667             className="new-game"
668             onClick={() => fetch_post_json('reset') }
669           >
670             New Game
671           </button>
672         </div>
673       );
674     }
675
676     if (state.ambiguities){
677       return <Ambiguities
678                prompt={state.active_prompt}
679                words={state.ambiguities}
680                players_judged={state.players_judged}
681                players_total={players_total}
682              />;
683     }
684
685     if (state.active_prompt) {
686       return <ActivePrompt
687                prompt={state.active_prompt}
688                players_answered={state.players_answered}
689                players_total={players_total}
690              />;
691     }
692
693     return [
694       <GameInfo
695         key="game-info"
696         id={state.game_info.id}
697         url={state.game_info.url}
698       />,
699       <PlayerInfo
700         key="player-info"
701         game={this}
702         player={state.player_info}
703         other_players={state.other_players}
704       />,
705       <p key="spacer"></p>,
706       <CategoryRequest
707         key="category-request"
708       />,
709       <PromptOptions
710         key="prompts"
711         prompts={state.prompts}
712       />,
713       <LetsPlay
714         key="lets-play"
715         num_players={1+state.other_players.length}
716         prompts={state.prompts}
717       />
718     ];
719   }
720 }
721
722 ReactDOM.render(<Game
723                   ref={(me) => window.game = me}
724                 />, document.getElementById("empathy"));