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