465 lines
17 KiB
Python
465 lines
17 KiB
Python
from enum import Enum
|
|
from typing import Dict, List, Tuple, Optional
|
|
from multiplayer_crosswords.dictionary import Dictionary, Word
|
|
from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots
|
|
import random
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from multiplayer_crosswords.utils import load_de_dictionary
|
|
|
|
class CrosswordWord:
|
|
|
|
def __init__(
|
|
self,
|
|
word: str,
|
|
start_x: int,
|
|
start_y: int,
|
|
orientation: Orientation,
|
|
hint: str,
|
|
index: Optional[int],
|
|
solved: bool = False,
|
|
|
|
):
|
|
self.word = word
|
|
self.start_x = start_x
|
|
self.start_y = start_y
|
|
self.orientation = orientation
|
|
self.hint = hint
|
|
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
|
|
|
|
@property
|
|
def current_grid(self) -> List[List[Optional[str]]]:
|
|
return self._current_grid
|
|
|
|
@property
|
|
def solution_word_positions(self) -> Optional[List[Tuple[int, int]]]:
|
|
return self._solution_word_positions
|
|
|
|
|
|
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")],
|
|
hint=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(
|
|
dictionary: Dictionary,
|
|
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,
|
|
grid_block_ratio=grid_block_ratio,
|
|
seed=seed
|
|
)
|
|
final_step = generator.generate(
|
|
max_tries_per_step=2,
|
|
max_allowed_single_choice=1,
|
|
max_allowed_threshold_choice=3,
|
|
max_allowed_threshold=5,
|
|
show_progress=False
|
|
)
|
|
|
|
if final_step is None:
|
|
logger.warning("Crossword generation failed for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
|
return None
|
|
|
|
logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
|
|
|
# Build letter position index for efficient lookup
|
|
letter_to_positions: Dict[str, List[Tuple[int, int]]] = {}
|
|
for r in range(len(final_step.grid)):
|
|
for c in range(len(final_step.grid[0])):
|
|
cell = final_step.grid[r][c]
|
|
if cell and cell != '#':
|
|
if cell not in letter_to_positions:
|
|
letter_to_positions[cell] = []
|
|
letter_to_positions[cell].append((r, c))
|
|
|
|
# Now find a solution word: generate a random word from the dictionary (with 10-20 letters)
|
|
# and try to find the necessary letter positions in the grid. if we fail we repeat with another word (max 10 tries)
|
|
solution_word_positions: Optional[List[Tuple[int, int]]] = None
|
|
max_solution_word_attempts = 20
|
|
for i in range(max_solution_word_attempts):
|
|
random_length = random.randint(10, 27 - (i // 2))
|
|
possible_words = dictionary.find_by_pattern('*' * random_length)
|
|
if not possible_words:
|
|
continue
|
|
|
|
chosen_word = random.choice(possible_words)
|
|
letter_positions = []
|
|
used_positions = set()
|
|
|
|
for letter in chosen_word.word:
|
|
if letter not in letter_to_positions:
|
|
letter_positions = []
|
|
break
|
|
|
|
# Pick random position for this letter that's not already used
|
|
available = [p for p in letter_to_positions[letter] if p not in used_positions]
|
|
if not available:
|
|
letter_positions = []
|
|
break
|
|
|
|
chosen_position = random.choice(available)
|
|
letter_positions.append(chosen_position)
|
|
used_positions.add(chosen_position)
|
|
|
|
if len(letter_positions) == random_length:
|
|
solution_word_positions = letter_positions
|
|
break
|
|
|
|
if solution_word_positions is None:
|
|
logger.warning("Failed to find a solution word for the generated crossword after %d attempts", max_solution_word_attempts)
|
|
return None
|
|
|
|
cw = Crossword(
|
|
dictionary=dictionary,
|
|
grid=final_step.grid,
|
|
solution_word_positions=solution_word_positions
|
|
)
|
|
|
|
logger.debug("Generated Crossword: \n\n%s", cw)
|
|
|
|
|
|
return cw
|
|
|
|
|
|
def __init__(
|
|
self,
|
|
dictionary: Dictionary,
|
|
grid: List[List[Optional[str]]],
|
|
current_grid: Optional[List[List[Optional[str]]]] = None,
|
|
words: Optional[List[CrosswordWord]] = None,
|
|
solution_word_positions: Optional[List[Tuple[int, int]]] = None,
|
|
):
|
|
"""
|
|
Initialize a Crossword object.
|
|
|
|
Args:
|
|
dictionary (Dictionary): The dictionary containing words and hints.
|
|
grid (List[List[Optional[str]]]): The solved crossword grid.
|
|
current_grid (Optional[List[List[Optional[str]]]]): The current state of the crossword grid.
|
|
words (Optional[List[CrosswordWord]]): Pre-extracted list of CrosswordWord objects.
|
|
solution_word_positions (Optional[List[Tuple[int, int]]]): Positions of letters building the solution word.
|
|
"""
|
|
|
|
self._dictionary = dictionary
|
|
self._solved_grid = grid
|
|
self._words: List[CrosswordWord] = []
|
|
|
|
self._horizontal_words_by_y_x_position = {}
|
|
self._vertical_words_by_y_x_position = {}
|
|
|
|
self._solution_word_positions = solution_word_positions
|
|
|
|
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))]
|
|
|
|
# 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()
|
|
|
|
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
|
|
|
|
def get_clues(self) -> Tuple[Dict[int, str], Dict[int, str]]:
|
|
"""Get the clues for horizontal and vertical words.
|
|
|
|
Returns:
|
|
Tuple[Dict[int, str], Dict[int, str]]: Two dictionaries,
|
|
each containing (index, hint) for horizontal and vertical words respectively.
|
|
"""
|
|
clues_across = {}
|
|
clues_down = {}
|
|
|
|
for word in self._words:
|
|
if word.orientation == Orientation.HORIZONTAL:
|
|
clues_across[str(word.index)] = word.hint
|
|
else:
|
|
clues_down[str(word.index)] = word.hint
|
|
|
|
return clues_across, clues_down
|
|
|
|
def get_clue_positions(self) -> Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]:
|
|
"""Get the starting positions for horizontal and vertical clues.
|
|
|
|
Returns:
|
|
Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]: Two dictionaries,
|
|
each containing (index, (x, y)) for horizontal and vertical words respectively.
|
|
"""
|
|
positions_across = {}
|
|
positions_down = {}
|
|
|
|
for word in self._words:
|
|
if word.orientation == Orientation.HORIZONTAL:
|
|
positions_across[str(word.index)] = (word.start_x, word.start_y)
|
|
else:
|
|
positions_down[str(word.index)] = (word.start_x, word.start_y)
|
|
|
|
return positions_across, positions_down
|
|
|
|
def extract_words(self):
|
|
"""Extract all fully placed words from the grid and store them in self._words.
|
|
|
|
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.hint 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,
|
|
hint=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.hint,
|
|
"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.
|
|
"""
|
|
letter = letter.lower()
|
|
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
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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].lower() != crossword_word.word[i].lower():
|
|
return False
|
|
return True
|
|
|
|
def get_words_at_position(self, x: int, y: int) -> List[CrosswordWord]:
|
|
"""
|
|
get horizontal and vertical words 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
|
|
|
|
def __str__(self):
|
|
# 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 = load_de_dictionary()
|
|
|
|
dictionary._build_pos_index_list()
|
|
|
|
|
|
crossword = Crossword.generate(
|
|
dictionary=dictionary,
|
|
seed=None,
|
|
grid_width=25,
|
|
grid_height=25,
|
|
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.hint}")
|
|
|
|
|
|
print(crossword) |