Empathy: When receiving a game-state event overwrite all prompts
[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
65 events.addEventListener("prompt", event => {
66   const prompt = JSON.parse(event.data);
67
68   window.game.add_or_update_prompt(prompt);
69 });
70
71 events.addEventListener("start", event => {
72   const prompt = JSON.parse(event.data);
73
74   window.game.set_active_prompt(prompt);
75 });
76
77 events.addEventListener("answered", event => {
78   const players_answered = JSON.parse(event.data);
79
80   window.game.set_players_answered(players_answered);
81 });
82
83 events.addEventListener("scores", event => {
84   const scores = JSON.parse(event.data);
85
86   window.game.set_scores(scores);
87 });
88
89 /*********************************************************
90  * Game and supporting classes                           *
91  *********************************************************/
92
93 function copy_to_clipboard(id)
94 {
95   const tmp = document.createElement("input");
96   tmp.setAttribute("value", document.getElementById(id).innerHTML);
97   document.body.appendChild(tmp);
98   tmp.select();
99   document.execCommand("copy");
100   document.body.removeChild(tmp);
101 }
102
103 const GameInfo = React.memo(props => {
104   if (! props.id)
105     return null;
106
107   return (
108     <div className="game-info">
109       <span className="game-id">{props.id}</span>
110       {" "}
111       Share this link to invite friends:{" "}
112       <span id="game-share-url">{props.url}</span>
113       {" "}
114       <button
115         className="inline"
116         onClick={() => copy_to_clipboard('game-share-url')}
117       >Copy Link</button>
118     </div>
119   );
120 });
121
122 const PlayerInfo = React.memo(props => {
123   if (! props.player.id)
124     return null;
125
126   return (
127     <div className="player-info">
128       <span className="players-header">Players: </span>
129       {props.player.name}
130       {props.other_players.map(other => (
131         <span key={other.id}>
132           {", "}
133           {other.name}
134         </span>
135       ))}
136     </div>
137   );
138 });
139
140 function fetch_method_json(method, api = '', data = {}) {
141   const response = fetch(api, {
142     method: method,
143     headers: {
144       'Content-Type': 'application/json'
145     },
146     body: JSON.stringify(data)
147   });
148   return response;
149 }
150
151 function fetch_post_json(api = '', data = {}) {
152   return fetch_method_json('POST', api, data);
153 }
154
155 async function fetch_put_json(api = '', data = {}) {
156   return fetch_method_json('PUT', api, data);
157 }
158
159 class CategoryRequest extends React.PureComponent {
160   constructor(props) {
161     super(props);
162     this.category = React.createRef();
163
164     this.handle_change = this.handle_change.bind(this);
165     this.handle_submit = this.handle_submit.bind(this);
166   }
167
168   handle_change(event) {
169     const category_input = this.category.current;
170     const category = category_input.value;
171
172     if (/[0-9]/.test(category))
173       category_input.setCustomValidity("");
174   }
175
176   handle_submit(event) {
177     const form = event.currentTarget;
178     const category_input = this.category.current;
179     const category = category_input.value;
180
181     /* Prevent the default page-changing form-submission behavior. */
182     event.preventDefault();
183
184     const match = category.match(/[0-9]+/);
185     if (match === null) {
186       category_input.setCustomValidity("Category must include a number");
187       form.reportValidity();
188       return;
189     }
190
191     fetch_post_json("prompts", {
192       items: parseInt(match[0], 10),
193       prompt: category
194     });
195
196     form.reset();
197   }
198
199   render() {
200     return (
201       <div className="category-request">
202         <h2>Submit a Category</h2>
203         <p>
204           Suggest a category to play. Don't forget to include the
205           number of items for each person to submit.
206         </p>
207
208         <form onSubmit={this.handle_submit} >
209           <div className="form-field large">
210             <input
211               type="text"
212               id="category"
213               placeholder="6 things at the beach"
214               required
215               autoComplete="off"
216               onChange={this.handle_change}
217               ref={this.category}
218             />
219           </div>
220
221           <div className="form-field large">
222             <button type="submit">
223               Send
224             </button>
225           </div>
226
227         </form>
228       </div>
229     );
230   }
231 }
232
233 const PromptOptions = React.memo(props => {
234
235   if (props.prompts.length === 0)
236     return null;
237
238   return (
239     <div className="prompt-options">
240       <h2>Vote on Categories</h2>
241       <p>
242         Select any categories below that you'd like to play.
243         You can choose as many as you'd like.
244       </p>
245       {props.prompts.map(p => {
246         return (
247           <button
248             className="vote-button"
249             key={p.id}
250             onClick={() => fetch_post_json(`vote/${p.id}`) }
251           >
252             {p.prompt}
253             <div className="vote-choices">
254               {p.votes.map(v => {
255                 return (
256                   <div
257                     key={v}
258                     className="vote-choice"
259                   >
260                     {v}
261                   </div>
262                 );
263               })}
264             </div>
265           </button>
266         );
267       })}
268     </div>
269   );
270 });
271
272 const LetsPlay = React.memo(props => {
273
274   function handle_click(prompt_id) {
275     fetch_post_json
276   }
277
278   const quorum = Math.round((props.num_players + 1) / 2);
279   const max_votes = props.prompts.reduce(
280     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
281
282   if (max_votes < quorum)
283     return null;
284
285   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
286   const index = Math.floor(Math.random() * candidates.length);
287   const winner = candidates[index];
288
289   return (
290     <div className="lets-play">
291       <h2>Let's Play</h2>
292       <p>
293         That should be enough voting. If you're not waiting for any
294         other players to join, then let's start.
295       </p>
296       <button
297         className="lets-play"
298         onClick={() => fetch_post_json(`start/${winner.id}`) }
299       >
300         Start Game
301       </button>
302     </div>
303   );
304 });
305
306 class ActivePrompt extends React.PureComponent {
307
308   constructor(props) {
309     super(props);
310     const items = props.prompt.items;
311
312     this.state = {
313       submitted: false
314     };
315
316     this.answers = [...Array(items)].map(() => React.createRef());
317     this.handle_submit = this.handle_submit.bind(this);
318   }
319
320   async handle_submit(event) {
321     const form = event.currentTarget;
322
323     /* Prevent the default page-changing form-submission behavior. */
324     event.preventDefault();
325
326     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
327       answers: this.answers.map(r => r.current.value)
328     });
329     if (response.status == 200) {
330       const result = await response.json();
331       if (! result.valid) {
332         add_message("danger", result.message);
333         return;
334       }
335     } else {
336       add_message("danger", "An error occurred submitting your answers");
337       return;
338     }
339
340     /* Everything worked. Server is happy with our answers. */
341     form.reset();
342     this.setState({
343         submitted: true
344     });
345   }
346
347   render() {
348     if (this.state.submitted)
349       return (
350         <div className="please-wait">
351           <h2>{this.props.players_answered}/
352             {this.props.players_total} players have responded</h2>
353           <p>
354             Please wait for the rest of the players to submit their answers.
355           </p>
356         </div>
357       );
358
359     return (
360       <div className="active-prompt">
361         <h2>The Game of Empathy</h2>
362         <p>
363           Remember, you're trying to match your answers with
364           what the other players submit.
365           Give {this.props.prompt.items} answers for the following prompt:
366         </p>
367         <h2>{this.props.prompt.prompt}</h2>
368         <form onSubmit={this.handle_submit}>
369           {Array(this.props.prompt.items).fill(null).map((whocares,i) => {
370             return (
371               <div
372                 key={i}
373                 className="form-field large">
374                 <input
375                   type="text"
376                   name={`answer_${i}`}
377                   required
378                   autoComplete="off"
379                   ref={this.answers[i]}
380                 />
381               </div>
382             );
383           })}
384
385           <div
386             key="submit-button"
387             className="form-field large">
388             <button type="submit">
389               Send
390             </button>
391           </div>
392
393         </form>
394       </div>
395     );
396   }
397 }
398
399 class Game extends React.PureComponent {
400   constructor(props) {
401     super(props);
402     this.state = {
403       game_info: {},
404       player_info: {},
405       other_players: [],
406       prompts: [],
407       active_prompt: null,
408       players_answered: 0
409     };
410   }
411
412   set_game_info(info) {
413     this.setState({
414       game_info: info
415     });
416   }
417
418   set_player_info(info) {
419     this.setState({
420       player_info: info
421     });
422   }
423
424   set_other_player_info(info) {
425     const other_players_copy = [...this.state.other_players];
426     const idx = other_players_copy.findIndex(o => o.id === info.id);
427     if (idx >= 0) {
428       other_players_copy[idx] = info;
429     } else {
430       other_players_copy.push(info);
431     }
432     this.setState({
433       other_players: other_players_copy
434     });
435   }
436
437   set_prompts(prompts) {
438     this.setState({
439       prompts: prompts
440     });
441   }
442
443   add_or_update_prompt(prompt) {
444     const prompts_copy = [...this.state.prompts];
445     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
446     if (idx >= 0) {
447       prompts_copy[idx] = prompt;
448     } else {
449       prompts_copy.push(prompt);
450     }
451     this.setState({
452       prompts: prompts_copy
453     });
454   }
455
456   set_active_prompt(prompt) {
457     this.setState({
458       active_prompt: prompt
459     });
460   }
461
462   set_players_answered(players_answered) {
463     this.setState({
464       players_answered: players_answered
465     });
466   }
467
468   set_scores(scores) {
469     this.setState({
470       scores: scores
471     });
472   }
473
474   render() {
475     const state = this.state;
476     const players_total = 1 + state.other_players.length;
477
478     if (state.scores) {
479       return (
480         <div className="scores">
481           <h2>Scores</h2>
482           <ul>
483             {state.scores.scores.map(score => {
484               return (
485                 <li key={score.player}>
486                   {score.player}: {score.score}
487                 </li>
488               );
489             })}
490           </ul>
491           <h2>Words submitted</h2>
492           <ul>
493             {state.scores.words.map(word => {
494               return (
495                 <li key={word.word}>
496                   {word.word}:
497                   {word.players.map(p => {
498                     return (
499                       <span key={p}>{p}{" "}</span>
500                     );
501                   })}
502                 </li>
503               );
504             })}
505           </ul>
506           <button
507             className="new-game"
508             onClick={() => fetch_post_json('reset') }
509           >
510             New Game
511           </button>
512         </div>
513       );
514     }
515
516     if (state.active_prompt) {
517       return <ActivePrompt
518                prompt={state.active_prompt}
519                players_answered={state.players_answered}
520                players_total={players_total}
521              />;
522     }
523
524     return [
525       <GameInfo
526         key="game-info"
527         id={state.game_info.id}
528         url={state.game_info.url}
529       />,
530       <PlayerInfo
531         key="player-info"
532         game={this}
533         player={state.player_info}
534         other_players={state.other_players}
535       />,
536       <p key="spacer"></p>,
537       <CategoryRequest
538         key="category-request"
539       />,
540       <PromptOptions
541         key="prompts"
542         prompts={state.prompts}
543       />,
544       <LetsPlay
545         key="lets-play"
546         num_players={1+state.other_players.length}
547         prompts={state.prompts}
548       />
549     ];
550   }
551 }
552
553 ReactDOM.render(<Game
554                   ref={(me) => window.game = me}
555                 />, document.getElementById("empathy"));