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