From e483d1127a4e4f5bbee532a7cbc24ed0a4274a2a Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 6 May 2026 22:39:43 -0600 Subject: [PATCH] feat: add BoardFactory for word search grid generation Co-Authored-By: Claude Opus 4.6 --- game/__init__.py | 0 game/factory.py | 115 +++++++++++++++++++++++++++++++++++++ tests/unit/test_factory.py | 89 ++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 game/__init__.py create mode 100644 game/factory.py create mode 100644 tests/unit/test_factory.py diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/factory.py b/game/factory.py new file mode 100644 index 0000000..54dbe11 --- /dev/null +++ b/game/factory.py @@ -0,0 +1,115 @@ +import random +import string + +from models.category import CategoryWord +from models.game import Game, Word + +DIRECTIONS = { + "E": (0, 1), + "W": (0, -1), + "S": (1, 0), + "N": (-1, 0), + "SE": (1, 1), + "SW": (1, -1), + "NE": (-1, 1), + "NW": (-1, -1), +} + +MAX_PLACEMENT_ATTEMPTS = 100 + + +class BoardFactory: + def __init__(self, session): + self.session = session + + def create(self, category_id, board_size=10, word_count=8): + candidates = ( + self.session.query(CategoryWord) + .filter(CategoryWord.category_id == category_id) + .all() + ) + # Filter words that fit in the grid + candidates = [cw for cw in candidates if len(cw.word) <= board_size] + random.shuffle(candidates) + selected = candidates[:word_count] + + grid = [[""] * board_size for _ in range(board_size)] + placed_words = [] + + for cw in selected: + result = self._place_word(grid, cw.word, board_size) + if result: + row, col, direction = result + placed_words.append( + Word( + text=cw.word, + start_row=row, + start_col=col, + direction=direction, + ) + ) + + # Fill empty cells with random letters + for r in range(board_size): + for c in range(board_size): + if grid[r][c] == "": + grid[r][c] = random.choice(string.ascii_uppercase) + + game = Game( + category_id=category_id, + board_size=board_size, + grid=grid, + ) + + for w in placed_words: + w.game = game + + return game, placed_words + + def _place_word(self, grid, word, board_size): + directions = list(DIRECTIONS.keys()) + for _ in range(MAX_PLACEMENT_ATTEMPTS): + direction = random.choice(directions) + dr, dc = DIRECTIONS[direction] + word_len = len(word) + + # Calculate valid start ranges + if dr > 0: + row_range = range(0, board_size - word_len + 1) + elif dr < 0: + row_range = range(word_len - 1, board_size) + else: + row_range = range(0, board_size) + + if dc > 0: + col_range = range(0, board_size - word_len + 1) + elif dc < 0: + col_range = range(word_len - 1, board_size) + else: + col_range = range(0, board_size) + + if not row_range or not col_range: + continue + + row = random.choice(list(row_range)) + col = random.choice(list(col_range)) + + if self._can_place(grid, word, row, col, dr, dc): + self._do_place(grid, word, row, col, dr, dc) + return row, col, direction + + return None + + def _can_place(self, grid, word, row, col, dr, dc): + for i, letter in enumerate(word): + r = row + i * dr + c = col + i * dc + if grid[r][c] != "" and grid[r][c] != letter: + return False + return True + + def _do_place(self, grid, word, row, col, dr, dc): + for i, letter in enumerate(word): + r = row + i * dr + c = col + i * dc + grid[r][c] = letter diff --git a/tests/unit/test_factory.py b/tests/unit/test_factory.py new file mode 100644 index 0000000..1350efe --- /dev/null +++ b/tests/unit/test_factory.py @@ -0,0 +1,89 @@ +import random + +from models.category import Category, CategoryWord +from game.factory import BoardFactory + +DIRECTIONS = { + "E": (0, 1), + "W": (0, -1), + "S": (1, 0), + "N": (-1, 0), + "SE": (1, 1), + "SW": (1, -1), + "NE": (-1, 1), + "NW": (-1, -1), +} + + +def _seed_category(db_session, name, words): + cat = Category(name=name) + db_session.add(cat) + db_session.flush() + for w in words: + db_session.add(CategoryWord(category_id=cat.id, word=w)) + db_session.commit() + return cat + + +def test_factory_creates_game_and_words(db_session): + cat = _seed_category( + db_session, + "animals", + ["TIGER", "LION", "BEAR", "WOLF", "EAGLE", "HAWK", "DEER", "FROG"], + ) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=10, word_count=5) + assert game.board_size == 10 + assert game.category_id == cat.id + assert len(game.grid) == 10 + assert all(len(row) == 10 for row in game.grid) + assert len(words) <= 5 + assert len(words) >= 1 + + +def test_factory_words_are_on_grid(db_session): + cat = _seed_category( + db_session, + "test", + ["CAT", "DOG", "BAT", "RAT", "OWL", "COW", "PIG", "HEN"], + ) + random.seed(42) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=10, word_count=5) + for word in words: + dr, dc = DIRECTIONS[word.direction] + extracted = "" + r, c = word.start_row, word.start_col + for _ in range(len(word.text)): + extracted += game.grid[r][c] + r += dr + c += dc + assert extracted == word.text + + +def test_factory_fills_all_cells(db_session): + cat = _seed_category(db_session, "small", ["AB", "CD"]) + factory = BoardFactory(db_session) + game, _ = factory.create(category_id=cat.id, board_size=8, word_count=2) + for row in game.grid: + for cell in row: + assert cell.isalpha() and cell.isupper() and len(cell) == 1 + + +def test_factory_filters_words_too_long(db_session): + cat = _seed_category( + db_session, + "mixed", + ["HIPPOPOTAMUS", "CAT", "DOG"], # HIPPOPOTAMUS won't fit in 8x8 + ) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=8, word_count=3) + placed_texts = [w.text for w in words] + assert "HIPPOPOTAMUS" not in placed_texts + + +def test_factory_clamps_word_count(db_session): + cat = _seed_category(db_session, "tiny", ["CAT", "DOG"]) + factory = BoardFactory(db_session) + game, words = factory.create(category_id=cat.id, board_size=10, word_count=10) + assert len(words) <= 2