Created
May 23, 2025 16:15
-
-
Save apenney/37e83250d041a691bd3fbab977df067c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # scrabble_validator.py | |
| from typing import List, Set, Tuple, Optional | |
| from collections import deque | |
| class ScrabbleValidator: | |
| """Validates Scrabble boards according to official rules.""" | |
| BOARD_SIZE = 15 | |
| CENTER = (7, 7) | |
| def __init__(self, board: List[str]): | |
| """ | |
| Initialize validator with a board. | |
| Args: | |
| board: List of 15 strings, each 15 characters long. | |
| '.' represents empty squares, letters represent tiles. | |
| """ | |
| if len(board) != self.BOARD_SIZE: | |
| raise ValueError(f"Board must have {self.BOARD_SIZE} rows") | |
| for row in board: | |
| if len(row) != self.BOARD_SIZE: | |
| raise ValueError(f"Each row must have {self.BOARD_SIZE} characters") | |
| self.board = board | |
| def is_valid(self) -> bool: | |
| """ | |
| Check if the board is valid according to Scrabble rules. | |
| Returns: | |
| True if valid, False otherwise. | |
| """ | |
| # Rule 1: Center tile must contain a letter | |
| if self.board[self.CENTER[0]][self.CENTER[1]] == '.': | |
| return False | |
| # Rule 2: All tiles must be connected | |
| if not self._check_connectivity(): | |
| return False | |
| # Rule 3: At least two different letters | |
| if not self._has_two_different_letters(): | |
| return False | |
| # Rule 4: All words must be at least 2 letters long | |
| words = self.get_words() | |
| if not words: # No valid words found | |
| return False | |
| for word in words: | |
| if len(word) < 2: | |
| return False | |
| return True | |
| def get_words(self) -> List[str]: | |
| """ | |
| Extract all horizontal and vertical words from the board. | |
| Returns: | |
| List of words found on the board. | |
| """ | |
| words = [] | |
| # Find horizontal words | |
| for row in range(self.BOARD_SIZE): | |
| current_word = "" | |
| for col in range(self.BOARD_SIZE): | |
| if self.board[row][col] != '.': | |
| current_word += self.board[row][col] | |
| else: | |
| if len(current_word) >= 2: | |
| words.append(current_word) | |
| current_word = "" | |
| if len(current_word) >= 2: | |
| words.append(current_word) | |
| # Find vertical words | |
| for col in range(self.BOARD_SIZE): | |
| current_word = "" | |
| for row in range(self.BOARD_SIZE): | |
| if self.board[row][col] != '.': | |
| current_word += self.board[row][col] | |
| else: | |
| if len(current_word) >= 2: | |
| words.append(current_word) | |
| current_word = "" | |
| if len(current_word) >= 2: | |
| words.append(current_word) | |
| return words | |
| def _check_connectivity(self) -> bool: | |
| """ | |
| Check if all tiles form a single connected group including center. | |
| Returns: | |
| True if connected, False otherwise. | |
| """ | |
| # Find all tiles | |
| tiles = set() | |
| for row in range(self.BOARD_SIZE): | |
| for col in range(self.BOARD_SIZE): | |
| if self.board[row][col] != '.': | |
| tiles.add((row, col)) | |
| if not tiles: | |
| return False | |
| # BFS from center to check connectivity | |
| visited = set() | |
| queue = deque([self.CENTER]) | |
| visited.add(self.CENTER) | |
| directions = [(0, 1), (0, -1), (1, 0), (-1, 0)] | |
| while queue: | |
| row, col = queue.popleft() | |
| for dr, dc in directions: | |
| new_row, new_col = row + dr, col + dc | |
| if (0 <= new_row < self.BOARD_SIZE and | |
| 0 <= new_col < self.BOARD_SIZE and | |
| (new_row, new_col) in tiles and | |
| (new_row, new_col) not in visited): | |
| visited.add((new_row, new_col)) | |
| queue.append((new_row, new_col)) | |
| # All tiles should be visited | |
| return visited == tiles | |
| def _has_two_different_letters(self) -> bool: | |
| """ | |
| Check if there are at least two different letters on the board. | |
| Returns: | |
| True if at least two different letters exist, False otherwise. | |
| """ | |
| letters = set() | |
| for row in self.board: | |
| for char in row: | |
| if char != '.': | |
| letters.add(char) | |
| if len(letters) >= 2: | |
| return True | |
| return False | |
| def validate_board(board: List[str]) -> bool: | |
| """ | |
| Convenience function to validate a board. | |
| Args: | |
| board: List of 15 strings, each 15 characters long. | |
| Returns: | |
| True if valid, False otherwise. | |
| """ | |
| validator = ScrabbleValidator(board) | |
| return validator.is_valid() | |
| def get_board_words(board: List[str]) -> Optional[List[str]]: | |
| """ | |
| Get all words from a board if it's valid. | |
| Args: | |
| board: List of 15 strings, each 15 characters long. | |
| Returns: | |
| List of words if board is valid, None otherwise. | |
| """ | |
| validator = ScrabbleValidator(board) | |
| if validator.is_valid(): | |
| return validator.get_words() | |
| return None |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # scrabble_validator.py | |
| import collections | |
| EMPTY_SQUARE = '.' | |
| BOARD_SIZE = 15 | |
| CENTER_ROW = 7 | |
| CENTER_COL = 7 | |
| class ScrabbleValidator: | |
| def __init__(self, board_str_list): | |
| if not isinstance(board_str_list, list) or len(board_str_list) != BOARD_SIZE: | |
| raise ValueError(f"Board must be a list of {BOARD_SIZE} strings.") | |
| if not all(isinstance(row, str) and len(row) == BOARD_SIZE for row in board_str_list): | |
| raise ValueError(f"Each row in the board must be a string of {BOARD_SIZE} characters.") | |
| self.board_str_list = board_str_list # Keep original format for reference if needed | |
| self.board = [list(row) for row in board_str_list] # Mutable working copy | |
| self.rows = BOARD_SIZE | |
| self.cols = BOARD_SIZE | |
| self.all_letters_coords = set() | |
| # _parse_board_letters will be called as part of is_valid to handle potential invalid chars correctly | |
| self.discovered_words = set() | |
| self._is_validated = False | |
| self._is_valid_board = False # Cache validation result | |
| self._validation_error_message = "" # Store first error message | |
| def _parse_board_letters_and_check_chars(self): | |
| self.all_letters_coords.clear() | |
| for r in range(self.rows): | |
| for c in range(self.cols): | |
| char = self.board[r][c] | |
| if char == EMPTY_SQUARE: | |
| continue | |
| if 'A' <= char <= 'Z': | |
| self.all_letters_coords.add((r, c)) | |
| else: | |
| # Invalid character on board | |
| self._validation_error_message = f"Invalid character '{char}' at ({r},{c})." | |
| return False # Found invalid character | |
| return True | |
| def _check_center_tile_occupied(self): | |
| if self.board[CENTER_ROW][CENTER_COL] == EMPTY_SQUARE: | |
| self._validation_error_message = "Center tile (7,7) is empty." | |
| return False | |
| # Ensured by _parse_board_letters_and_check_chars that if not EMPTY_SQUARE, it's an uppercase letter | |
| if (CENTER_ROW, CENTER_COL) not in self.all_letters_coords: | |
| # This case implies center tile has an invalid char, caught by _parse_board_letters_and_check_chars | |
| self._validation_error_message = "Center tile (7,7) does not contain an uppercase letter." | |
| return False | |
| return True | |
| def _check_at_least_two_different_letters(self): | |
| if not self.all_letters_coords: | |
| # This implies an empty board or board with only invalid chars, | |
| # which should be caught by _check_center_tile_occupied. | |
| self._validation_error_message = "Board has no letters." # Should not be primary error if center rule exists | |
| return False | |
| distinct_letters = set() | |
| for r, c in self.all_letters_coords: | |
| distinct_letters.add(self.board[r][c]) | |
| if len(distinct_letters) < 2: | |
| self._validation_error_message = f"Board must contain at least two different letters. Found: {distinct_letters or 'None'}" | |
| return False | |
| return True | |
| def _find_all_words_and_check_coverage(self): | |
| self.discovered_words.clear() | |
| letters_in_found_words = set() | |
| # Find horizontal words | |
| for r in range(self.rows): | |
| current_word_coords = [] | |
| for c in range(self.cols): | |
| if self.board[r][c] != EMPTY_SQUARE: # It's a letter | |
| current_word_coords.append((r,c)) | |
| else: | |
| if len(current_word_coords) >= 2: | |
| word_str = "".join(self.board[coord[0]][coord[1]] for coord in current_word_coords) | |
| self.discovered_words.add(word_str) | |
| letters_in_found_words.update(current_word_coords) | |
| current_word_coords = [] | |
| if len(current_word_coords) >= 2: # Check for word at the end of the row | |
| word_str = "".join(self.board[coord[0]][coord[1]] for coord in current_word_coords) | |
| self.discovered_words.add(word_str) | |
| letters_in_found_words.update(current_word_coords) | |
| # Find vertical words | |
| for c in range(self.cols): | |
| current_word_coords = [] | |
| for r in range(self.rows): | |
| if self.board[r][c] != EMPTY_SQUARE: # It's a letter | |
| current_word_coords.append((r,c)) | |
| else: | |
| if len(current_word_coords) >= 2: | |
| word_str = "".join(self.board[coord[0]][coord[1]] for coord in current_word_coords) | |
| self.discovered_words.add(word_str) | |
| letters_in_found_words.update(current_word_coords) | |
| current_word_coords = [] | |
| if len(current_word_coords) >= 2: # Check for word at the end of the column | |
| word_str = "".join(self.board[coord[0]][coord[1]] for coord in current_word_coords) | |
| self.discovered_words.add(word_str) | |
| letters_in_found_words.update(current_word_coords) | |
| # If there are letters on the board, but no words of length >= 2 were formed | |
| if self.all_letters_coords and not self.discovered_words: | |
| self._validation_error_message = "No words of length 2 or more found, but letters exist on board." | |
| return False | |
| # All letters on the board must be part of at least one word of length >= 2 | |
| if self.all_letters_coords != letters_in_found_words: | |
| # Example: An isolated letter 'A' is in all_letters_coords but not letters_in_found_words | |
| uncovered_letters = self.all_letters_coords - letters_in_found_words | |
| self._validation_error_message = f"Not all letters are part of a word of length >= 2. Uncovered at: {uncovered_letters}" | |
| return False | |
| return True | |
| def _check_single_connected_group(self): | |
| if not self.all_letters_coords: | |
| # This means board is empty or has only invalid characters. | |
| # This should be caught by the center tile rule. | |
| # If center tile is occupied (and thus in all_letters_coords), this won't be true. | |
| self._validation_error_message = "Connectivity check on board with no valid letters." # Should be pre-empted | |
| return False | |
| # Center tile must be occupied by a letter; this is checked by _check_center_tile_occupied. | |
| # (CENTER_ROW, CENTER_COL) must be in self.all_letters_coords if center rule passed. | |
| if (CENTER_ROW, CENTER_COL) not in self.all_letters_coords: | |
| # This can happen if center is empty (caught by _check_center_tile_occupied) | |
| # or if _parse_board_letters_and_check_chars failed to add it (e.g. invalid char at center) | |
| self._validation_error_message = "Center tile not part of letter group for connectivity check." | |
| return False | |
| q = collections.deque([(CENTER_ROW, CENTER_COL)]) | |
| visited = {(CENTER_ROW, CENTER_COL)} | |
| while q: | |
| r, c = q.popleft() | |
| for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]: | |
| nr, nc = r + dr, c + dc | |
| # Check if neighbor is a letter tile (already validated to be 'A'-'Z') | |
| if (nr, nc) in self.all_letters_coords and (nr, nc) not in visited: | |
| # Bounds check (0 <= nr < self.rows etc.) is implicitly handled by | |
| # (nr, nc) being in self.all_letters_coords, which only contains valid board coords. | |
| visited.add((nr, nc)) | |
| q.append((nr, nc)) | |
| if visited != self.all_letters_coords: | |
| disconnected_components = self.all_letters_coords - visited | |
| self._validation_error_message = f"Not all letters form a single connected group from the center. Unconnected letters: {disconnected_components}" | |
| return False | |
| return True | |
| def is_valid(self): | |
| if self._is_validated: | |
| return self._is_valid_board | |
| self._validation_error_message = "" # Reset error message | |
| # Rule 0: Board must contain only valid characters ('A'-'Z' or '.') | |
| # Also populates self.all_letters_coords | |
| if not self._parse_board_letters_and_check_chars(): | |
| # _validation_error_message is set within the method | |
| self._is_valid_board = False | |
| self._is_validated = True | |
| return False | |
| # Rule 1: Center tile must contain a letter. | |
| if not self._check_center_tile_occupied(): | |
| self._is_valid_board = False | |
| self._is_validated = True | |
| return False | |
| # If center is occupied, all_letters_coords is guaranteed not empty. | |
| # Rule 3: At least two different letters. | |
| if not self._check_at_least_two_different_letters(): | |
| self._is_valid_board = False | |
| self._is_validated = True | |
| return False | |
| # Rule 4 & 5: All words formed must be at least 2 letters long (horizontal/vertical). | |
| # All letters on board must be part of such words. | |
| # This populates self.discovered_words. | |
| if not self._find_all_words_and_check_coverage(): | |
| self._is_valid_board = False | |
| self._is_validated = True | |
| return False | |
| # Rule 2: All non-empty spaces must form a single connected group that includes the center tile. | |
| if not self._check_single_connected_group(): | |
| self._is_valid_board = False | |
| self._is_validated = True | |
| return False | |
| self._is_valid_board = True | |
| self._is_validated = True | |
| return True | |
| def get_words(self): | |
| if not self._is_validated: | |
| self.is_valid() | |
| if self._is_valid_board: | |
| return sorted(list(self.discovered_words)) | |
| else: | |
| return [] | |
| def get_last_error(self): | |
| """Returns the reason for the last validation failure.""" | |
| return self._validation_error_message |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment