Skip to content

Instantly share code, notes, and snippets.

@apenney
Created May 23, 2025 16:15
Show Gist options
  • Select an option

  • Save apenney/37e83250d041a691bd3fbab977df067c to your computer and use it in GitHub Desktop.

Select an option

Save apenney/37e83250d041a691bd3fbab977df067c to your computer and use it in GitHub Desktop.
# 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
# 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