first working algorithm sample

This commit is contained in:
2025-10-05 19:24:42 +02:00
parent 928cb350db
commit a4970d6980
4 changed files with 1180454 additions and 1 deletions

857341
data/de.json Normal file

File diff suppressed because it is too large Load Diff

322653
data/en.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,10 @@
from typing import List, Tuple, Dict, Set, Optional from typing import List, Tuple, Dict, Set, Optional
from multiplayer_crosswords.crossword import Crossword, Orientation from multiplayer_crosswords.crossword import Crossword, Orientation
from multiplayer_crosswords.dictionary import Dictionary, Word 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
class Slot: class Slot:
def __init__(self, row: int, col: int, orientation: Orientation, length: int): def __init__(self, row: int, col: int, orientation: Orientation, length: int):
@ -16,6 +20,17 @@ class Slot:
return f"Slot(row={self.row}, col={self.col}, orientation={self.orientation}, length={self.length})" return f"Slot(row={self.row}, col={self.col}, orientation={self.orientation}, length={self.length})"
def extract_slots(grid: List[List[str]], min_length: int = 2) -> List[Slot]: def extract_slots(grid: List[List[str]], min_length: int = 2) -> List[Slot]:
"""Extracts slots from the grid where words can be placed.
A slot is a sequence of empty cells (not '#') either horizontally or vertically.
Args:
grid (List[List[str]]): 2D grid representing the crossword layout.
min_length (int): Minimum length of a slot to be considered valid.
Returns:
List[Slot]: List of extracted slots.
"""
rows, cols = len(grid), len(grid[0]) rows, cols = len(grid), len(grid[0])
slots = [] slots = []
@ -46,10 +61,428 @@ def extract_slots(grid: List[List[str]], min_length: int = 2) -> List[Slot]:
return slots return slots
def slot_pattern(grid: List[List[str]], slot: Slot) -> str: def slot_pattern(grid: List[List[str]], slot: Slot) -> str:
"""Generates a pattern string for a given slot in the grid.
Args:
grid (List[List[str]]): 2D grid representing the crossword layout.
slot (Slot): The slot for which to generate the pattern.
Returns:
str: Pattern string with known letters and '*' for unknowns.
"""
dr, dc = (0, 1) if slot.orientation == Orientation.HORIZONTAL else (1, 0) dr, dc = (0, 1) if slot.orientation == Orientation.HORIZONTAL else (1, 0)
pattern = [] pattern = []
for i in range(slot.length): for i in range(slot.length):
r, c = slot.row + dr * i, slot.col + dc * i r, c = slot.row + dr * i, slot.col + dc * i
cell = grid[r][c] cell = grid[r][c]
pattern.append(cell if cell and cell != '#' else '*') pattern.append(cell if cell and cell != '#' else '*')
return ''.join(pattern) return ''.join(pattern)
def generate_grid_template(width: int, height: int, block_ratio: float = 0.25, seed: Optional[int] = None, max_slot_length: int = 15) -> List[List[str]]:
"""Generates a grid template with blocks ('#') and empty cells (''). It will be rotationally symmetric.
Args:
width (int): Width of the grid.
height (int): Height of the grid.
block_ratio (float): Approximate ratio of blocks in the grid.
seed (Optional[int]): Random seed for reproducibility.
max_slot_length (int): Maximum length of any slot to avoid overly long slots.
Returns:
List[List[str]]: Generated grid template.
"""
rnd = random.Random()
if seed is not None:
rnd.seed(seed)
# Initialize empty grid
grid = [['' for _ in range(width)] for _ in range(height)]
def is_connected(test_grid: List[List[str]]) -> bool:
"""Check if all empty cells are connected using flood fill"""
rows, cols = len(test_grid), len(test_grid[0])
visited = set()
# Find first empty cell
start = None
for r in range(rows):
for c in range(cols):
if test_grid[r][c] == '':
start = (r, c)
break
if start:
break
if not start:
return True # No empty cells
# Flood fill from start position
stack = [start]
while stack:
r, c = stack.pop()
if (r, c) in visited or r < 0 or r >= rows or c < 0 or c >= cols or test_grid[r][c] == '#':
continue
visited.add((r, c))
stack.extend([(r+1, c), (r-1, c), (r, c+1), (r, c-1)])
# Count total empty cells
empty_count = sum(1 for r in range(rows) for c in range(cols) if test_grid[r][c] == '')
return len(visited) == empty_count
def can_place_block(r: int, c: int) -> bool:
"""Check if we can place a block at (r,c) while maintaining symmetry and connectivity"""
sym_r, sym_c = height - 1 - r, width - 1 - c
# Can't place if positions are already blocked
if grid[r][c] == '#' or grid[sym_r][sym_c] == '#':
return False
# Temporarily place blocks
grid[r][c] = '#'
grid[sym_r][sym_c] = '#'
# Check connectivity
connected = is_connected(grid)
# Restore original state
grid[r][c] = ''
grid[sym_r][sym_c] = ''
return connected
def place_block_permanently(r: int, c: int):
"""Place block at (r,c) and its symmetric position"""
grid[r][c] = '#'
grid[height - 1 - r][width - 1 - c] = '#'
# Calculate target number of blocks
total_cells = width * height
target_blocks = int(total_cells * block_ratio)
# Create candidate positions for the upper half of the grid
# We only work with the upper half due to rotational symmetry
candidates = []
for r in range((height + 1) // 2):
for c in range(width):
# For center row (if height is odd), only consider left half + center
if r == height // 2 and height % 2 == 1:
if c <= width // 2:
candidates.append((r, c))
else:
candidates.append((r, c))
rnd.shuffle(candidates)
# Place blocks
blocks_placed = 0
for r, c in candidates:
if blocks_placed >= target_blocks:
break
if can_place_block(r, c):
place_block_permanently(r, c)
# Count blocks added (1 if center position, 2 otherwise)
sym_r, sym_c = height - 1 - r, width - 1 - c
blocks_added = 1 if (r == sym_r and c == sym_c) else 2
blocks_placed += blocks_added
# Break long slots by strategically placing additional blocks
for _ in range(3): # Limited iterations to prevent infinite loops
slots = extract_slots(grid)
long_slots = [s for s in slots if s.length > max_slot_length]
if not long_slots:
break
# Sort by length (longest first) to prioritize breaking worst offenders
long_slots.sort(key=lambda x: x.length, reverse=True)
for slot in long_slots:
# Try to break the slot in the middle
break_pos = slot.length // 2
if slot.orientation == Orientation.HORIZONTAL:
r, c = slot.row, slot.col + break_pos
else: # VERTICAL
r, c = slot.row + break_pos, slot.col
# Only break if we can maintain connectivity and symmetry
if can_place_block(r, c):
place_block_permanently(r, c)
break # Break one slot at a time and re-evaluate
return grid
class CrosswordGeneratorStep(object):
def __init__(
self,
dictionary: Dictionary,
grid: Optional[List[List[str]]] = None,
known_slots: Optional[List[Slot]] = None,
field_slotindex_map_hor: Optional[List[List[int]]] = None,
field_slotindex_map_ver: Optional[List[List[int]]] = None,
seed: float | int | None = None,
grid_width: int | None = None,
grid_height: int | None = None,
grid_block_ratio: float = 0.25,
max_slot_length: int = 15,
available_words_for_slotindex: Optional[Dict[int, Set[Word]]] = None,
unfilled_slots: Optional[Set[int]] = None,
rnd=None,
):
self._dictionary = dictionary
self._grid = grid
self._known_slots = known_slots
self._field_slotindex_map_hor = field_slotindex_map_hor
self._field_slotindex_map_ver = field_slotindex_map_ver
self._seed = seed
self._unfilled_slots = unfilled_slots
if self._seed is None:
self._seed = random.randint(0, 2**31 - 1)
if rnd is None:
self._rnd = random.Random()
self._rnd.seed(self._seed)
else:
self._rnd = rnd
self._grid_width = grid_width
self._grid_height = grid_height
self._grid_block_ratio = grid_block_ratio
self._max_slot_length = max_slot_length
self._available_words_for_slotindex = available_words_for_slotindex
if self._grid is None:
if grid_width is None or grid_height is None:
raise ValueError("If no grid is provided, grid_width and grid_height must be specified.")
# create a default grid if none is provided
self._grid = generate_grid_template(width=grid_width,
height=grid_height,
block_ratio=grid_block_ratio,
seed=self._seed,
max_slot_length=self._max_slot_length)
if self._known_slots is None:
self._known_slots = extract_slots(self._grid)
if self._field_slotindex_map_hor is None:
self._field_slotindex_map_hor = [[-1 for _ in range(len(self._grid[0]))] for _ in range(len(self._grid))]
for idx, slot in enumerate(self._known_slots):
if slot.orientation != Orientation.HORIZONTAL:
continue
dr, dc = (0, 1)
for i in range(slot.length):
r, c = slot.row + dr * i, slot.col + dc * i
self._field_slotindex_map_hor[r][c] = idx
if self._field_slotindex_map_ver is None:
self._field_slotindex_map_ver = [[-1 for _ in range(len(self._grid[0]))] for _ in range(len(self._grid))]
for idx, slot in enumerate(self._known_slots):
if slot.orientation != Orientation.VERTICAL:
continue
dr, dc = (1, 0)
for i in range(slot.length):
r, c = slot.row + dr * i, slot.col + dc * i
self._field_slotindex_map_ver[r][c] = idx
if self._available_words_for_slotindex is None:
self._available_words_for_slotindex = {}
for idx, slot in enumerate(self._known_slots):
pattern = slot_pattern(self._grid, slot)
matching_words = self._dictionary.find_by_pattern(pattern)
self._available_words_for_slotindex[idx] = matching_words
if self._unfilled_slots is None:
# initialize with all slots unfilled
self._unfilled_slots = set(range(len(self._known_slots)))
def copy(self) -> "CrosswordGeneratorStep":
return CrosswordGeneratorStep(
dictionary=self._dictionary,
grid=[row.copy() for row in self._grid],
known_slots=self._known_slots.copy(),
field_slotindex_map_hor=[row.copy() for row in self._field_slotindex_map_hor] if self._field_slotindex_map_hor else None,
field_slotindex_map_ver=[row.copy() for row in self._field_slotindex_map_ver] if self._field_slotindex_map_ver else None,
seed=self._seed,
grid_width=self._grid_width,
grid_height=self._grid_height,
grid_block_ratio=self._grid_block_ratio,
max_slot_length=self._max_slot_length,
available_words_for_slotindex={k: v.copy() for k, v in self._available_words_for_slotindex.items()} if self._available_words_for_slotindex else None,
unfilled_slots=self._unfilled_slots.copy(),
rnd=self._rnd,
)
def fill_next_slot(self) -> bool:
if len(self._unfilled_slots) == 0:
return True # All slots filled, everything done
# choose slot randomly from all slots with minimal available words
min_num_words = min(len(self._available_words_for_slotindex.get(idx, [])) for idx in self._unfilled_slots)
candidates = [idx for idx in self._unfilled_slots if len(self._available_words_for_slotindex.get(idx, [])) == min_num_words ]
slot_to_fill = self._rnd.choice(candidates)
available_words = list(self._available_words_for_slotindex.get(slot_to_fill, []))
if len(available_words) == 0:
return False # No available words for this slot, dead end
# choose a random word from available words
chosen_word = self._rnd.choice(available_words)
slot = self._known_slots[slot_to_fill]
# update own slot
self._available_words_for_slotindex[slot_to_fill] = {chosen_word}
# Place the word in the grid
dr, dc = (0, 1) if slot.orientation == Orientation.HORIZONTAL else (1, 0)
for i in range(slot.length):
r, c = slot.row + dr * i, slot.col + dc * i
self._grid[r][c] = chosen_word.word[i]
self._unfilled_slots.remove(slot_to_fill)
# Update available words for intersecting slots
for i in range(slot.length):
r, c = slot.row + dr * i, slot.col + dc * i
# Check horizontal slot
hor_slot_idx = self._field_slotindex_map_hor[r][c]
if hor_slot_idx != -1 and hor_slot_idx in self._unfilled_slots:
pattern = slot_pattern(self._grid, self._known_slots[hor_slot_idx])
matching_words = self._dictionary.find_by_pattern(pattern)
# Dead end, no words fit anymore
self._available_words_for_slotindex[hor_slot_idx] = set(matching_words)
if len(matching_words) == 0:
return False
# Check vertical slot
ver_slot_idx = self._field_slotindex_map_ver[r][c]
if ver_slot_idx != -1 and ver_slot_idx in self._unfilled_slots:
pattern = slot_pattern(self._grid, self._known_slots[ver_slot_idx])
matching_words = self._dictionary.find_by_pattern(pattern)
self._available_words_for_slotindex[ver_slot_idx] = set(matching_words)
if len(matching_words) == 0:
return False # Dead end, no words fit anymore
return True
def generate(self,
max_tries_per_step: int = 10,
max_allowed_single_choice: int = 1,
max_allowed_threshold_choice: int = 3,
max_allowed_threshold: int = 3,
show_progress: bool = False
) -> Optional["CrosswordGeneratorStep"]:
# count how many slots have only one available word
single_choice_slots = sum(1 for idx in self._unfilled_slots if len(self._available_words_for_slotindex.get(idx, [])) == 1)
if single_choice_slots > max_allowed_single_choice:
return None # Too many single-choice slots, backtrack
# count how many slots have only less than threshold available words
threshold_choice_slots = sum(1 for idx in self._unfilled_slots if len(self._available_words_for_slotindex.get(idx, [])) <= max_allowed_threshold)
if threshold_choice_slots > max_allowed_threshold_choice:
return None # Too many threshold-choice slots, backtrack
for i in range(max_tries_per_step if single_choice_slots == 0 else 1):
step_copy = self.copy()
if step_copy.fill_next_slot():
if show_progress:
self.print_grid()
if len(step_copy._unfilled_slots) == 0:
return step_copy # Successfully filled all slots
else:
next_step = step_copy.generate(
max_tries_per_step,
max_allowed_single_choice=max_allowed_single_choice,
max_allowed_threshold_choice=max_allowed_threshold_choice,
max_allowed_threshold=max_allowed_threshold,
show_progress=show_progress
)
if next_step is not None:
return next_step
else:
if show_progress:
self.print_grid()
#pass
else:
#self.print_grid()
pass
return None # Failed to fill a slot after max tries
def print_grid(self):
# clear as many rows in terminal as we will print
os.system('cls' if os.name == 'nt' else 'clear')
result = ""
for row in self._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
print(result, flush=True)
def __str__(self):
# Simple string representation for debugging
result = "Crossword Grid:\n"
for row in self._grid:
result += ' '.join(cell if cell else ' ' for cell in row).replace("#", ".") + "\n"
#return result
result += "\n"
result += f"Horizontal Slot Map:\n"
for r in range(len(self._field_slotindex_map_hor)):
result += ' '.join(f"{idx:2d}" if idx != -1 else " ." for idx in self._field_slotindex_map_hor[r]) + "\n"
result += f"Vertical Slot Map:\n"
for r in range(len(self._field_slotindex_map_ver)):
result += ' '.join(f"{idx:2d}" if idx != -1 else " ." for idx in self._field_slotindex_map_ver[r]) + "\n"
result += "Slots:\n"
for i, slot in enumerate(self._known_slots):
result += f"{i}: {slot} Pattern: {slot_pattern(self._grid, slot)} -- "
result += f"Available Words: {len(self._available_words_for_slotindex.get(i, []))}\n"
return result
if __name__ == "__main__":
dummy_words = ["apple", "banana", "grape", "orange", "melon", "kiwi", "peach", "pear", "plum", "mango",
"cherry", "berry", "lemon", "lime", "apricot", "date", "fig", "papaya", "quince", "tangerine"]
dict_obj = load_de_dictionary()
dict_obj._build_pos_index_list()
test_seeds = [42]
for seed in test_seeds:
print(f"Testing with seed {seed}")
generator = CrosswordGeneratorStep(dictionary=dict_obj,
seed=seed,
grid_width=30,
grid_height=30,
grid_block_ratio=0.4,
max_slot_length=15)
final_step = generator.generate(
max_tries_per_step=2,
show_progress=True,
max_allowed_single_choice=1,
max_allowed_threshold_choice=3,
max_allowed_threshold=5
)
if final_step is None:
print("Failed to generate crossword. Last attempt:")
print(generator)
else:
final_step.print_grid()
print("Successfully generated crossword")

View File

@ -0,0 +1,26 @@
from multiplayer_crosswords.dictionary import Dictionary, Word
import json
from pathlib import Path
def load_dictionary(p: str | Path) -> Dictionary:
p = Path(p)
if not p.exists():
raise FileNotFoundError(f"Dictionary file not found: {p}")
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
dict_obj = Dictionary()
for key, obj in data.items():
word = obj.get("word", "").strip()
if not word.isalpha():
continue
word = word.lower()
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
return dict_obj
def load_en_dictionary() -> Dictionary:
return load_dictionary(Path(__file__).parent.parent / "data" / "en.json")
def load_de_dictionary() -> Dictionary:
return load_dictionary(Path(__file__).parent.parent / "data" / "de.json")