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