// Client-side geometry of the play being composed: which cells the formed word(s) cover, and // where to anchor the move-score badge. This is deliberately independent of the move evaluator // (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell // coordinates, and the board highlight plus the score badge must work even while the local // dictionary is still warming up. Legality and the score come from the preview; the geometry is // derived here purely from the board and the staged tiles. Both functions are pure (testable in // lib/formed.test.ts); the board stays the source of truth for legality. import type { Board } from './board'; import { BOARD_SIZE } from './premiums'; /** Play axis: horizontal or vertical. */ export type Dir = 'H' | 'V'; /** MainWord locates the play's main word: its start cell, axis and length in cells. */ export interface MainWord { row: number; col: number; dir: Dir; len: number; } /** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a * "row,col" key) that the main word and any cross words cover — committed tiles included. */ export interface FormedGeometry { main: MainWord; cells: Set; } /** A minimal cell reference; PendingTile satisfies it structurally. */ interface Cell { row: number; col: number; } const key = (r: number, c: number): string => `${r},${c}`; /** * filledPredicate returns a test for whether a cell holds a tile once the staged play is on the * board: either a committed tile sits there or a pending tile is being placed there. */ function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean { const pend = new Set(pending.map((p) => key(p.row, p.col))); return (r, c) => r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && (board[r]?.[c] != null || pend.has(key(r, c))); } /** * span walks the maximal contiguous filled run through cell (row, col) along dir and returns the * run's start index and length on the moving axis (columns for H, rows for V). */ function span( filled: (r: number, c: number) => boolean, dir: Dir, row: number, col: number, ): { start: number; len: number } { if (dir === 'H') { let s = col; while (filled(row, s - 1)) s--; let e = col; while (filled(row, e + 1)) e++; return { start: s, len: e - s + 1 }; } let s = row; while (filled(s - 1, col)) s--; let e = row; while (filled(e + 1, col)) e++; return { start: s, len: e - s + 1 }; } /** * formedGeometry derives the composed play's word geometry from the board and the staged tiles. * It returns the main word (the maximal run along the play axis through the staged tiles) and the * set of every cell the main word and each cross word (a perpendicular run of two or more cells * through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words * are omitted, matching the engine, which ignores them entirely — a perpendicular run there is * neither validated nor scored (and need not even be a real word), so highlighting it would be * misleading. It returns null when nothing is staged, or when the staged tiles are not on a single * line — a shape only an illegal play produces, which the caller never asks about (it is invoked * only for a legal preview). The play axis is fixed by the staged tiles when two or more are * colinear; a lone tile takes whichever axis forms the longer word. */ export function formedGeometry( board: Board, pending: readonly Cell[], multipleWords: boolean, ): FormedGeometry | null { if (pending.length === 0) return null; const filled = filledPredicate(board, pending); const first = pending[0]; let dir: Dir; if (pending.length === 1) { const h = span(filled, 'H', first.row, first.col); const v = span(filled, 'V', first.row, first.col); dir = h.len >= v.len ? 'H' : 'V'; } else if (pending.every((p) => p.row === first.row)) { dir = 'H'; } else if (pending.every((p) => p.col === first.col)) { dir = 'V'; } else { return null; } const cells = new Set(); // Main word: the maximal run along the play axis through the staged tiles (contiguous via any // committed tiles between them), collected from any one staged tile. const ms = span(filled, dir, first.row, first.col); let main: MainWord; if (dir === 'H') { main = { row: first.row, col: ms.start, dir, len: ms.len }; for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c)); } else { main = { row: ms.start, col: first.col, dir, len: ms.len }; for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col)); } // Cross words: the perpendicular run through each staged tile, kept only when it is a real word // (two or more cells). Skipped under the single-word rule, where the engine ignores cross words, // so a perpendicular run must not be highlighted as if it were a formed word. if (multipleWords) { const cross: Dir = dir === 'H' ? 'V' : 'H'; for (const p of pending) { const cs = span(filled, cross, p.row, p.col); if (cs.len < 2) continue; if (cross === 'H') { for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c)); } else { for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col)); } } } return { main, cells }; } /** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */ export interface BadgeAnchor { row: number; col: number; corner: 'tr' | 'bl'; } /** * badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge * points into the board and away from the nearest edge (the board clamps the final pill fully * on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left * corner (horizontal → its first letter, vertical → its last letter); a word touching the left or * bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last * letter, vertical → its first letter). A full-width horizontal word (it touches both side edges) * anchors at the last letter's top-right, which the board then clamps leftward. */ export function badgePlacement(main: MainWord): BadgeAnchor { const last = BOARD_SIZE - 1; const h = main.dir === 'H'; const minRow = main.row; const maxRow = h ? main.row : main.row + main.len - 1; const minCol = main.col; const maxCol = h ? main.col + main.len - 1 : main.col; if (h && main.len === BOARD_SIZE) { return { row: main.row, col: maxCol, corner: 'tr' }; } if (maxCol === last || minRow === 0) { return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' }; } return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' }; }