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