empathy: Add a comman between player names
[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(
336       `judging/${this.props.prompt.id}`,{
337         word_groups: this.state.word_groups
338       }
339     );
340
341     if (response.status === 200) {
342       const result = await response.json();
343       if (! result.valid) {
344         add_message("danger", result.message);
345         return;
346       }
347     } else {
348       add_message("danger", "An error occurred submitting your answers");
349       return;
350     }
351
352     this.setState({
353       submitted: true
354     });
355   }
356
357   handle_click(word) {
358     if (this.state.selected == word) {
359       /* Second click on same word removes the word from the group. */
360       const new_groups = this.state.word_groups.filter(
361         group => (! group.includes(this.state.selected)) || (group.length > 1)).map(
362           group => {
363             return group.filter(w => w !== this.state.selected);
364           }
365         );
366       this.setState({
367         selected: null,
368         word_groups: [...new_groups, [word]]
369       });
370     } else if (this.state.selected) {
371       /* Click of a second word groups the two together. */
372       const new_groups = this.state.word_groups.filter(
373         group => (! group.includes(word)) || (group.length > 1)).map(
374           group => {
375             if (group.includes(this.state.selected)) {
376               if (! group.includes(word))
377                 return [...group, word];
378               else
379                 return group;
380             } else {
381               return group.filter(w => w !== word);
382             }
383           }
384         );
385       this.setState({
386         selected: null,
387         word_groups: new_groups
388       });
389     } else {
390       /* First click of a word selects it. */
391       this.setState({
392         selected: word
393       });
394     }
395   }
396
397   render() {
398     if (this.state.submitted)
399       return (
400         <div className="please-wait">
401           <h2>{this.props.players_judged}/
402             {this.props.players_total} players have responded</h2>
403           <p>
404             Please wait for the rest of the players to complete judging.
405           </p>
406         </div>
407       );
408
409     const btn_class = "ambiguity-button";
410     const btn_selected_class = btn_class + " selected";
411
412     return (
413       <div className="ambiguities">
414         <h2>Judging Answers</h2>
415         <p>
416           Click on each pair of answers that should be scored as equivalent,
417           (and click any word twice to split it out from a group). Remember,
418           what goes around comes around, so it's best to be generous when
419           judging.
420         </p>
421         {this.state.word_groups.map(word_group => {
422           return (
423             <div
424               className="ambiguity-group"
425               key={word_group[0]}
426             >
427             {word_group.map(word => {
428               return (
429                 <button
430                 className={this.state.selected === word ?
431                            btn_selected_class : btn_class }
432                 key={word}
433                 onClick={() => this.handle_click(word)}
434                   >
435                 {word}
436                 </button>
437               );
438             })}
439             </div>
440           );
441         })}
442         <p>
443           Click here when done judging:<br/>
444           <button
445             onClick={() => this.handle_submit()}
446           >
447             Send
448           </button>
449         </p>
450       </div>
451     );
452   }
453 }
454
455 class ActivePrompt extends React.PureComponent {
456
457   constructor(props) {
458     super(props);
459     const items = props.prompt.items;
460
461     this.state = {
462       submitted: false
463     };
464
465     this.answers = [...Array(items)].map(() => React.createRef());
466     this.handle_submit = this.handle_submit.bind(this);
467   }
468
469   async handle_submit(event) {
470     const form = event.currentTarget;
471
472     /* Prevent the default page-changing form-submission behavior. */
473     event.preventDefault();
474
475     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
476       answers: this.answers.map(r => r.current.value)
477     });
478     if (response.status === 200) {
479       const result = await response.json();
480       if (! result.valid) {
481         add_message("danger", result.message);
482         return;
483       }
484     } else {
485       add_message("danger", "An error occurred submitting your answers");
486       return;
487     }
488
489     /* Everything worked. Server is happy with our answers. */
490     form.reset();
491     this.setState({
492         submitted: true
493     });
494   }
495
496   render() {
497     if (this.state.submitted)
498       return (
499         <div className="please-wait">
500           <h2>{this.props.players_answered}/
501             {this.props.players_total} players have responded</h2>
502           <p>
503             Please wait for the rest of the players to submit their answers.
504           </p>
505         </div>
506       );
507
508     return (
509       <div className="active-prompt">
510         <h2>The Game of Empathy</h2>
511         <p>
512           Remember, you're trying to match your answers with
513           what the other players submit.
514           Give {this.props.prompt.items} answers for the following prompt:
515         </p>
516         <h2>{this.props.prompt.prompt}</h2>
517         <form onSubmit={this.handle_submit}>
518           {[...Array(this.props.prompt.items)].map((whocares,i) => {
519             return (
520               <div
521                 key={i}
522                 className="form-field large">
523                 <input
524                   type="text"
525                   name={`answer_${i}`}
526                   required
527                   autoComplete="off"
528                   ref={this.answers[i]}
529                 />
530               </div>
531             );
532           })}
533
534           <div
535             key="submit-button"
536             className="form-field large">
537             <button type="submit">
538               Send
539             </button>
540           </div>
541
542         </form>
543       </div>
544     );
545   }
546 }
547
548 class Game extends React.PureComponent {
549   constructor(props) {
550     super(props);
551     this.state = {
552       game_info: {},
553       player_info: {},
554       other_players: [],
555       prompts: [],
556       active_prompt: null,
557       players_answered: 0,
558       ambiguities: null,
559       players_judged: 0
560     };
561   }
562
563   set_game_info(info) {
564     this.setState({
565       game_info: info
566     });
567   }
568
569   set_player_info(info) {
570     this.setState({
571       player_info: info
572     });
573   }
574
575   set_other_player_info(info) {
576     const other_players_copy = [...this.state.other_players];
577     const idx = other_players_copy.findIndex(o => o.id === info.id);
578     if (idx >= 0) {
579       other_players_copy[idx] = info;
580     } else {
581       other_players_copy.push(info);
582     }
583     this.setState({
584       other_players: other_players_copy
585     });
586   }
587
588   set_prompts(prompts) {
589     this.setState({
590       prompts: prompts
591     });
592   }
593
594   add_or_update_prompt(prompt) {
595     const prompts_copy = [...this.state.prompts];
596     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
597     if (idx >= 0) {
598       prompts_copy[idx] = prompt;
599     } else {
600       prompts_copy.push(prompt);
601     }
602     this.setState({
603       prompts: prompts_copy
604     });
605   }
606
607   set_active_prompt(prompt) {
608     this.setState({
609       active_prompt: prompt
610     });
611   }
612
613   set_players_answered(players_answered) {
614     this.setState({
615       players_answered: players_answered
616     });
617   }
618
619   set_ambiguities(ambiguities) {
620     this.setState({
621       ambiguities: ambiguities
622     });
623   }
624
625   set_players_judged(players_judged) {
626     this.setState({
627       players_judged: players_judged
628     });
629   }
630
631   set_scores(scores) {
632     this.setState({
633       scores: scores
634     });
635   }
636
637   render() {
638     const state = this.state;
639     const players_total = 1 + state.other_players.length;
640
641     if (state.scores) {
642       return (
643         <div className="scores">
644           <h2>Scores</h2>
645           <ul>
646             {state.scores.scores.map(score => {
647               return (
648                 <li key={score.player}>
649                   {score.player}: {score.score}
650                 </li>
651               );
652             })}
653           </ul>
654           <h2>Words submitted</h2>
655           <ul>
656             {state.scores.words.map(word => {
657               return (
658                 <li key={word.word}>
659                   {`${word.word}: ${word.players.join(', ')}`}
660                 </li>
661               );
662             })}
663           </ul>
664           <button
665             className="new-game"
666             onClick={() => fetch_post_json('reset') }
667           >
668             New Game
669           </button>
670         </div>
671       );
672     }
673
674     if (state.ambiguities){
675       return <Ambiguities
676                prompt={state.active_prompt}
677                words={state.ambiguities}
678                players_judged={state.players_judged}
679                players_total={players_total}
680              />;
681     }
682
683     if (state.active_prompt) {
684       return <ActivePrompt
685                prompt={state.active_prompt}
686                players_answered={state.players_answered}
687                players_total={players_total}
688              />;
689     }
690
691     return [
692       <GameInfo
693         key="game-info"
694         id={state.game_info.id}
695         url={state.game_info.url}
696       />,
697       <PlayerInfo
698         key="player-info"
699         game={this}
700         player={state.player_info}
701         other_players={state.other_players}
702       />,
703       <p key="spacer"></p>,
704       <CategoryRequest
705         key="category-request"
706       />,
707       <PromptOptions
708         key="prompts"
709         prompts={state.prompts}
710       />,
711       <LetsPlay
712         key="lets-play"
713         num_players={1+state.other_players.length}
714         prompts={state.prompts}
715       />
716     ];
717   }
718 }
719
720 ReactDOM.render(<Game
721                   ref={(me) => window.game = me}
722                 />, document.getElementById("empathy"));