Initial commit

This commit is contained in:
Ovidiu U
2026-05-04 09:45:17 +01:00
commit 18b7e11657
31 changed files with 2838 additions and 0 deletions

198
app/generator.py Normal file
View File

@@ -0,0 +1,198 @@
"""Grid building and word placement."""
from __future__ import annotations
import random
import string
from dataclasses import dataclass, field
from .normaliser import Normalised, normalise_all
# Direction vectors: (drow, dcol)
RIGHT = (0, 1)
DOWN = (1, 0)
DOWN_RIGHT = (1, 1)
UP_RIGHT = (-1, 1)
LEFT = (0, -1)
UP = (-1, 0)
UP_LEFT = (-1, -1)
DOWN_LEFT = (1, -1)
PLACEMENT_RETRIES = 200
@dataclass
class Placement:
word: Normalised
row: int
col: int
direction: tuple[int, int]
@dataclass
class Puzzle:
grid: list[list[str]]
placements: list[Placement]
skipped: list[Normalised] = field(default_factory=list)
filtered_out: list[Normalised] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
diagonal: bool = False
reversed_: bool = False
allow_overlap: bool = False
@property
def size(self) -> int:
return len(self.grid)
@property
def option_labels(self) -> list[str]:
labels = []
if self.diagonal:
labels.append("Diagonal")
if self.reversed_:
labels.append("Reversed")
if self.allow_overlap:
labels.append("Overlapping")
return labels
def active_directions(diagonal: bool, reversed_: bool) -> list[tuple[int, int]]:
base = [RIGHT, DOWN]
if diagonal:
base += [DOWN_RIGHT, UP_RIGHT]
if reversed_:
base = base + [(-dr, -dc) for (dr, dc) in base]
return base
def _can_place(grid: list[list[str]], word: str, row: int, col: int,
direction: tuple[int, int], allow_overlap: bool) -> bool:
size = len(grid)
dr, dc = direction
for i, ch in enumerate(word):
r, c = row + dr * i, col + dc * i
if not (0 <= r < size and 0 <= c < size):
return False
existing = grid[r][c]
if existing == "":
continue
if allow_overlap and existing == ch:
continue
return False
return True
def _commit(grid: list[list[str]], word: str, row: int, col: int,
direction: tuple[int, int]) -> None:
dr, dc = direction
for i, ch in enumerate(word):
grid[row + dr * i][col + dc * i] = ch
def _start_bounds(size: int, length: int,
direction: tuple[int, int]) -> tuple[range, range]:
"""Valid starting (row, col) ranges so the whole word fits."""
dr, dc = direction
if dr >= 0:
rows = range(0, size - (length - 1) * dr)
else:
rows = range((length - 1) * (-dr), size)
if dc >= 0:
cols = range(0, size - (length - 1) * dc)
else:
cols = range((length - 1) * (-dc), size)
return rows, cols
def generate(
*,
words: list[str],
grid_size: int,
word_count: int,
min_length: int,
max_length: int,
diagonal: bool,
reversed_: bool,
allow_overlap: bool,
rng: random.Random | None = None,
) -> Puzzle:
if rng is None:
rng = random.Random()
if grid_size < 5 or grid_size > 25:
raise ValueError("grid size must be between 5 and 25")
if word_count < 1 or word_count > 30:
raise ValueError("word count must be between 1 and 30")
if min_length < 1:
raise ValueError("min length must be at least 1")
if max_length < min_length:
raise ValueError("max length must be greater than or equal to min length")
effective_max = min(max_length, grid_size)
normalised = normalise_all(words)
eligible: list[Normalised] = []
filtered_out: list[Normalised] = []
skipped: list[Normalised] = []
for n in normalised:
if n.skipped:
skipped.append(n)
continue
length = len(n.grid)
if min_length <= length <= effective_max:
eligible.append(n)
else:
filtered_out.append(n)
rng.shuffle(eligible)
directions = active_directions(diagonal, reversed_)
grid: list[list[str]] = [["" for _ in range(grid_size)]
for _ in range(grid_size)]
placements: list[Placement] = []
warnings: list[str] = []
for word in eligible:
if len(placements) >= word_count:
break
placed = False
for _ in range(PLACEMENT_RETRIES):
direction = rng.choice(directions)
rows, cols = _start_bounds(grid_size, len(word.grid), direction)
if not rows or not cols:
continue
row = rng.choice(rows)
col = rng.choice(cols)
if _can_place(grid, word.grid, row, col, direction, allow_overlap):
_commit(grid, word.grid, row, col, direction)
placements.append(Placement(word=word, row=row, col=col,
direction=direction))
placed = True
break
if not placed:
warnings.append(
f"could not place word {word.grid!r} after "
f"{PLACEMENT_RETRIES} attempts; skipping"
)
if filtered_out:
warnings.append(
f"{len(filtered_out)} word(s) excluded by length filter "
f"(min={min_length}, max={effective_max})"
)
if len(placements) < word_count:
warnings.append(
f"asked for {word_count} word(s); placed {len(placements)}"
)
# Fill remaining empty cells with random uppercase letters.
for r in range(grid_size):
for c in range(grid_size):
if grid[r][c] == "":
grid[r][c] = rng.choice(string.ascii_uppercase)
return Puzzle(grid=grid, placements=placements, skipped=skipped,
filtered_out=filtered_out, warnings=warnings,
diagonal=diagonal, reversed_=reversed_,
allow_overlap=allow_overlap)