Skip to content

Commit

Permalink
create puzzle mask PoC
Browse files Browse the repository at this point in the history
This is just a proof-of-concept, and there remains much TODO work.

The idea behind this is to make the Puzzle type more than a mere
list[list[chr]].  With this, groundwork has been laid for creating
puzzles with shapes other than that of the square.

Current to-do items I can remember before this is ready to merge:

1. Edit all the other files to use this new Puzzle object
2. Add triangle mask (accepts diagonal Direction)
3. Add ellipse mask
4. Make the code readable with comments/docstrings
5. Add more tests
6. If someone really wanted, they could implement a bitmap to mask
   function
7. Add inversion mask

If you run puzzle.py in your terminal, it should show an I-shaped
template for a puzzle.

I expect that masking will NOT be accessible from the CLI.  If you want
to mask, you'll have to roll your own interface.  Perhaps some basic
presets may be implemented.  That's for the future.

Masking idea inspired by the word search blog post over on scipython.com
  • Loading branch information
duck57 committed Oct 8, 2022
1 parent 0eb4e7c commit 18e221f
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 0 deletions.
5 changes: 5 additions & 0 deletions src/word_search_generator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
min_puzzle_words = 1
max_puzzle_words = 30
max_fit_tries = 100
oob_chr = "#"


@unique
Expand Down Expand Up @@ -37,6 +38,10 @@ def r_move(self) -> int:
def c_move(self) -> int:
return self.value[1]

@property
def opposite(self) -> "Direction":
return self.__class__((-self.r_move, -self.c_move))


level_dirs = {
1: {Direction.E, Direction.S},
Expand Down
226 changes: 226 additions & 0 deletions src/word_search_generator/puzzle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#! /usr/bin/env python
from copy import deepcopy
from typing import Callable

from word_search_generator import config
from word_search_generator.config import oob_chr, Direction
from word_search_generator.types import Puzzle as PType, DirectionSet


class OOBError(IndexError):
pass


class BadIndexRange(OOBError):
pass


class BlockedCellError(OOBError):
pass


class PuzzleTooSmallError(Exception):
pass


class Puzzle:
def __init__(
self,
height: int,
width: int = 0,
masks: "list[Callable]" = None,
valid_directions: DirectionSet = config.level_dirs[3],
):
self.valid_directions = valid_directions | {
d.opposite for d in valid_directions
}
if not width:
width = height
if height < 1 or width < 1:
raise ValueError(
"Dimensions must be positive integers." + f"\nh={height}, w={width}"
)
self._list = [[""] * width for _ in range(height)]
if masks:
masks += [remove_stranded(), trim()]
else:
masks = []
self.apply_masks(masks)
if self.height < config.min_puzzle_size or self.width < config.min_puzzle_size:
raise PuzzleTooSmallError(
"Supplied masks chopped off too much puzzle"
+ f"\nResult of {self.height} rows and {self.width if self.height else 0} columns"
+ f" after {len(masks)} filters"
)

@property
def width(self) -> int:
return len(self._list[0])

@property
def height(self) -> int:
return len(self._list)

def apply_masks(self, masks: list[Callable], dev_debug: bool = False) -> None:
for mask in masks:
if dev_debug:
print(f"Applying {mask.__name__}")
self._list = mask(self)
if dev_debug:
print(self)

def __call__(self, *args, **kwargs):
return deepcopy(self._list)

def __getitem__(self, item):
# print(item, type(item))
if isinstance(item, int):
return self._list[item]
if not isinstance(item, tuple):
raise TypeError
row, col = item
if row not in range(self.height) or col not in range(self.width):
return oob_chr
return self._list[row][col]

def __setitem__(self, item: tuple[int, int], val: chr):
row, col = item
if val == oob_chr and (row >= self.width or col >= self.height):
return # setting OOB marker outside valid area is fine
cur = self[row][col]
if val == cur:
return # no change needed
if cur == oob_chr:
raise OOBError
self.hard_set(row, col, val)

def hard_set(self, row: int, col: int, val: chr):
self._list[row][col] = val.strip().upper()

def neighbor_set(self, row: int, col: int) -> set[chr]:
try:
return {self[row + d.r_move, col + d.c_move] for d in self.valid_directions}
except IndexError:
raise IndexError(f"{row} {col}")

def toggle_cell(self, row: int, col: int) -> None:
if self[row, col] == oob_chr:
self._list[row][col] = ""
else:
self[row, col] = oob_chr

def clear_cell(self, row: int, col: int) -> None:
"""Clearing cells outside the range will raise errors"""
self.hard_set(row, col, "")

def remove_cell(self, row: int, col: int) -> None:
"""Silently ignores OOB errors"""
self[row, col] = oob_chr

def __str__(self) -> str:
return "\n".join(
[" ".join([c if c else "." for c in row]) for row in self._list]
)

def chop(
self, *, row_slice: slice = slice(None), col_slice: slice = slice(None)
) -> None:
self._list = [row[col_slice] for row in self._list[row_slice]]

def expand(self, *, rows: int = 0, cols: int = 0, block_new: bool = False) -> None:
ch: chr = oob_chr if block_new else ""
if cols < 0:
self._list = [[ch] * -cols + row for row in self._list]
else:
self._list = [row + [ch] * cols for row in self._list]
if rows > 0:
self._list = self._list + [[ch] * self.width] * rows
else:
self._list = [[ch] * self.width] * -rows + self._list


def chop(r, s) -> Callable:
def apply_chop(p: Puzzle):
p.chop(row_slice=r, col_slice=s)
return p()

return apply_chop


def expand(r, s, b) -> Callable:
def apply_expand(p: Puzzle):
p.expand(rows=r, cols=s, block_new=b)
return p()

return apply_expand


def remove_stranded() -> Callable:
def apply_singleton_remover(p: Puzzle):
for row in range(p.height):
for col in range(p.width):
if p.neighbor_set(row, col) == {oob_chr}:
p[row, col] = oob_chr
return p()

return apply_singleton_remover


def trim() -> Callable:
def apply_trim(p: Puzzle):
# trim empty rows
o = [r for r in p() if not all(c == oob_chr for c in r)]
if not o:
return o
# trim empty columns
empty_cols = []
for i in range(len(o[0])):
if all(r[i] == oob_chr for r in o):
empty_cols.append(i)
for col in reversed(empty_cols):
[r.pop(col) for r in o]
return o

return apply_trim


def blackout(fill: chr = oob_chr) -> Callable:
def apply_blackout(p: Puzzle):
return [[fill] * p.width for _ in range(p.height)]

return apply_blackout


def include_cell(row: int, column: int, include: bool) -> Callable:
def apply_include(p: Puzzle):
if include:
p.clear_cell(row, column)
else:
p.remove_cell(row, column)
return p()

return apply_include


def set_cell(row: int, col: int, val: chr) -> Callable:
def apply_set(p: Puzzle):
p.hard_set(row, col, val)
return p()

return apply_set


# print(Puzzle(12)[14, 11])
# print(Puzzle(12)[6, 6])

print(
Puzzle(
6,
masks=[
expand(0, 4, True),
expand(0, -4, True),
expand(-3, 0, False),
expand(3, 0, False),
],
)
)
17 changes: 17 additions & 0 deletions tests/test_mask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import pytest

from word_search_generator.puzzle import *


def test_puzzle_too_small_after_blackout():
with pytest.raises(PuzzleTooSmallError):
Puzzle(
15,
16,
[
blackout(),
include_cell(0, 0, True),
set_cell(13, 13, "d"),
include_cell(14, 13, True),
],
)

1 comment on commit 18e221f

@duck57
Copy link
Owner Author

@duck57 duck57 commented on 18e221f Oct 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some masks, such as a diamond/rotated square could be implemented as combinations of the to-do masks. The rotated square, for example, would be something like (pardon the Elixir-like pseudocode)

def diamond_mask(p: Puzzle):
    n = min(p.width, p.height) // 2
    p
    |> triangle_mask(Direction.NW, n)
    |> triangle_mask(Direction.SW, n)
    |> triangle_mask(Direction.SE, n)
    |> triangle_mask(Direction.NE, n)

Please sign in to comment.