From 175371ee00fbe32521b74685981122383c80e639 Mon Sep 17 00:00:00 2001 From: Jonas Weinz Date: Sun, 1 Jun 2025 23:04:30 +0200 Subject: [PATCH] first draft --- multiplayer_crosswords/__init__.py | 0 multiplayer_crosswords/crossword.py | 88 ++++++++++++++ multiplayer_crosswords/dictionary.py | 168 +++++++++++++++++++++++++++ pyproject.toml | 29 +++++ tests/test_crossword.py | 87 ++++++++++++++ tests/test_dictionary.py | 105 +++++++++++++++++ 6 files changed, 477 insertions(+) create mode 100644 multiplayer_crosswords/__init__.py create mode 100644 multiplayer_crosswords/crossword.py create mode 100644 multiplayer_crosswords/dictionary.py create mode 100644 pyproject.toml create mode 100644 tests/test_crossword.py create mode 100644 tests/test_dictionary.py diff --git a/multiplayer_crosswords/__init__.py b/multiplayer_crosswords/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multiplayer_crosswords/crossword.py b/multiplayer_crosswords/crossword.py new file mode 100644 index 0000000..72ad2d1 --- /dev/null +++ b/multiplayer_crosswords/crossword.py @@ -0,0 +1,88 @@ +from enum import Enum +from typing import List, Tuple, Optional +from .dictionary import Dictionary, Word + +class Orientation(Enum): + HORIZONTAL = 1 + VERTICAL = 2 + +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 + +class 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 + + 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) + + # 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 + + # 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 + + 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 add_word(self, word: Word, row: int, col: int, orientation: Orientation) -> bool: + if not self.can_place_word(word, row, col, orientation): + return False + + length = len(word.word) + dr, dc = (0, 1) if orientation == Orientation.HORIZONTAL else (1, 0) + + # 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] = '#' + + # 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 __str__(self): + return '\n'.join( + ''.join(cell if cell else '.' for cell in row) + for row in self.grid + ) \ No newline at end of file diff --git a/multiplayer_crosswords/dictionary.py b/multiplayer_crosswords/dictionary.py new file mode 100644 index 0000000..bc9f838 --- /dev/null +++ b/multiplayer_crosswords/dictionary.py @@ -0,0 +1,168 @@ +import numpy as np +import json +import re +from typing import List, Dict, Any, Optional +from numba import njit +import time +import numpy as np +import cProfile +import pstats + + +class Word: + + def __init__(self, word: str, hints: list[str], difficulty: float): + self.word = word + self.hints = hints + self.difficulty = difficulty + + def to_dict(self) -> dict[str, any]: + return { + "word": self.word, + "hints": self.hints, + "difficulty": self.difficulty + } + + @staticmethod + def from_dict(data: dict[str, any]) -> "Word": + return Word( + word=data["word"], + hints=data["hints"], + difficulty=data["difficulty"] + ) + +class Dictionary: + def __init__(self): + self.words: List[Word] = [] + self._length_index: Dict[int, List[Word]] = {} + self._max_word_length = 0 + self._pos_index_list = None # Will be built on demand + self._index_built = False + + def add_word(self, word: Word): + self.words.append(word) + l = len(word.word) + if l not in self._length_index: + self._length_index[l] = [] + self._length_index[l].append(word) + if l > self._max_word_length: + self._max_word_length = l + self._index_built = False # Invalidate index + elif self._index_built: + # just add the word to the existing index + if self._pos_index_list is not None: + for i, c in enumerate(word.word): + idx = ord(c) - ord('a') + self._pos_index_list[l][i][idx].add(word) + + def _build_pos_index_list(self): + # pos_index_list[length][pos][char_index] = set(words) + self._pos_index_list = [None] * (self._max_word_length + 1) + for length in range(1, self._max_word_length + 1): + pos_list = [] + for pos in range(length): + char_list = [set() for _ in range(26)] # a-z + pos_list.append(char_list) + self._pos_index_list[length] = pos_list + for word in self.words: + l = len(word.word) + for i, c in enumerate(word.word): + idx = ord(c) - ord('a') + self._pos_index_list[l][i][idx].add(word) + self._index_built = True + + def to_dict(self) -> dict[str, any]: + return { + "words": [w.to_dict() for w in self.words] + } + + @staticmethod + def from_dict(data: dict[str, any]) -> "Dictionary": + d = Dictionary() + for word_data in data.get("words", []): + d.add_word(Word.from_dict(word_data)) + return d + + def to_json(self) -> str: + import json + return json.dumps(self.to_dict(), indent=2) + + @staticmethod + def from_json(json_str: str) -> "Dictionary": + import json + data = json.loads(json_str) + return Dictionary.from_dict(data) + + def find_by_pattern(self, pattern: str) -> List[Word]: + length = len(pattern) + if not self._index_built: + self._build_pos_index_list() + if length > self._max_word_length or self._pos_index_list[length] is None: + return [] + fixed_positions = [(i, c) for i, c in enumerate(pattern) if c != '*'] + if not fixed_positions: + return [w for w in self._length_index.get(length, [])] + sets = [] + for i, c in fixed_positions: + idx = ord(c) - ord('a') + sets.append(self._pos_index_list[length][i][idx]) + if not sets: + return [] + sets.sort(key=len) + candidates = set.intersection(*sets) + return candidates + + + +def main(): + + def generate_dummy_words(n, min_length=4, max_length=8): + # Generates n random lowercase words of random length between min_length and max_length + rng = np.random.default_rng(42) + alphabet = np.array([ord(c) for c in "abcdefghijklmnopqrstuvwxyz"], dtype=np.uint8) + words = [] + lengths = rng.integers(min_length, max_length + 1, size=n) + for l in lengths: + chars = rng.choice(alphabet, size=l) + word = ''.join([chr(c) for c in chars]) + words.append(word) + return words + + N = 500_000 + MIN_WORD_LENGTH = 4 + MAX_WORD_LENGTH = 8 + dummy_words = generate_dummy_words(N, MIN_WORD_LENGTH, MAX_WORD_LENGTH) + + # Fill Dictionary (position-indexed) + dict_obj = Dictionary() + for w in dummy_words: + dict_obj.add_word(Word(w, hints=[], difficulty=1.0)) + + # Prepare 1000 patterns (repeat the same 10 patterns 100 times) + base_patterns = ["**a*e", "b****", "c***d*", "*e***", "a****z", "****", "*****", "******", "*******", "********"] + patterns = base_patterns * 100 # 10 * 100 = 1000 + + dict_obj._build_pos_index_list() # Ensure the index is built before testing + + print(f"Testing {len(patterns)} patterns on {N} words with lengths {MIN_WORD_LENGTH}-{MAX_WORD_LENGTH}.") + + # --- Profiling block --- + profiler = cProfile.Profile() + profiler.enable() + + t0 = time.time() + total_matches_dict = 0 + for pattern in patterns: + result1 = dict_obj.find_by_pattern(pattern) + total_matches_dict += len(result1) + t1 = time.time() + + profiler.disable() + print(f"Dictionary (position-indexed) total matches: {total_matches_dict} in {t1-t0:.6f} seconds for 1000 queries.") + + # Print top 20 time-consuming functions + stats = pstats.Stats(profiler).sort_stats('cumtime') + stats.print_stats(20) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1b4a3f4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "multiplayer-crosswords" +version = "0.1.0" +description = "" +authors = [ + {name = "Jonas Weinz",email = "jo.we93@gmx.de"} +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "numpy (>=2.2.6,<3.0.0)", + "pandas (>=2.2.3,<3.0.0)", + "numba (>=0.61.2,<1.0.0)", + "bitarray (>=3.4.2,<4.0.0)", +] + + + +[tool.poetry] + +[tool.poetry.dev-dependencies] +pytest = "^7.0" + +[tool.poetry.group.dev.dependencies] +jupyterlab = "^4.4.3" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/test_crossword.py b/tests/test_crossword.py new file mode 100644 index 0000000..ce30c4b --- /dev/null +++ b/tests/test_crossword.py @@ -0,0 +1,87 @@ +import pytest +from multiplayer_crosswords.crossword import Crossword, Orientation, PlacedWord +from unittest.mock import MagicMock + +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 \ No newline at end of file diff --git a/tests/test_dictionary.py b/tests/test_dictionary.py new file mode 100644 index 0000000..4e6badf --- /dev/null +++ b/tests/test_dictionary.py @@ -0,0 +1,105 @@ +import pytest +from multiplayer_crosswords.dictionary import Word, Dictionary + +def test_add_word_and_to_dict(): + d = Dictionary() + w = Word("apple", ["a red or green fruit"], 0.2) + d.add_word(w) + result = d.to_dict() + assert "words" in result + assert len(result["words"]) == 1 + assert result["words"][0]["word"] == "apple" + assert result["words"][0]["hints"] == ["a red or green fruit"] + assert result["words"][0]["difficulty"] == 0.2 + +def test_from_dict_and_to_dict_roundtrip(): + data = { + "words": [ + {"word": "banana", "hints": ["a yellow fruit", "something monkeys like to eat"], "difficulty": 0.1}, + {"word": "car", "hints": ["vehicle", "four wheels"], "difficulty": 0.3} + ] + } + d = Dictionary.from_dict(data) + assert len(d.words) == 2 + assert d.words[0].word == "banana" + assert d.words[1].hints == ["vehicle", "four wheels"] + assert d.words[1].difficulty == 0.3 + assert d.to_dict() == data + +def test_to_json_and_from_json(): + d = Dictionary() + d.add_word(Word("dog", ["animal", "barks"], 0.15)) + json_str = d.to_json() + d2 = Dictionary.from_json(json_str) + assert len(d2.words) == 1 + assert d2.words[0].word == "dog" + assert d2.words[0].hints == ["animal", "barks"] + assert d2.words[0].difficulty == 0.15 + +def test_empty_dictionary_to_dict_and_json(): + d = Dictionary() + assert d.to_dict() == {"words": []} + json_str = d.to_json() + assert json_str.strip().startswith("{") + d2 = Dictionary.from_json(json_str) + assert isinstance(d2, Dictionary) + assert d2.words == [] + +def test_word_to_dict_and_from_dict(): + w = Word("cat", ["animal", "meows"], 0.12) + d = w.to_dict() + assert d == {"word": "cat", "hints": ["animal", "meows"], "difficulty": 0.12} + w2 = Word.from_dict(d) + assert w2.word == "cat" + assert w2.hints == ["animal", "meows"] + assert w2.difficulty == 0.12 + +def test_find_by_pattern_exact_match(): + d = Dictionary() + d.add_word(Word("apple", ["fruit"], 0.2)) + d.add_word(Word("apply", ["verb"], 0.3)) + d.add_word(Word("angle", ["geometry"], 0.4)) + result = d.find_by_pattern("apple") + assert [r.word for r in result] == ["apple"] + +def test_find_by_pattern_with_wildcards(): + d = Dictionary() + d.add_word(Word("apple", ["fruit"], 0.2)) + d.add_word(Word("apply", ["verb"], 0.3)) + d.add_word(Word("angle", ["geometry"], 0.4)) + d.add_word(Word("ample", ["enough"], 0.5)) + # Pattern: a**le should match apple and angle and ample + result = sorted([w.word for w in d.find_by_pattern("a**le")]) + assert set(result) == {"apple", "angle", "ample"} + +def test_find_by_pattern_all_wildcards(): + d = Dictionary() + d.add_word(Word("apple", ["fruit"], 0.2)) + d.add_word(Word("apply", ["verb"], 0.3)) + d.add_word(Word("angle", ["geometry"], 0.4)) + # Pattern: ***** should match all 5-letter words + result = sorted([w.word for w in d.find_by_pattern("*****")]) + assert set(result) == {"apple", "apply", "angle"} + +def test_find_by_pattern_no_match(): + d = Dictionary() + d.add_word(Word("apple", ["fruit"], 0.2)) + d.add_word(Word("apply", ["verb"], 0.3)) + # Pattern: z**** should match nothing + result = d.find_by_pattern("z****") + assert list(result) == [] + +def test_find_by_pattern_duplicate_letters(): + d = Dictionary() + d.add_word(Word("level", ["word"], 0.1)) + d.add_word(Word("lever", ["tool"], 0.2)) + # Pattern: l*vel should match "level" only + result = d.find_by_pattern("l*vel") + assert [r.word for r in result] == ["level"] + +def test_find_by_pattern_length_not_in_index(): + d = Dictionary() + d.add_word(Word("cat", ["animal"], 0.1)) + # Pattern: four letters, but only 3-letter word in dict + result = d.find_by_pattern("****") + assert [r.word for r in result] == []