diff --git a/multiplayer_crosswords/crossword.py b/multiplayer_crosswords/crossword.py index e41826f..19691ab 100644 --- a/multiplayer_crosswords/crossword.py +++ b/multiplayer_crosswords/crossword.py @@ -1,158 +1,337 @@ from enum import Enum from typing import List, Tuple, Optional from multiplayer_crosswords.dictionary import Dictionary, Word -from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation +from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots import random -class PlacedWord: - def __init__(self, word: Word, row: int, col: int, orientation: Orientation): - self.word = word - self.row = row - self.col = col - self.orientation = orientation +from multiplayer_crosswords.utils import load_de_dictionary +class CrosswordWord: + + def __init__( + self, + word: str, + start_x: int, + start_y: int, + orientation: Orientation, + hist: str, + index: Optional[int], + solved: bool = False, + + ): + self.word = word + self.start_x = start_x + self.start_y = start_y + self.orientation = orientation + self.hist = hist + self.length = len(word) + self.index: Optional[int] = index + self.solved = solved + + class Crossword: + @property + def solved_grid(self) -> List[List[Optional[str]]]: + return self._solved_grid + + @property + def dictionary(self) -> Dictionary: + return self._dictionary + + @property + def words(self) -> List[CrosswordWord]: + return self._words + + + def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]: + """Get the list of CrosswordWord objects that start at position (x, y).""" + result = [] + horizontal_cw = self._horizontal_words_by_y_x_position.get((y, x)) + if horizontal_cw is not None: + result.append(horizontal_cw) + vertical_cw = self._vertical_words_by_y_x_position.get((y, x)) + if vertical_cw is not None: + result.append(vertical_cw) + return result + + @classmethod + def from_serialized(cls, data: dict, dictionary: Dictionary) -> "Crossword": + """Create a Crossword object from serialized data.""" + grid = data.get("solved_grid", []) + + words: List[CrosswordWord] = [] + for wdata in data.get("words", []): + cw = CrosswordWord( + word=wdata.get("word"), + start_x=wdata.get("start_x"), + start_y=wdata.get("start_y"), + orientation=Orientation[wdata.get("orientation")], + hist=wdata.get("hint", ""), + index=wdata.get("index", None), + solved=wdata.get("solved", False) + ) + words.append(cw) + + crossword = cls( + dictionary=dictionary, + grid=grid, + current_grid=data.get("current_grid", None), + words=words + ) + + return crossword + @staticmethod def generate( - rows: int, - cols: int, dictionary: Dictionary, - seed: Optional[int] = None, - block_ratio: float = 0.4, - ) -> "Crossword": + grid_width: int = 15, + grid_height: int = 15, + grid_block_ratio: float = 0.4, + seed: Optional[int] = None, + + ) -> "Crossword": + + if seed is None: + # initialize seed based on current system time + seed = random.randint(0, 2**32 - 1) generator = CrosswordGeneratorStep( + grid_width=grid_width, + grid_height=grid_height, dictionary=dictionary, - seed=seed, - grid_width=cols, - grid_height=rows, - grid_block_ratio=block_ratio + grid_block_ratio=grid_block_ratio, + seed=seed ) final_step = generator.generate( max_tries_per_step=2, - show_progress=False, max_allowed_single_choice=1, max_allowed_threshold_choice=3, - max_allowed_threshold=5 + max_allowed_threshold=5, + show_progress=False ) - crossword = Crossword(rows, cols, dictionary) - horizontal_slots = final_step.get_horizontal_slots() - vertical_slots = final_step.get_vertical_slots() + if final_step is None: + return None - grid = final_step.get_grid() - - horizontal_words = [] - vertical_words = [] - - for slot in horizontal_slots: - word_str = ''.join(grid[slot.row][slot.col + i] for i in range(slot.length)) - horizontal_words.append((word_str, slot.row, slot.col, Orientation.HORIZONTAL)) - - word_candidates = list(dictionary.find_by_pattern(word_str)) - if len(word_candidates) <= 0: - raise ValueError(f"No word found for pattern '{word_str}' at ({slot.row}, {slot.col})") - - # choose a candidate at random - chosen_word = random.choice(word_candidates).copy_with_random_hint() - crossword.add_word(chosen_word, slot.row, slot.col, Orientation.HORIZONTAL) - - - - for slot in vertical_slots: - word_str = ''.join(grid[slot.row + i][slot.col] for i in range(slot.length)) - vertical_words.append((word_str, slot.row, slot.col, Orientation.VERTICAL)) - word_candidates = list(dictionary.find_by_pattern(word_str)) - if len(word_candidates) <= 0: - raise ValueError(f"No word found for pattern '{word_str}' at ({slot.row}, {slot.col})") - - chosen_word = random.choice(word_candidates).copy_with_random_hint() - crossword.add_word(chosen_word, slot.row, slot.col, Orientation.VERTICAL) - - return crossword - - def __init__(self, rows: int, cols: int, dictionary: Dictionary): - self.rows = rows - self.cols = cols - self.grid = [['' for _ in range(cols)] for _ in range(rows)] - self.dictionary = dictionary - self.placed_words: List[PlacedWord] = [] + return Crossword( + dictionary=dictionary, + grid=final_step.grid, + ) + + def __init__( + self, + dictionary: Dictionary, + grid: List[List[Optional[str]]], + current_grid: Optional[List[List[Optional[str]]]] = None, + words: Optional[List[CrosswordWord]] = None, + ): + self._dictionary = dictionary + self._solved_grid = grid + self._words: List[CrosswordWord] = [] - def _in_bounds(self, row: int, col: int) -> bool: - return 0 <= row < self.rows and 0 <= col < self.cols + self._horizontal_words_by_y_x_position = {} + self._vertical_words_by_y_x_position = {} - def can_place_word(self, word: Word, row: int, col: int, orientation: Orientation) -> bool: - length = len(word.word) - dr, dc = (0, 1) if orientation == Orientation.HORIZONTAL else (1, 0) + if current_grid is not None: + self._current_grid = current_grid + + else: + # create an empty grid (empty = all letter cells are '') + self._current_grid = [['' for _ in range(len(grid[0]))] for _ in range(len(grid))] - # Check start block - start_r, start_c = row - dr, col - dc - if self._in_bounds(start_r, start_c): - if self.grid[start_r][start_c] not in ('', '#'): - return False - if self.grid[start_r][start_c] == '#': - return False + # place walls in the current grid + for r in range(len(grid)): + for c in range(len(grid[0])): + if grid[r][c] == '#': + self._current_grid[r][c] = '#' + + if words is not None: + self._words = words + else: + self.extract_words() - # Check end block - end_r, end_c = row + dr * length, col + dc * length - if self._in_bounds(end_r, end_c): - if self.grid[end_r][end_c] not in ('', '#'): - return False - if self.grid[end_r][end_c] == '#': - return False + if current_grid is not None: + # current grid was passed, check which words are already solved + for cw in self._words: + cw.solved = self.check_word_solved(cw) + + for cw in self._words: + if cw.orientation == Orientation.HORIZONTAL: + for i in range(cw.length): + self._horizontal_words_by_y_x_position[(cw.start_y, cw.start_x + i)] = cw + else: + for i in range(cw.length): + self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw - for i in range(length): - r, c = row + dr * i, col + dc * i - if not self._in_bounds(r, c): - return False - cell = self.grid[r][c] - if cell == '#': - return False - if cell != '' and cell != word.word[i]: - return False + - return True + def extract_words(self): + """Extract all fully placed words from the grid and store them in self._words. - def add_word(self, word: Word, row: int, col: int, orientation: Orientation) -> bool: - if not self.can_place_word(word, row, col, orientation): + A "placed" word is a slot (horizontal or vertical, length >= 2) where every + cell contains a letter (not '' and not '#'). For each found word we try to + retrieve a random hint from the dictionary (using Dictionary.Word.copy_with_random_hint) + and store it on the CrosswordWord.hist field (empty string if no hint found). + """ + self._words = [] + + # Use extract_slots to find candidate slots (min_length=2) + slots = extract_slots(self._solved_grid, min_length=2) + + word_index = 1 + + for slot in slots: + # Build the word and check all cells are filled with letters + dr, dc = (0, 1) if slot.orientation == Orientation.HORIZONTAL else (1, 0) + chars = [] + fully_filled = True + for i in range(slot.length): + r, c = slot.row + dr * i, slot.col + dc * i + cell = self._solved_grid[r][c] + # treat '#' or empty string as not filled + if not cell or cell == '#': + fully_filled = False + break + chars.append(cell) + + if not fully_filled: + continue + + word_str = ''.join(chars) + + # Find the Word object in the dictionary (if available) to extract a random hint + hint_str = "" + try: + match = next((w for w in self._dictionary.words if w.word == word_str), None) + if match is not None: + hint_word = match.copy_with_random_hint() + hint_str = hint_word.hints[0] if hint_word.hints else "" + except Exception: + # Fallback: ignore dictionary problems and leave hint empty + + hint_str = "ERROR while generating hint for word " + word_str + + if len(hint_str) == 0: + hint_str = "No hint available for this word: " + word_str + + # start_x/start_y: choose x=col, y=row (cartesian-like) + cw = CrosswordWord( + word=word_str, + start_x=slot.col, + start_y=slot.row, + orientation=slot.orientation, + hist=hint_str, + index=word_index, + ) + word_index += 1 + self._words.append(cw) + + def serialize(self) -> dict: + """Serialize the crossword to a dictionary format.""" + return { + "grid_width": len(self._solved_grid[0]), + "grid_height": len(self._solved_grid), + "solved_grid": self._solved_grid, + "current_grid": self._current_grid, + "words": [ + { + "word": w.word, + "start_x": w.start_x, + "start_y": w.start_y, + "orientation": w.orientation.name, + "hint": w.hist, + "length": w.length, + "solved": w.solved, + "index": w.index, + } + for w in self._words + ], + } + + def place_letter(self, y: int, x: int, letter: str) -> bool: + """Place a letter at position (x, y) in the current grid. + + Args: + x (int): The x-coordinate (column index). + y (int): The y-coordinate (row index). + letter (str): The letter to place. + Returns: + bool: True if the letter was placed successfully, False otherwise. + """ + if y < 0 or y >= len(self._current_grid): return False + if x < 0 or x >= len(self._current_grid[0]): + return False + if self._solved_grid[y][x] == '#': + return False # Cannot place letter on a wall + self._current_grid[y][x] = letter - length = len(word.word) - dr, dc = (0, 1) if orientation == Orientation.HORIZONTAL else (1, 0) + horizontal_cw = self._horizontal_words_by_y_x_position.get((y, x)) + if horizontal_cw is not None: + horizontal_cw.solved = self.check_word_solved(horizontal_cw) - # Place start block - start_r, start_c = row - dr, col - dc - if self._in_bounds(start_r, start_c): - self.grid[start_r][start_c] = '#' + vertical_cw = self._vertical_words_by_y_x_position.get((y, x)) + if vertical_cw is not None: + vertical_cw.solved = self.check_word_solved(vertical_cw) - # Place word - for i in range(length): - r, c = row + dr * i, col + dc * i - self.grid[r][c] = word.word[i] - - # Place end block - end_r, end_c = row + dr * length, col + dc * length - if self._in_bounds(end_r, end_c): - self.grid[end_r][end_c] = '#' - - self.placed_words.append(PlacedWord(word, row, col, orientation)) return True + def check_word_solved(self, crossword_word: CrosswordWord) -> bool: + """Check if the given CrosswordWord is correctly solved in the current grid. + + Args: + crossword_word (CrosswordWord): The word to check. + + Returns: + bool: True if the word is correctly solved, False otherwise. + """ + dr, dc = (0, 1) if crossword_word.orientation == Orientation.HORIZONTAL else (1, 0) + for i in range(crossword_word.length): + r = crossword_word.start_y + dr * i + c = crossword_word.start_x + dc * i + if self._current_grid[r][c] != crossword_word.word[i]: + return False + return True + + def __str__(self): - return '\n'.join( - ''.join(cell if cell else '.' for cell in row) - for row in self.grid - ) + # Simple string representation for debugging + result = "\nCurrent Grid:\n" + for row in self._current_grid: + # print on stdout dircectly and only flush in the end + row_str = ' '.join(cell if cell else ' ' for cell in row).replace("#", "█") + "\n" + row_str = row_str.replace("█ █", "███").replace("█ █", "███") + #row_str = row_str.replace(" █", "██") + result += row_str + result += "\n\nSolved Grid:\n" + for row in self._solved_grid: + row_str = ' '.join(cell if cell else ' ' for cell in row).replace("#", "█") + "\n" + row_str = row_str.replace("█ █", "███").replace("█ █", "███") + result += row_str + return result + + if __name__ == "__main__": # Example usage - dictionary = Dictionary() - dictionary.add_word(Word("hello", ["greeting"], "easy")) - dictionary.add_word(Word("world", ["earth"], "easy")) + dictionary = load_de_dictionary() + + dictionary._build_pos_index_list() + + + crossword = Crossword.generate( + dictionary=dictionary, + seed=None, + grid_width=20, + grid_height=20, + grid_block_ratio=0.38 + ) + + crossword.extract_words() + + for word in crossword.words: + print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hist}") - crossword = Crossword(5, 5, dictionary) - crossword.add_word(dictionary.words[0], 1, 0, Orientation.HORIZONTAL) - crossword.add_word(dictionary.words[1], 0, 4, Orientation.VERTICAL) print(crossword) \ No newline at end of file diff --git a/multiplayer_crosswords/crossword_algorithm.py b/multiplayer_crosswords/crossword_algorithm.py index b2bfd4a..f4ffd8e 100644 --- a/multiplayer_crosswords/crossword_algorithm.py +++ b/multiplayer_crosswords/crossword_algorithm.py @@ -218,6 +218,10 @@ def generate_grid_template(width: int, height: int, block_ratio: float = 0.25, s class CrosswordGeneratorStep(object): + @property + def grid(self) -> List[List[str]]: + return self._grid + def __init__( self, dictionary: Dictionary, diff --git a/multiplayer_crosswords/websocket_server.py b/multiplayer_crosswords/websocket_server.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_crossword.py b/tests/test_crossword.py index e748936..068cc44 100644 --- a/tests/test_crossword.py +++ b/tests/test_crossword.py @@ -1,115 +1,91 @@ import pytest -from multiplayer_crosswords.crossword import Crossword, Orientation, PlacedWord -from unittest.mock import MagicMock -from multiplayer_crosswords.dictionary import Dictionary -from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary -class DummyWord: - def __init__(self, word): - self.word = word - -class DummyDictionary: - def __init__(self, words): - self.words = words - -def make_crossword(rows=5, cols=5): - dictionary = DummyDictionary([DummyWord("HELLO"), DummyWord("WORLD")]) - return Crossword(rows, cols, dictionary) - -def test_can_place_word_empty_grid_horizontal(): - cw = make_crossword() - word = DummyWord("HELLO") - assert cw.can_place_word(word, 0, 0, Orientation.HORIZONTAL) - assert not cw.can_place_word(word, 0, 1, Orientation.HORIZONTAL) # would go out of bounds - -def test_can_place_word_empty_grid_vertical(): - cw = make_crossword() - word = DummyWord("HELLO") - assert cw.can_place_word(word, 0, 0, Orientation.VERTICAL) - assert not cw.can_place_word(word, 1, 0, Orientation.VERTICAL) # would go out of bounds - -def test_add_word_and_grid_update(): - cw = make_crossword() - word = DummyWord("HELLO") - assert cw.add_word(word, 0, 0, Orientation.HORIZONTAL) - # Check grid contents - assert cw.grid[0][0] == "H" - assert cw.grid[0][1] == "E" - assert cw.grid[0][2] == "L" - assert cw.grid[0][3] == "L" - assert cw.grid[0][4] == "O" - # Start and end blocks - assert cw.grid[0][0-0][0-1] == "#" if cw._in_bounds(0, -1) else True - assert cw.grid[0][5] == "#" if cw._in_bounds(0, 5) else True - -def test_cannot_place_overlapping_different_letter(): - cw = make_crossword() - word1 = DummyWord("HELLO") - word2 = DummyWord("WORLD") - assert cw.add_word(word1, 0, 0, Orientation.HORIZONTAL) - # Try to place "WORLD" vertically overlapping 'E' with 'O' - assert not cw.can_place_word(word2, 0, 1, Orientation.VERTICAL) - -def test_can_place_overlapping_same_letter(): - cw = make_crossword() - word1 = DummyWord("HELLO") - word2 = DummyWord("EVE") - assert cw.add_word(word1, 0, 0, Orientation.HORIZONTAL) - # "EVE" vertical, E overlaps at (0,1) - word2 = DummyWord("EVE") - assert cw.can_place_word(word2, 0, 1, Orientation.VERTICAL) - assert cw.add_word(word2, 0, 1, Orientation.VERTICAL) - -def test_blocks_are_placed_correctly(): - cw = make_crossword() - word = DummyWord("HI") - assert cw.add_word(word, 2, 2, Orientation.HORIZONTAL) - # Start block - assert cw.grid[2][1] == "#" - # End block - assert cw.grid[2][4] == "#" - -def test_str_representation(): - cw = make_crossword() - word = DummyWord("HI") - cw.add_word(word, 0, 0, Orientation.HORIZONTAL) - s = str(cw) - assert "HI" in s - assert "." in s - -def test_placed_words_tracking(): - cw = make_crossword() - word = DummyWord("HI") - cw.add_word(word, 1, 1, Orientation.VERTICAL) - assert len(cw.placed_words) == 1 - placed = cw.placed_words[0] - assert placed.word == word - assert placed.row == 1 - assert placed.col == 1 - assert placed.orientation == Orientation.VERTICAL - -def test_valid_hello_world_crossword(): - cw = make_crossword() - assert cw.add_word(DummyWord("HELLO"), 1, 0, Orientation.HORIZONTAL) - assert cw.add_word(DummyWord("WORLD"), 0, 4, Orientation.VERTICAL) - assert str(cw).count('H') == 1 - assert str(cw).count('E') == 1 - assert str(cw).count('L') == 3 - assert str(cw).count('O') == 1 - assert str(cw).count('W') == 1 - assert str(cw).count('R') == 1 - assert str(cw).count('D') == 1 +from multiplayer_crosswords.crossword import Crossword, CrosswordWord +from multiplayer_crosswords.dictionary import Dictionary, Word -def test_crossword_generation(): - dictionary = load_de_dictionary() - crossword = Crossword.generate(15, 15, dictionary) - assert crossword is not None +def make_test_dictionary(): + d = Dictionary() + d.add_word(Word("dog", hints=["bark"], difficulty=1.0)) + d.add_word(Word("cat", hints=["meow"], difficulty=1.0)) + return d - # Basic checks on the generated crossword - assert len(crossword.grid) == 15 - assert all(len(row) == 15 for row in crossword.grid) - assert len(crossword.placed_words) > 0 - # print the crossword for visual inspection - print(crossword) \ No newline at end of file +def test_extract_words_and_positions(): + # small 5x5 grid with two placed words: "dog" horizontal at (0,0) + # and "cat" vertical starting at (4,1) + grid = [ + ["d", "o", "g", "#", "#"], + ["#", "#", "#", "#", "c"], + ["#", "#", "#", "#", "a"], + ["#", "#", "#", "#", "t"], + ["#", "#", "#", "#", "#"], + ] + + d = make_test_dictionary() + + cw = Crossword(dictionary=d, grid=grid) + + # Two words should be extracted + words = cw.words + assert len(words) == 2 + + words_by_text = {w.word: w for w in words} + assert "dog" in words_by_text and "cat" in words_by_text + + dog = words_by_text["dog"] + assert dog.start_x == 0 and dog.start_y == 0 + assert dog.orientation.name == "HORIZONTAL" + # Hint should come from dictionary + assert dog.hist in ("bark",) or dog.hist.startswith("No hint available") is False + + cat = words_by_text["cat"] + assert cat.start_x == 4 and cat.start_y == 1 + assert cat.orientation.name == "VERTICAL" + + # get_words_by_y_x_position uses x,y ordering + by_pos = cw.get_words_by_y_x_position(0, 0) + assert any(w.word == "dog" for w in by_pos) + + by_pos_cat = cw.get_words_by_y_x_position(4, 1) + assert any(w.word == "cat" for w in by_pos_cat) + + +def test_place_letter_and_check_solved(): + grid = [ + ["d", "o", "g", "#"], + ["#", "#", "#", "#"], + ] + d = make_test_dictionary() + cw = Crossword(dictionary=d, grid=grid) + + # Initially not solved + dog = next(w for w in cw.words if w.word == "dog") + assert dog.solved is False + + # Place letters one by one + assert cw.place_letter(0, 0, "d") is True + assert cw.place_letter(0, 1, "o") is True + assert cw.place_letter(0, 2, "g") is True + + # Now the word should be marked solved + assert dog.solved is True + + # Cannot place on a wall + assert cw.place_letter(3, 0, "x") is False + + +def test_serialize_and_from_serialized_roundtrip(): + grid = [ + ["d", "o", "g", "#"], + ["#", "#", "#", "#"], + ] + d = make_test_dictionary() + cw = Crossword(dictionary=d, grid=grid) + + data = cw.serialize() + reconstructed = Crossword.from_serialized(data, dictionary=d) + + assert reconstructed is not None + assert len(reconstructed.words) == len(cw.words) + assert reconstructed.serialize()["solved_grid"] == cw.serialize()["solved_grid"]