From bd54ce3f37fe56e847964e7da33e05810787d9ac Mon Sep 17 00:00:00 2001 From: Jonas Weinz Date: Sun, 5 Oct 2025 20:38:25 +0200 Subject: [PATCH] generate crosswords using crossword algorithm --- multiplayer_crosswords/crossword.py | 66 +++++++++++++++++-- ...d_generators.py => crossword_algorithm.py} | 14 +++- multiplayer_crosswords/dictionary.py | 9 +++ tests/test_crossword.py | 18 ++++- tests/test_crossword_generators.py | 2 +- 5 files changed, 102 insertions(+), 7 deletions(-) rename multiplayer_crosswords/{crossword_generators.py => crossword_algorithm.py} (97%) diff --git a/multiplayer_crosswords/crossword.py b/multiplayer_crosswords/crossword.py index 3e0b0bd..e41826f 100644 --- a/multiplayer_crosswords/crossword.py +++ b/multiplayer_crosswords/crossword.py @@ -1,10 +1,8 @@ from enum import Enum from typing import List, Tuple, Optional from multiplayer_crosswords.dictionary import Dictionary, Word - -class Orientation(Enum): - HORIZONTAL = 1 - VERTICAL = 2 +from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation +import random class PlacedWord: def __init__(self, word: Word, row: int, col: int, orientation: Orientation): @@ -14,12 +12,72 @@ class PlacedWord: self.orientation = orientation class Crossword: + + @staticmethod + def generate( + rows: int, + cols: int, + dictionary: Dictionary, + seed: Optional[int] = None, + block_ratio: float = 0.4, + ) -> "Crossword": + generator = CrosswordGeneratorStep( + dictionary=dictionary, + seed=seed, + grid_width=cols, + grid_height=rows, + grid_block_ratio=block_ratio + ) + 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 + ) + crossword = Crossword(rows, cols, dictionary) + + horizontal_slots = final_step.get_horizontal_slots() + vertical_slots = final_step.get_vertical_slots() + + 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] = [] + def _in_bounds(self, row: int, col: int) -> bool: return 0 <= row < self.rows and 0 <= col < self.cols diff --git a/multiplayer_crosswords/crossword_generators.py b/multiplayer_crosswords/crossword_algorithm.py similarity index 97% rename from multiplayer_crosswords/crossword_generators.py rename to multiplayer_crosswords/crossword_algorithm.py index 6a240bd..b2bfd4a 100644 --- a/multiplayer_crosswords/crossword_generators.py +++ b/multiplayer_crosswords/crossword_algorithm.py @@ -1,12 +1,16 @@ # algorithms will be implemnented here from typing import List, Tuple, Dict, Set, Optional -from multiplayer_crosswords.crossword import Crossword, Orientation from multiplayer_crosswords.dictionary import Dictionary, Word from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary from copy import copy, deepcopy import random import os +from enum import Enum + +class Orientation(Enum): + HORIZONTAL = 1 + VERTICAL = 2 class Slot: def __init__(self, row: int, col: int, orientation: Orientation, length: int): @@ -429,6 +433,14 @@ class CrosswordGeneratorStep(object): result += row_str print(result, flush=True) + + def get_horizontal_slots(self) -> List[Slot]: + return [slot for slot in self._known_slots if slot.orientation == Orientation.HORIZONTAL] + def get_vertical_slots(self) -> List[Slot]: + return [slot for slot in self._known_slots if slot.orientation == Orientation.VERTICAL] + + def get_grid(self) -> List[List[str]]: + return self._grid def __str__(self): diff --git a/multiplayer_crosswords/dictionary.py b/multiplayer_crosswords/dictionary.py index 171a757..bec5c73 100644 --- a/multiplayer_crosswords/dictionary.py +++ b/multiplayer_crosswords/dictionary.py @@ -8,6 +8,8 @@ import numpy as np import cProfile import pstats +import random + class Word: @@ -15,6 +17,13 @@ class Word: self.word = word self.hints = hints self.difficulty = difficulty + + def copy_with_random_hint(self) -> "Word": + if not self.hints: + return Word(self.word, [], self.difficulty) + hint = random.choice(self.hints) + return Word(self.word, [hint], self.difficulty) + def to_dict(self) -> dict[str, any]: return { diff --git a/tests/test_crossword.py b/tests/test_crossword.py index b6d59ff..e748936 100644 --- a/tests/test_crossword.py +++ b/tests/test_crossword.py @@ -1,6 +1,8 @@ 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): @@ -96,4 +98,18 @@ def test_valid_hello_world_crossword(): assert str(cw).count('O') == 1 assert str(cw).count('W') == 1 assert str(cw).count('R') == 1 - assert str(cw).count('D') == 1 \ No newline at end of file + assert str(cw).count('D') == 1 + + +def test_crossword_generation(): + dictionary = load_de_dictionary() + crossword = Crossword.generate(15, 15, dictionary) + assert crossword is not None + + # 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 diff --git a/tests/test_crossword_generators.py b/tests/test_crossword_generators.py index 7a3e93e..452fd42 100644 --- a/tests/test_crossword_generators.py +++ b/tests/test_crossword_generators.py @@ -1,5 +1,5 @@ import pytest -from multiplayer_crosswords.crossword_generators import extract_slots, slot_pattern, Slot +from multiplayer_crosswords.crossword_algorithm import extract_slots, slot_pattern, Slot from multiplayer_crosswords.crossword import Orientation def test_extract_slots_simple():