]> git.cworth.org Git - lmno.games/commitdiff
letterrip: Remove all word validation from the client
authorCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 13:10:22 +0000 (08:10 -0500)
committerCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 13:19:16 +0000 (08:19 -0500)
Now that the server has a dictionary, it performs all validation of
valid tile placements. This allows for a smaller client, (no need
to download a word list anymore), and ensures that word validation
is performed consistently in a single place on the server side.

letterrip/game.html
letterrip/letterrip.jsx

index 0259cc8fe6644f836111a47b2525495078d1350a..cbc5e945fe11f08ce312b50d0181694b02ebf480 100644 (file)
@@ -12,7 +12,6 @@
 
     <script src="/react.js"></script>
     <script src="/react-dom.js"></script>
-    <script src="twl.js"></script>
     <script type="module" src="letterrip.js"></script>
   </head>
   <body>
index bc30d2574726ab3f0e925918c1f5135cea487bce..84890062ed5c2a3fc105feaf0cfade0651a9364a 100644 (file)
@@ -18,163 +18,6 @@ function fetch_put_json(api = '', data = {}) {
   });
 }
 
-/*********************************************************
- * Word detection and validation                         *
- *********************************************************/
-
-/* Find all words on the grid (horizontal and vertical runs of 2+).
- * Returns { words: [{cells, word, valid}], all_valid, all_connected }
- */
-function analyze_grid(grid) {
-  const occupied = Object.keys(grid);
-  if (occupied.length === 0) {
-    return { words: [], all_valid: true, all_connected: true };
-  }
-
-  /* Parse all occupied positions. */
-  const positions = occupied.map(key => {
-    const [r, c] = key.split(",").map(Number);
-    return { r, c, key };
-  });
-
-  const pos_set = new Set(occupied);
-
-  /* Find horizontal words. */
-  const words = [];
-  const visited_h = new Set();
-  const visited_v = new Set();
-
-  for (const { r, c } of positions) {
-    /* Horizontal word starting from leftmost cell of this run. */
-    const hkey = r + "," + c;
-    if (!visited_h.has(hkey)) {
-      /* Find start of horizontal run. */
-      let sc = c;
-      while (pos_set.has(r + "," + (sc - 1))) sc--;
-      /* Find end. */
-      let ec = sc;
-      while (pos_set.has(r + "," + (ec + 1))) ec++;
-      /* Mark visited. */
-      for (let cc = sc; cc <= ec; cc++) visited_h.add(r + "," + cc);
-      if (ec > sc) {
-        const cells = [];
-        let word = "";
-        for (let cc = sc; cc <= ec; cc++) {
-          const cell = grid[r + "," + cc];
-          cells.push({ r, c: cc });
-          word += cell.letter;
-        }
-        const valid = window.TWL.has(word);
-        words.push({ cells, word, valid });
-      }
-    }
-
-    /* Vertical word starting from topmost cell of this run. */
-    const vkey = r + "," + c;
-    if (!visited_v.has(vkey)) {
-      let sr = r;
-      while (pos_set.has((sr - 1) + "," + c)) sr--;
-      let er = sr;
-      while (pos_set.has((er + 1) + "," + c)) er++;
-      for (let rr = sr; rr <= er; rr++) visited_v.add(rr + "," + c);
-      if (er > sr) {
-        const cells = [];
-        let word = "";
-        for (let rr = sr; rr <= er; rr++) {
-          const cell = grid[rr + "," + c];
-          cells.push({ r: rr, c });
-          word += cell.letter;
-        }
-        const valid = window.TWL.has(word);
-        words.push({ cells, word, valid });
-      }
-    }
-  }
-
-  /* Check connectivity via BFS. */
-  const start = positions[0].key;
-  const visited = new Set([start]);
-  const queue = [start];
-  while (queue.length > 0) {
-    const cur = queue.shift();
-    const [cr, cc] = cur.split(",").map(Number);
-    const neighbors = [
-      (cr-1)+","+cc, (cr+1)+","+cc,
-      cr+","+(cc-1), cr+","+(cc+1)
-    ];
-    for (const n of neighbors) {
-      if (pos_set.has(n) && !visited.has(n)) {
-        visited.add(n);
-        queue.push(n);
-      }
-    }
-  }
-  const all_connected = visited.size === occupied.length;
-  const all_valid = words.length > 0 && words.every(w => w.valid);
-
-  return { words, all_valid, all_connected };
-}
-
-/* Determine which cells are in invalid words or isolated. */
-function get_invalid_cells(grid, analysis) {
-  const invalid = new Set();
-
-  for (const w of analysis.words) {
-    if (!w.valid) {
-      for (const cell of w.cells) {
-        invalid.add(cell.r + "," + cell.c);
-      }
-    }
-  }
-
-  /* Mark isolated tiles (not part of any word). */
-  const in_word = new Set();
-  for (const w of analysis.words) {
-    for (const cell of w.cells) {
-      in_word.add(cell.r + "," + cell.c);
-    }
-  }
-  for (const key of Object.keys(grid)) {
-    if (!in_word.has(key)) {
-      invalid.add(key);
-    }
-  }
-
-  return invalid;
-}
-
-/* Find which cells are not connected to the main component. */
-function get_unconnected_cells(grid, analysis) {
-  if (analysis.all_connected) return new Set();
-
-  const occupied = Object.keys(grid);
-  if (occupied.length === 0) return new Set();
-
-  const pos_set = new Set(occupied);
-  const start = occupied[0];
-  const visited = new Set([start]);
-  const queue = [start];
-  while (queue.length > 0) {
-    const cur = queue.shift();
-    const [cr, cc] = cur.split(",").map(Number);
-    const neighbors = [
-      (cr-1)+","+cc, (cr+1)+","+cc,
-      cr+","+(cc-1), cr+","+(cc+1)
-    ];
-    for (const n of neighbors) {
-      if (pos_set.has(n) && !visited.has(n)) {
-        visited.add(n);
-        queue.push(n);
-      }
-    }
-  }
-  const unconnected = new Set();
-  for (const key of occupied) {
-    if (!visited.has(key)) unconnected.add(key);
-  }
-  return unconnected;
-}
-
 /*********************************************************
  * Compute grid render bounds                            *
  *********************************************************/
@@ -427,7 +270,9 @@ class Game extends React.Component {
       drag_over_cell: null,
       rack_drag_over: false,
       selected: null,
-      touch_pos: null
+      touch_pos: null,
+      board_state: { invalid_cells: [], unconnected_cells: [],
+                     all_valid: true, all_connected: true }
     };
   }
 
@@ -564,6 +409,10 @@ class Game extends React.Component {
     });
   }
 
+  receive_board_state(data) {
+    this.setState({ board_state: data });
+  }
+
   receive_game_over(data) {
     this.setState({ game_over: true, results: data.results || null });
   }
@@ -997,12 +846,12 @@ class Game extends React.Component {
       ];
     }
 
-    const analysis = analyze_grid(state.grid);
-    const invalid_cells = get_invalid_cells(state.grid, analysis);
-    const unconnected_cells = get_unconnected_cells(state.grid, analysis);
+    const bs = state.board_state;
+    const invalid_cells = new Set(bs.invalid_cells);
+    const unconnected_cells = new Set(bs.unconnected_cells);
 
     const all_placed = state.rack.length === 0 && state.tiles.length > 0;
-    const can_complete = all_placed && analysis.all_valid && analysis.all_connected;
+    const can_complete = all_placed && bs.all_valid && bs.all_connected;
 
     return [
       <GameInfo key="gi" id={state.game_info.id} url={state.game_info.url} />,
@@ -1041,7 +890,7 @@ class Game extends React.Component {
 
       state.joined ? (
         <div key="board-rack" className="board-and-rack">
-          {this.render_board(analysis, invalid_cells, unconnected_cells)}
+          {this.render_board(invalid_cells, unconnected_cells)}
           {this.render_rack(can_complete)}
           {state.blank_pending ? (
             <BlankTileModal
@@ -1062,8 +911,8 @@ class Game extends React.Component {
           ) : null}
           {Object.keys(state.grid).length > 0 && !can_complete ? (
             <div className="status">
-              {!analysis.all_valid ? "Some words are not valid." :
-               !analysis.all_connected ? "All tiles must be connected." :
+              {!bs.all_valid ? "Some words are not valid." :
+               !bs.all_connected ? "All tiles must be connected." :
                state.rack.length > 0 ? "Place all your tiles to complete." :
                null}
             </div>
@@ -1107,7 +956,7 @@ class Game extends React.Component {
     );
   }
 
-  render_board(analysis, invalid_cells, unconnected_cells) {
+  render_board(invalid_cells, unconnected_cells) {
     const { grid, drag_over_cell, drag_source, selected } = this.state;
     const bounds = grid_bounds(grid);
     const rows = [];
@@ -1268,12 +1117,16 @@ events.addEventListener("stuck", event => {
   window.game.receive_stuck(JSON.parse(event.data));
 });
 
+events.addEventListener("board-state", event => {
+  window.game.receive_board_state(JSON.parse(event.data));
+});
+
 events.addEventListener("game-over", event => {
   window.game.receive_game_over(JSON.parse(event.data));
 });
 
 events.addEventListener("game-state", event => {
-  /* game-state is sent by the base class but we don't use it for
-   * board state (that's all client-side). We rely on the 'tiles'
-   * event for reconnection recovery instead. */
+  /* game-state is sent by the base class but we don't use it
+   * directly. We rely on the 'tiles' and 'board' events for
+   * reconnection recovery instead. */
 });