]> git.cworth.org Git - lmno.games/blob - scribe/scribe.jsx
Score each mini glyph and render the winner for each
[lmno.games] / scribe / scribe.jsx
1 function team_symbol(team) {
2   if (team === "+")
3     return "+";
4   else
5     return "o";
6 }
7
8 function undisplay(element) {
9   element.style.display="none";
10 }
11
12 function add_message(severity, message) {
13   message = `<div class="message ${severity}" onclick="undisplay(this)">
14 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
15 ${message}
16 </div>`;
17   const message_area = document.getElementById('message-area');
18   message_area.insertAdjacentHTML('beforeend', message);
19 }
20
21 /*********************************************************
22  * Handling server-sent event stream                     *
23  *********************************************************/
24
25 const events = new EventSource("events");
26
27 events.onerror = function(event) {
28   if (event.target.readyState === EventSource.CLOSED) {
29     setTimeout(() => {
30       add_message("danger", "Connection to server lost.");
31     }, 1000);
32   }
33 };
34
35 events.addEventListener("game-info", event => {
36   const info = JSON.parse(event.data);
37
38   window.game.set_game_info(info);
39 });
40
41 events.addEventListener("player-info", event => {
42   const info = JSON.parse(event.data);
43
44   window.game.set_player_info(info);
45 });
46
47 events.addEventListener("player-enter", event => {
48   const info = JSON.parse(event.data);
49
50   window.game.set_other_player_info(info);
51 });
52
53 events.addEventListener("player-update", event => {
54   const info = JSON.parse(event.data);
55
56   if (info.id === window.game.state.player_info.id)
57     window.game.set_player_info(info);
58   else
59     window.game.set_other_player_info(info);
60 });
61
62 events.addEventListener("move", event => {
63   const move = JSON.parse(event.data);
64
65   window.game.receive_move(move);
66 });
67
68 events.addEventListener("game-state", event => {
69   const state = JSON.parse(event.data);
70
71   window.game.reset_board();
72
73   for (let square of state.moves) {
74     window.game.receive_move(square);
75   }
76 });
77
78 /*********************************************************
79  * Game and supporting classes                           *
80  *********************************************************/
81
82 const scribe_glyphs = [
83   {
84     name: "Single",
85     squares: [1,0,0,
86               0,0,0,
87               0,0,0]
88   },
89   {
90     name: "Double",
91     squares: [1,1,0,
92               0,0,0,
93               0,0,0]
94   },
95   {
96     name: "Line",
97     squares: [1,1,1,
98               0,0,0,
99               0,0,0]
100   },
101   {
102     name: "Pipe",
103     squares: [0,0,1,
104               1,1,1,
105               0,0,0]
106   },
107   {
108     name: "Squat-T",
109     squares: [1,1,1,
110               0,1,0,
111               0,0,0]
112   },
113   {
114     name: "4-block",
115     squares: [1,1,0,
116               1,1,0,
117               0,0,0]
118   },
119   {
120     name: "T",
121     squares: [1,1,1,
122               0,1,0,
123               0,1,0]
124   },
125   {
126     name: "Cross",
127     squares: [0,1,0,
128               1,1,1,
129               0,1,0]
130   },
131   {
132     name: "6-block",
133     squares: [1,1,1,
134               1,1,1,
135               0,0,0]
136   },
137   {
138     name: "Bomber",
139     squares: [1,1,1,
140               0,1,1,
141               0,0,1]
142   },
143   {
144     name: "Chair",
145     squares: [0,0,1,
146               1,1,1,
147               1,0,1]
148   },
149   {
150     name: "J",
151     squares: [0,0,1,
152               1,0,1,
153               1,1,1]
154   },
155   {
156     name: "Earring",
157     squares: [0,1,1,
158               1,0,1,
159               1,1,1]
160   },
161   {
162     name: "House",
163     squares: [0,1,0,
164               1,1,1,
165               1,1,1]
166   },
167   {
168     name: "H",
169     squares: [1,0,1,
170               1,1,1,
171               1,0,1]
172   },
173   {
174     name: "U",
175     squares: [1,0,1,
176               1,0,1,
177               1,1,1]
178   },
179   {
180     name: "Ottoman",
181     squares: [1,1,1,
182               1,1,1,
183               1,0,1]
184   },
185   {
186     name: "O",
187     squares: [1,1,1,
188               1,0,1,
189               1,1,1]
190   },
191   {
192     name: "9-block",
193     squares: [1,1,1,
194               1,1,1,
195               1,1,1]
196   }
197 ];
198
199 function copy_to_clipboard(id)
200 {
201   const tmp = document.createElement("input");
202   tmp.setAttribute("value", document.getElementById(id).innerHTML);
203   document.body.appendChild(tmp);
204   tmp.select();
205   document.execCommand("copy");
206   document.body.removeChild(tmp);
207 }
208
209 function GameInfo(props) {
210   if (! props.id)
211     return null;
212
213   return (
214     <div className="game-info">
215       <span className="game-id">{props.id}</span>
216       {" "}
217       Share this link to invite a friend:{" "}
218       <span id="game-share-url">{props.url}</span>
219       {" "}
220       <button
221         className="inline"
222         onClick={() => copy_to_clipboard('game-share-url')}
223       >Copy Link</button>
224     </div>
225   );
226 }
227
228 function TeamButton(props) {
229   return <button className="inline"
230                  onClick={() => props.game.join_team(props.team)}>
231            {props.label}
232          </button>;
233 }
234
235 function TeamChoices(props) {
236   let other_team;
237   if (props.player.team === "+")
238     other_team = "o";
239   else
240     other_team = "+";
241
242   if (props.player.team === "") {
243     if (props.first_move) {
244       return null;
245     } else {
246       return [
247         <TeamButton key="+" game={props.game} team="+" label="Join ðŸž¥" />,
248         " ",
249         <TeamButton key="o" game={props.game} team="o" label="Join ðŸž‡" />
250       ];
251     }
252   } else {
253     return <TeamButton game={props.game} team={other_team} label="Switch" />;
254   }
255 }
256
257 function PlayerInfo(props) {
258   if (! props.player.id)
259     return null;
260
261   const choices = <TeamChoices
262                     game={props.game}
263                     first_move={props.first_move}
264                     player={props.player}
265                   />;
266
267   return (
268     <div className="player-info">
269       <span className="players-header">Players: </span>
270       {props.player.name}
271       {props.player.team ? ` (${props.player.team})` : ""}
272       {props.first_move ? "" : " "}
273       {choices}
274       {props.other_players.map(other => (
275         <span key={other.id}>
276           {", "}
277           {other.name}
278           {other.team ? ` (${other.team})` : ""}
279         </span>
280       ))}
281     </div>
282   );
283 }
284
285 function Glyph(props) {
286
287   const glyph_dots = [];
288
289   let last_square = 0;
290   for (let i = 0; i < 9; i++) {
291     if (props.squares[i])
292       last_square = i;
293   }
294
295   const height = Math.floor(20 * (Math.floor(last_square / 3) + 1));
296
297   const viewbox=`0 0 60 ${height}`;
298
299   for (let row = 0; row < 3; row++) {
300     for (let col = 0; col < 3; col++) {
301       if (props.squares[3 * row + col]) {
302         let cy = 10 + 20 * row;
303         let cx = 10 + 20 * col;
304         glyph_dots.push(
305           <circle
306             key={3 * row + col}
307             cx={cx}
308             cy={cy}
309             r="8"
310           />
311         );
312       }
313     }
314   }
315
316   return (<div className="glyph-and-name">
317             {props.name}
318             <div className="glyph">
319               <svg viewBox={viewbox}>
320                 <g fill="#287789">
321                   {glyph_dots}
322                 </g>
323               </svg>
324             </div>
325           </div>
326          );
327 }
328
329 function Square(props) {
330   let className = "square";
331
332   if (props.value.symbol) {
333     className += " occupied";
334   } else if (props.active) {
335     className += " open";
336   }
337
338   if (props.value.glyph) {
339     if (props.value.symbol === '+')
340       className += " glyph-plus";
341     else
342       className += " glyph-o";
343   }
344
345   if (props.last_move) {
346     className += " last-move";
347   }
348
349   const onClick = props.active ? props.onClick : null;
350
351   return (
352     <div className={className}
353          onClick={onClick}>
354       {props.value.symbol}
355     </div>
356   );
357 }
358
359 function MiniGrid(props) {
360
361   const mini_grid = props.mini_grid;
362   const squares = mini_grid.squares;
363
364   function grid_square(j) {
365     const value = squares[j];
366     const last_move = props.last_moves.includes(j);
367
368     /* Even if the grid is active, the square is only active if
369      * unoccupied. */
370     const square_active = (props.active && (value.symbol === null));
371
372     return (
373       <Square
374         value={value}
375         active={square_active}
376         last_move={last_move}
377         onClick={() => props.onClick(j)}
378       />
379     );
380   }
381
382   /* Even if my parent thinks I'm active because of the last move, I
383    * might not _really_ be active if I'm full. */
384   let occupied = 0;
385   mini_grid.squares.forEach(element => {
386     if (element.symbol)
387       occupied++;
388   });
389
390   let class_name = "mini-grid";
391   if (props.active && occupied < 9)
392     class_name += " active";
393
394   let winner = null;
395   if (mini_grid.winner) {
396     winner = <div className="winner">{mini_grid.winner}</div>;
397   }
398
399   return (
400     <div className={class_name}>
401       {grid_square(0)}
402       {grid_square(1)}
403       {grid_square(2)}
404       {grid_square(3)}
405       {grid_square(4)}
406       {grid_square(5)}
407       {grid_square(6)}
408       {grid_square(7)}
409       {grid_square(8)}
410       {winner}
411     </div>
412   );
413 }
414
415 class Board extends React.Component {
416   mini_grid(i) {
417     /* This mini grid is active only if both:
418      *
419      * 1. It is our turn (this.props.active === true)
420      *
421      * 2. One of the following conditions is met:
422      *
423      *    a. This is this players first turn (last_two_moves[0] === null)
424      *    b. This mini grid corresponds to this players last turn
425      *    c. The mini grid that corresponds to the players last turn is full
426      */
427     let grid_active = false;
428     if (this.props.active) {
429       grid_active = true;
430       if (this.props.last_two_moves.length > 1) {
431         /* First index (0) gives us our last move, (that is, of the
432          * last two moves, it's the first one, so two moves ago).
433          *
434          * Second index (1) gives us the second number from that move,
435          * (that is, the index within the mini-grid that we last
436          * played).
437          */
438         const target = this.props.last_two_moves[0][1];
439         let occupied = 0;
440         this.props.mini_grids[target].squares.forEach(element => {
441           if (element.symbol)
442             occupied++;
443         });
444         /* If the target mini-grid isn't full then this grid is
445          * only active if it is that target. */
446         if (occupied < 9)
447           grid_active = (i === target);
448       }
449     }
450
451     /* We want to highlight each of the last two moves (both "+" and
452      * "o"). So we filter the last two moves that have a first index
453      * that matches this mini_grid and pass down their second index
454      * be highlighted.
455      */
456     const last_moves = this.props.last_two_moves.filter(move => move[0] === i)
457           .map(move => move[1]);
458
459     const mini_grid = this.props.mini_grids[i];
460     return (
461       <MiniGrid
462         mini_grid={mini_grid}
463         active={grid_active}
464         last_moves={last_moves}
465         onClick={(j) => this.props.onClick(i,j)}
466       />
467     );
468   }
469
470   render() {
471     return (
472       <div className="board-container">
473         <div className="board">
474           {this.mini_grid(0)}
475           {this.mini_grid(1)}
476           {this.mini_grid(2)}
477           {this.mini_grid(3)}
478           {this.mini_grid(4)}
479           {this.mini_grid(5)}
480           {this.mini_grid(6)}
481           {this.mini_grid(7)}
482           {this.mini_grid(8)}
483         </div>
484       </div>
485     );
486   }
487 }
488
489 function fetch_method_json(method, api = '', data = {}) {
490   const response = fetch(api, {
491     method: method,
492     headers: {
493       'Content-Type': 'application/json'
494     },
495     body: JSON.stringify(data)
496   });
497   return response;
498 }
499
500 function fetch_post_json(api = '', data = {}) {
501   return fetch_method_json('POST', api, data);
502 }
503
504 async function fetch_put_json(api = '', data = {}) {
505   return fetch_method_json('PUT', api, data);
506 }
507
508 class Game extends React.Component {
509   constructor(props) {
510     super(props);
511     this.state = {
512       game_info: {},
513       player_info: {},
514       other_players: [],
515       mini_grids: [...Array(9)].map(() => ({
516         score_plus: null,
517         score_o: null,
518         winner: null,
519         squares: Array(9).fill({
520           symbol: null,
521           glyph: false
522         })
523       })),
524       moves: [],
525       next_to_play: "+",
526     };
527   }
528
529   set_game_info(info) {
530     this.setState({
531       game_info: info
532     });
533   }
534
535   set_player_info(info) {
536     this.setState({
537       player_info: info
538     });
539   }
540
541   set_other_player_info(info) {
542     const other_players_copy = [...this.state.other_players];
543     const idx = other_players_copy.findIndex(o => o.id === info.id);
544     if (idx >= 0) {
545       other_players_copy[idx] = info;
546     } else {
547       other_players_copy.push(info);
548     }
549     this.setState({
550       other_players: other_players_copy
551     });
552   }
553
554   reset_board() {
555     this.setState({
556       next_to_play: "+"
557     });
558   }
559
560   find_connected_recursive(recursion_state, position) {
561
562     if (position < 0 || position >= 9)
563       return;
564
565     if (recursion_state.visited[position])
566       return;
567
568     recursion_state.visited[position] = true;
569
570     if (recursion_state.mini_grid[position].symbol !== recursion_state.target)
571       return;
572
573     recursion_state.connected[position] = true;
574
575     /* Left */
576     if (position % 3 !== 0)
577       this.find_connected_recursive(recursion_state, position - 1);
578     /* Right */
579     if (position % 3 !== 2)
580       this.find_connected_recursive(recursion_state, position + 1);
581     /* Up */
582     this.find_connected_recursive(recursion_state, position - 3);
583     /* Down */
584     this.find_connected_recursive(recursion_state, position + 3);
585   }
586
587   /* Find all cells within a mini-grid that are 4-way connected to the
588    * given cell. */
589   find_connected(mini_grid, position) {
590     const connected = Array(9).fill(false);
591
592     /* If the given cell is empty then there is nothing connected. */
593     if (mini_grid[position] === null)
594       return connected;
595
596     const cell = mini_grid[position].symbol;
597
598     let recursion_state = {
599       mini_grid: mini_grid,
600       connected: connected,
601       visited: Array(9).fill(false),
602       target: cell,
603     };
604     this.find_connected_recursive(recursion_state, position);
605
606     return connected;
607   }
608
609   /* Determine whether a connected group of cells is a glyph.
610    *
611    * Here, 'connected' is a length-9 array of Booleans, true
612    * for the connected cells in a mini-grid.
613    */
614   is_glyph(connected) {
615
616     /* Now that we have a set of connected cells, let's collect some
617      * stats on them, (width, height, number of cells, configuration
618      * of corner cells, etc.).
619      */
620     let min_row = 2;
621     let min_col = 2;
622     let max_row = 0;
623     let max_col = 0;
624     let count = 0;
625
626     for (let i = 0; i < 9; i++) {
627       const row = Math.floor(i/3);
628       const col = i % 3;
629
630       if (! connected[i])
631         continue;
632
633       count++;
634
635       min_row = Math.min(row, min_row);
636       min_col = Math.min(col, min_col);
637       max_row = Math.max(row, max_row);
638       max_col = Math.max(col, max_col);
639     }
640
641     const width = max_col - min_col + 1;
642     const height = max_row - min_row + 1;
643
644     /* Corners, (top-left, top-right, bottom-left, and bottom-right) */
645     const tl = connected[3 * min_row + min_col];
646     const tr = connected[3 * min_row + max_col];
647     const bl = connected[3 * max_row + min_col];
648     const br = connected[3 * max_row + max_col];
649
650     const count_true = (acc, val) => acc + (val ? 1 : 0);
651     const corners_count = [tl, tr, bl, br].reduce(count_true, 0);
652     const top_corners_count = [tl, tr].reduce(count_true, 0);
653     const bottom_corners_count = [bl, br].reduce(count_true, 0);
654     const left_corners_count = [tl, bl].reduce(count_true, 0);
655     const right_corners_count = [tr, br].reduce(count_true, 0);
656
657     let two_corners_in_a_line = false;
658     if (top_corners_count    === 2 ||
659         bottom_corners_count === 2 ||
660         left_corners_count   === 2 ||
661         right_corners_count  === 2)
662     {
663       two_corners_in_a_line = true;
664     }
665
666     let zero_corners_in_a_line = false;
667     if (top_corners_count    === 0 ||
668         bottom_corners_count === 0 ||
669         left_corners_count   === 0 ||
670         right_corners_count  === 0)
671     {
672       zero_corners_in_a_line = true;
673     }
674
675     /* Now we have the information we need to determine glyphs. */
676     switch (count) {
677     case 1:
678       /* Single */
679       return true;
680     case 2:
681       /* Double */
682       return true;
683     case 3:
684       /* Line */
685       return (width === 3 || height === 3);
686     case 4:
687       /* Pipe, Squat-T, and 4-block, but not Tetris S */
688       return two_corners_in_a_line;
689     case 5:
690       if (width !== 3 || height !== 3 || ! connected[4])
691       {
692         /* Pentomino P and U are not glyphs (not 3x3) */
693         /* Pentomino V is not a glyph (center not connected) */
694         return false;
695       }
696       else if (corners_count === 0 || two_corners_in_a_line)
697       {
698         /* Pentomino X is glyph Cross (no corners) */
699         /* Pentomino T is glyph T (has a row or column with 2 corners) */
700         return true;
701       } else {
702         /* The corner counting above excludes pentomino F, W, and Z
703          * which are not glyphs. */
704         return false;
705       }
706       break;
707     case 6:
708       /* 6-Block has width or height of 2. */
709       /* Bomber, Chair, and J have 3 corners occupied. */
710       if (width === 2 || height === 2 || corners_count === 3)
711         return true;
712       return false;
713     case 7:
714       /* Earring and U have no center square occupied */
715       /* H has 4 corners occupied */
716       /* House has a row or column with 0 corners occupied */
717       if ((! connected[4]) || corners_count === 4 || zero_corners_in_a_line)
718         return true;
719       return false;
720     case 8:
721       /* Ottoman or O */
722       if (corners_count === 4)
723         return true;
724       return false;
725     case 9:
726       return true;
727     }
728
729     /* Should be unreachable */
730     return false;
731   }
732
733   receive_move(move) {
734     const mini_grid_index = move[0];
735     const position = move[1];
736
737     /* Don't allow any moves after the board is full */
738     if (this.state.moves.length === 81) {
739       return;
740     }
741
742     /* Set the team's symbol into the board state. */
743     const symbol = team_symbol(this.state.next_to_play);
744     const new_mini_grids = this.state.mini_grids.map(obj => {
745       const new_obj = {...obj};
746       new_obj.squares = obj.squares.slice();
747       return new_obj;
748     });
749     const new_mini_grid = new_mini_grids[mini_grid_index];
750     new_mini_grid.squares[position] = {
751       symbol: symbol,
752       glyph: false
753     };
754
755     /* With the symbol added to the squares, we need to see if this
756      * newly-placed move forms a glyph or not. */
757     const connected = this.find_connected(new_mini_grid.squares, position);
758     const is_glyph = this.is_glyph(connected);
759
760     /* Either set (or clear) the glyph Boolean for each connected square. */
761     for (let i = 0; i < 9; i++) {
762       if (connected[i])
763         new_mini_grid.squares[i].glyph = is_glyph;
764     }
765
766     /* If this is the last cell of played in a mini-grid then it's
767      * time to score it. */
768     const occupied = new_mini_grid.squares.reduce(
769       (acc, val) => acc + (val.symbol !== null ? 1 : 0), 0);
770     if (occupied === 9) {
771       for (let i = 0; i < 9; i++) {
772         if (new_mini_grid.squares[i].glyph) {
773           if (new_mini_grid.squares[i].symbol === '+')
774             new_mini_grid.score_plus++;
775           else
776             new_mini_grid.score_o++;
777         }
778       }
779       if (new_mini_grid.score_plus > new_mini_grid.score_o)
780         new_mini_grid.winner = '+';
781       else
782         new_mini_grid.winner = 'o';
783     }
784
785     /* And append the move to the list of moves. */
786     const new_moves = [...this.state.moves, move];
787
788     /* Finally, compute the next player to move. */
789     let next_to_play;
790     if (this.state.next_to_play === "+")
791       next_to_play = "o";
792     else
793       next_to_play = "+";
794
795     /* And shove all those state modifications toward React. */
796     this.setState({
797       mini_grids: new_mini_grids,
798       moves: new_moves,
799       next_to_play: next_to_play
800     });
801   }
802
803   async handle_click(i, j, first_move) {
804     let move = {
805       move: [i, j]
806     };
807     if (first_move) {
808       move.assert_first_move = true;
809     }
810     const response = await fetch_post_json("move", move);
811     if (response.status == 200) {
812       const result = await response.json();
813       if (! result.legal)
814         add_message("danger", result.message);
815     } else {
816       add_message("danger", `Error occurred sending move`);
817     }
818   }
819
820   join_team(team) {
821     fetch_put_json("player", {team: team});
822   }
823
824   render() {
825     const state = this.state;
826     const first_move = state.moves.length === 0;
827     const my_team = state.player_info.team;
828     var board_active;
829
830     let status;
831     if (this.state.moves.length === 81)
832     {
833       status = "Game over";
834       board_active = false;
835     }
836     else if (first_move)
837     {
838       if (state.other_players.length == 0) {
839         status = "You can move or wait for another player to join.";
840       } else {
841         let qualifier;
842         if (state.other_players.length == 1) {
843           qualifier = "Either";
844         } else {
845           qualifier = "Any";
846         }
847         status = `${qualifier} player can make the first move.`;
848       }
849       board_active = true;
850     }
851     else if (my_team === "")
852     {
853       status = "You're just watching the game.";
854       board_active = false;
855     }
856     else if (my_team === state.next_to_play)
857     {
858       status = "Your turn. Make a move.";
859       board_active = true;
860     }
861     else
862     {
863       status = "Waiting for another player to ";
864       if (state.other_players.length == 0) {
865         status += "join.";
866       } else {
867         status += "move.";
868       }
869       board_active = false;
870     }
871
872     return [
873       <GameInfo
874         key="game-info"
875         id={state.game_info.id}
876         url={state.game_info.url}
877       />,
878       <PlayerInfo
879         key="player-info"
880         game={this}
881         first_move={first_move}
882         player={state.player_info}
883         other_players={state.other_players}
884       />,
885       <div key="game" className="game">
886         <div>{status}</div>
887         <div className="game-board">
888           <Board
889             active={board_active}
890             mini_grids={state.mini_grids}
891             last_two_moves={state.moves.slice(-2)}
892             onClick={(i,j) => this.handle_click(i, j, first_move)}
893           />
894         </div>
895       </div>,
896       <div key="glyphs" className="glyphs">
897         {
898           scribe_glyphs.map(glyph => {
899             return (
900               <Glyph
901                 key={glyph.name}
902                 name={glyph.name}
903                 squares={glyph.squares}
904               />
905             );
906           })
907         }
908       </div>
909     ];
910   }
911 }
912
913 ReactDOM.render(<Game
914                   ref={(me) => window.game = me}
915                 />, document.getElementById("scribe"));