199 lines
5.7 KiB
Python
199 lines
5.7 KiB
Python
"""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)
|