From: Carl Worth Date: Sat, 7 Mar 2026 13:10:22 +0000 (-0500) Subject: letterrip: Remove all word validation from the client X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=4abc58600128087e09f1e3fecabc6aedb95cbaeb;p=lmno.games letterrip: Remove all word validation from the client 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. --- diff --git a/letterrip/game.html b/letterrip/game.html index 0259cc8..cbc5e94 100644 --- a/letterrip/game.html +++ b/letterrip/game.html @@ -12,7 +12,6 @@ - diff --git a/letterrip/letterrip.jsx b/letterrip/letterrip.jsx index bc30d25..8489006 100644 --- a/letterrip/letterrip.jsx +++ b/letterrip/letterrip.jsx @@ -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 [ , @@ -1041,7 +890,7 @@ class Game extends React.Component { state.joined ? (
- {this.render_board(analysis, invalid_cells, unconnected_cells)} + {this.render_board(invalid_cells, unconnected_cells)} {this.render_rack(can_complete)} {state.blank_pending ? ( 0 && !can_complete ? (
- {!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}
@@ -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. */ });