forked from joshbduncan/word-search-generator
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
3 changed files
with
248 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
], | ||
) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
], | ||
) |
18e221f
There was a problem hiding this comment.
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)