]> git.cworth.org Git - lmno-server/commitdiff
letterrip: Implement server-side validation of words
authorCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 13:08:04 +0000 (08:08 -0500)
committerCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 13:08:04 +0000 (08:08 -0500)
Now that we have the dictionary on the server side, (for purpose of
final scoring) we also implement validation of words on every tile
placement on the server side. This lets us drop the dictionary from
the client side so there is no possibility of disagreement between
client and server on which words are valid.

letterrip.js

index 770c3099634b1f03961025f5d7ed19bcdd88a0bc..e90a165744679de20c1b5b29dfedb96f999bca85 100644 (file)
@@ -426,6 +426,69 @@ class LetterRip extends Game {
     }
 
     response.sendStatus(200);
+
+    this.send_board_state(session_id);
+  }
+
+  /* Analyze a player's board and send validation state via SSE. */
+  send_board_state(session_id) {
+    const player = this.players_by_session[session_id];
+    if (!player) return;
+
+    const tiles = this.state.player_tiles[session_id];
+    if (!tiles) return;
+
+    const board = this.state.player_boards[session_id] || {};
+    const analysis = LetterRip.analyze_grid(board);
+
+    /* Invalid cells: in an invalid word or isolated (not in any word). */
+    const invalid = new Set();
+    const in_word = new Set();
+    for (const w of analysis.words) {
+      for (const cell of w.cells) {
+        const key = cell.r + "," + cell.c;
+        in_word.add(key);
+        if (!w.valid) invalid.add(key);
+      }
+    }
+    for (const key of Object.keys(board)) {
+      if (!in_word.has(key)) invalid.add(key);
+    }
+
+    /* Unconnected cells (not reachable from the first tile via BFS). */
+    const unconnected = new Set();
+    const occupied = Object.keys(board);
+    if (occupied.length > 1) {
+      const pos_set = new Set(occupied);
+      const visited = new Set([occupied[0]]);
+      const queue = [occupied[0]];
+      while (queue.length > 0) {
+        const cur = queue.shift();
+        const [cr, cc] = cur.split(",").map(Number);
+        for (const n of [(cr-1)+","+cc, (cr+1)+","+cc,
+                          cr+","+(cc-1), cr+","+(cc+1)]) {
+          if (pos_set.has(n) && !visited.has(n)) {
+            visited.add(n);
+            queue.push(n);
+          }
+        }
+      }
+      for (const key of occupied) {
+        if (!visited.has(key)) unconnected.add(key);
+      }
+    }
+
+    const all_valid = analysis.words.length > 0
+      && analysis.words.every(w => w.valid);
+    const all_connected = unconnected.size === 0;
+
+    const data = JSON.stringify({
+      invalid_cells: [...invalid],
+      unconnected_cells: [...unconnected],
+      all_valid,
+      all_connected
+    });
+    player.send(`event: board-state\ndata: ${data}\n\n`);
   }
 
   handle_events(request, response) {
@@ -447,6 +510,11 @@ class LetterRip extends Game {
       response.write(`event: board\ndata: ${JSON.stringify(board)}\n\n`);
     }
 
+    /* Send current board validation state. */
+    if (tiles) {
+      this.send_board_state(session_id);
+    }
+
     /* Send current bag count. */
     response.write(`event: dealt\ndata: ${JSON.stringify({
       remaining: this.state.bag.length