Initial commit
This commit is contained in:
198
app/generator.py
Normal file
198
app/generator.py
Normal 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)
|
||||
Reference in New Issue
Block a user