Commit 1285303d authored by Skynet0's avatar Skynet0
Browse files

Initial commit

parents
No related merge requests found
Showing with 850 additions and 0 deletions
+850 -0
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Personal unnecessary files
poetry.lock
pyproject.toml
.vscode/
genres/**/*_ref.py
**/hashi/**
**/ringring/**
\ No newline at end of file
from dataclasses import dataclass
from typing import Callable, Generic, List, NamedTuple, Optional, TypeVar
from z3.z3 import ArithRef, Bool, BoolRef, Int, ModelRef, Or, Solver
T = TypeVar("T")
class Vector(NamedTuple):
"""A vector representing an offset in 2D."""
dy: int
dx: int
def negate(self) -> "Vector":
"""Return a `Vector` that is the negation of this one."""
return Vector(-self.dy, -self.dx)
def times(self, a: int) -> "Vector":
"""Return a `Vector` that is scaled by an integer."""
return Vector(self.dy * a, self.dx * a)
class Point(NamedTuple):
"""A point in 2D, usually the center of a grid cell."""
y: int
x: int
def translate(self, d: Vector) -> "Point":
"""Translate this point by the given `Vector`."""
return Point(self.y + d.dy, self.x + d.dx)
N = Vector(-1, 0)
S = Vector(1, 0)
W = Vector(0, -1)
E = Vector(0, 1)
NE = Vector(-1, 1)
NW = Vector(-1, -1)
SE = Vector(1, 1)
SW = Vector(1, -1)
ORTHO_DIRS = [N, S, W, E]
DIAG_DIRS = [NE, NW, SE, SW]
ALL_DIRS = ORTHO_DIRS + DIAG_DIRS
VEC_TO_DIR_NAME = {
N: "N",
S: "S",
W: "W",
E: "E",
NE: "NE",
NW: "NW",
SE: "SE",
SW: "SW",
}
# NamedTuples can't be generic, so use a slotted dataclass instead
@dataclass(frozen=True)
class Neighbor(Generic[T]):
"""Container for properties of a cell that is a neighbor of another."""
__slots__ = ("location", "direction", "value")
location: Point
direction: Vector
value: T
def point_to_int_var(p: Point, uniqueifier: int) -> ArithRef:
return Int(f"int-u({uniqueifier})-r{p.y}-c{p.x}")
def constrain_int_different(val: ArithRef, model: ModelRef) -> BoolRef:
return val != model.eval(val).as_long()
def point_to_bool_var(p: Point, uniqueifier: int) -> BoolRef:
return Bool(f"bool-u({uniqueifier})-r{p.y}-c{p.x}")
def constrain_bool_different(val: BoolRef, model: ModelRef) -> BoolRef:
return val != bool(model.eval(val))
class Grid(Generic[T]):
"""Rectangular grid class that can contain arbitrary values.
Args:
height (int): The height of the grid.
width (int): The width of the grid.
default_val (Callable[[Point, int], T], optional): Function called to create
the value for each Point in the grid. Possible to do extra processing with a
closure, but usually just for initializing variables. If you use
multiple grids ensure that the variables do not collide via the `int`
argument. Defaults to point_to_int_var.
solver (Optional[Solver]): A `Solver` object. If `None`, a default `Solver`
will be constructed.
"""
# Uniqueifier - if I want to use multiple grids in one solver for some
# reason, this keeps the variables from colliding.
_instance = 0
def __init__(
self,
height: int,
width: int,
default_val: Callable[[Point, int], T] = point_to_int_var,
solver: Optional[Solver] = None,
):
self.height = height
self.width = width
self.points = {
Point(y, x): default_val(Point(y, x), Grid._instance)
for y in range(height)
for x in range(width)
}
Grid._instance += 1
self.solver = solver if solver else Solver()
def edge_adjacent(self, p: Point) -> List[Neighbor[T]]:
"""Return all cells that share an edge with the given cell."""
return [
Neighbor(p.translate(d), d, self.points[p.translate(d)])
for d in ORTHO_DIRS
if p.translate(d) in self.points
]
def vertex_adjacent(self, p: Point) -> List[Neighbor[T]]:
"""Return all cells that share a vertex with the given cell."""
return [
Neighbor(p.translate(d), d, self.points[p.translate(d)])
for d in ALL_DIRS
if p.translate(d) in self.points
]
def contains_point(self, p: Point) -> bool:
"""Return whether the given cell is in the grid."""
return p in self.points
def constrain_new_solution(
self, constrain_point_to_diff_value: Callable[[T, ModelRef], BoolRef]
):
"""Add constraints to make the solver's current solution impossible.
Args:
constrain_point_to_diff_value (Callable[[T, ModelRef], BoolRef]):
Function to call on each value stored in the grid to return a `BoolRef`
that is satisfied if the cell is "different".
"""
# Resolver is because T is not necessarily a Ref. We have to use
# T with the ModelRef to constrain the things we care about correctly.
self.solver.add(
Or(
[
constrain_point_to_diff_value(val, self.solver.model())
for val in self.points.values()
]
)
)
def to_str_matrix(self, label_fn: Callable[[T, ModelRef], str]) -> List[List[str]]:
"""Convert the grid's solution to a string matrix.
Args:
label_fn (Callable[[T, ModelRef], str]): Function to call on each value
stored in the grid and convert it to a string.
Returns:
List[List[str]]: String matrix representation of the grid.
"""
model = self.solver.model()
output = [[" " for _ in range(self.width)] for _ in range(self.height)]
for p, v in self.points.items():
output[p.y][p.x] = label_fn(v, model)
return output
def get_solve_time(self) -> int:
"""Get the solve time."""
return self.solver.statistics().get_key_value("time")
def row_values(self, row: int) -> List[T]:
return [self.points[(row, x)] for x in range(self.height)]
def col_values(self, col: int) -> List[T]:
return [self.points[(y, col)] for y in range(self.width)]
from typing import List, Dict
from cs11puzzles.grid import ORTHO_DIRS, VEC_TO_DIR_NAME, E, N, Point, S, W
from cs11puzzles.str_helpers import ortho_dirs_sym
def borders_to_areas(
wall_right: List[List[bool]], wall_down: List[List[bool]]
) -> List[List[int]]:
"""Construct areas from walls."""
assert len(wall_right[0]) + 1 == len(wall_down[0])
assert len(wall_right) == len(wall_down) + 1
width = len(wall_down[0])
height = len(wall_right)
areas = [[-1 for _ in range(width)] for _ in range(height)]
to_visit = set([Point(y, x) for y in range(height) for x in range(width)])
area = 0
while to_visit:
curr_area_root = to_visit.pop()
to_flood = set([curr_area_root])
while to_flood:
curr_pt = to_flood.pop()
to_visit.discard(curr_pt)
areas[curr_pt.y][curr_pt.x] = area
for d in ORTHO_DIRS:
n = curr_pt.translate(d)
if n in to_visit and n not in to_flood:
if (
d == N
and not wall_down[n.y][n.x]
or d == S
and not wall_down[curr_pt.y][curr_pt.x]
or d == W
and not wall_right[n.y][n.x]
or d == E
and not wall_right[curr_pt.y][curr_pt.x]
):
to_flood.add(n)
area += 1
return areas
def conns_to_str_grid(conn_right: List[List[bool]],
conn_down: List[List[bool]]) -> List[List[str]]:
assert len(conn_right[0]) + 1 == len(conn_down[0])
assert len(conn_right) == len(conn_down) + 1
width = len(conn_down[0])
height = len(conn_right)
str_grid = [[' ' for _ in range(width)] for _ in range(height)]
points = set([Point(y, x) for y in range(height) for x in range(width)])
for p in points:
dirs: Dict[str, bool] = {}
for d in ORTHO_DIRS:
n = p.translate(d)
dirs[VEC_TO_DIR_NAME[d]] = (n in points) and (
d == N and conn_down[n.y][n.x] or d == S and conn_down[p.y][p.x]
or d == W and conn_right[n.y][n.x] or
d == E and conn_right[p.y][p.x])
str_grid[p.y][p.x] = ortho_dirs_sym(**dirs)
return str_grid
\ No newline at end of file
from typing import Callable, Dict, List, Literal, Text
def str_matrix_to_text(
matrix: List[List[str]],
padding: Dict[str, str] = None,
align_type: Literal["left", "center", "right"] = "center",
) -> Text:
"""Convert a matrix of strings to an aligned multiline string.
Args:
str_grid (List[List[str]]): 2D string matrix
padding (Dict[str, str], optional): Dictionary containing a mapping from
cell contents to padding strs.
align_type (Literal["left", "center", "right"], optional): Type of alignment.
Defaults to "center".
Returns:
Text: Printable multiline string
"""
align_fn: Callable[[str, int, str], str] = {
"left": str.ljust,
"center": str.center,
"right": str.rjust,
}[align_type]
if padding is None:
padding = {}
max_sym_len = max([max([len(s) for s in r]) for r in matrix])
return "\n".join(
[
"".join([align_fn(s, max_sym_len, padding.get(s, " ")) for s in r])
for r in matrix
]
)
def ortho_dirs_sym(
N: bool = False, S: bool = False, W: bool = False, E: bool = False
) -> str:
"""Converts boolean edges that connect cell centers to a Unicode symbol.
Args:
N (bool, optional): Presence of edge exiting to the north (up)
S (bool, optional): Presence of edge exiting to the south (down)
W (bool, optional): Presence of edge exiting to the west (left)
E (bool, optional): Presence of edge exiting to the east (right)
Returns:
str: Unicode symbol for given directions
"""
idx = sum([v * 2 ** i for i, v in enumerate([N, S, W, E])])
# There have to be SO MANY BETTER WAYS TO DO THIS
symbols = [
" ", # ----
chr(0x2575), # ---U
chr(0x2577), # --D-
chr(0x2502), # --DU
chr(0x2574), # -L--
chr(0x2518), # -L-U
chr(0x2510), # -LD-
chr(0x2524), # -LDU
chr(0x2576), # R---
chr(0x2514), # R--U
chr(0x250C), # R-D-
chr(0x251C), # R-DU
chr(0x2500), # RL--
chr(0x2534), # RL-U
chr(0x252C), # RLD-
chr(0x253C), # RLDU
]
return symbols[idx]
from typing import List, Optional
from cs11puzzles.grid import Grid, point_to_bool_var
from cs11puzzles.str_helpers import str_matrix_to_text
from genres.nonogram.nonogram_util import SHADED, NonogramInstance, load_puzzle
from z3.z3 import BoolRef, ModelRef, sat
def solve_nonogram(puzzle: NonogramInstance) -> Optional[List[List[str]]]:
rows = puzzle.rows
cols = puzzle.cols
height = len(rows)
width = len(cols)
# Note: this could be extended to colored nonograms if you use an
# ArithRef instead, and change the block logic slightly!
g = Grid[BoolRef](height, width, point_to_bool_var)
# TODO: Set up constraints!
if g.solver.check() == sat:
def label_fn(cell: BoolRef, m: ModelRef):
return SHADED if bool(m[cell]) else "."
return g.to_str_matrix(label_fn)
return None
if __name__ == "__main__":
filename = "tests/nonogram/data/nonogram_0.txt"
puzzle = load_puzzle(filename)
sol = solve_nonogram(puzzle)
if sol:
print(str_matrix_to_text(sol))
else:
print("No solution.")
from typing import List, NamedTuple, Tuple
SHADED = chr(0x2589)
class NonogramInstance(NamedTuple):
cols: List[List[int]]
rows: List[List[int]]
# Solution is presented inline with the puzzle :(
def load_puzzle_and_solution(fname: str) -> Tuple[NonogramInstance, List[List[str]]]:
with open(fname) as f:
assert f.readline().strip() == "pzprv3"
assert f.readline().strip() == "nonogram"
height = int(f.readline())
width = int(f.readline())
lines: List[List[str]] = []
for line in f:
if not line.strip():
break
lines.append(line.strip().split(" "))
col_clues: List[List[int]] = [[] for _ in range(width)]
for r in lines[:-height]:
for i, clue in enumerate(r[-width:]):
if clue != ".":
col_clues[i].append(int(clue))
row_clues: List[List[int]] = [
[int(clue) for clue in r[:-width] if clue != "."] for r in lines[-height:]
]
shaded = [
[SHADED if s == "#" else "." for s in row[-width:]]
for row in lines[-height:]
]
return NonogramInstance(col_clues, row_clues), shaded
def load_puzzle(filename: str):
puzzle, _ = load_puzzle_and_solution(filename)
return puzzle
from typing import List, Optional
from cs11puzzles.grid import Grid, point_to_int_var
from cs11puzzles.str_helpers import str_matrix_to_text
from genres.skyscrapers.skyscrapers_util import (SkyscrapersInstance,
load_puzzle)
from z3.z3 import ArithRef, ModelRef, sat
def solve_skyscrapers(puzzle: SkyscrapersInstance) -> Optional[List[List[str]]]:
top = puzzle.top
bottom = puzzle.bottom
left = puzzle.left
right = puzzle.right
height = len(left)
width = len(top)
g = Grid[ArithRef](height, width, point_to_int_var)
# TODO: Set up constraints!
if g.solver.check() == sat:
def label_fn(cell: ArithRef, m: ModelRef):
return str(m[cell])
return g.to_str_matrix(label_fn)
return None
if __name__ == "__main__":
filename = "tests/skyscrapers/data/skyscrapers_0.txt"
puzzle = load_puzzle(filename)
sol = solve_skyscrapers(puzzle)
if sol:
print(str_matrix_to_text(sol))
else:
print("No solution.")
from typing import List, NamedTuple, Tuple
class SkyscrapersInstance(NamedTuple):
# Some skyscraper puzzles give numbers in the grid. pzprv3 doesn't support that,
# so I won't account for it here (though it's trivial to add and constrain).
top: List[int]
left: List[int]
right: List[int]
bottom: List[int]
# Solution is presented inline with the puzzle :(
def load_puzzle_and_solution(fname: str) -> Tuple[SkyscrapersInstance, List[List[str]]]:
with open(fname) as f:
assert f.readline().strip() == "pzprv3"
assert f.readline().strip() == "skyscrapers"
height = int(f.readline())
_ = int(f.readline()) # width
lines: List[List[str]] = []
for line in f:
if not line.strip():
break
lines.append(line.strip().split(" "))
top = [int(c) if c != "." else 0 for c in lines[0][1:-1]]
bottom = [int(c) if c != "." else 0 for c in lines[height + 1][1:-1]]
left = [int(r[0]) if r[0] != "." else 0 for r in lines[1 : height + 1]]
right = [int(r[-1]) if r[-1] != "." else 0 for r in lines[1 : height + 1]]
soln = [[c for c in r[1:-1]] for r in lines[1 : height + 1]]
return SkyscrapersInstance(top, left, right, bottom), soln
def load_puzzle(filename: str):
puzzle, _ = load_puzzle_and_solution(filename)
return puzzle
if __name__ == "__main__":
load_puzzle("tests/skyscrapers/data/skyscrapers_0.txt")
pzprv3
nonogram
5
5
. . . . . . . .
. . . 2 . . 2 1
. . . 1 2 3 1 1
. 1 1 # . . # .
. 1 1 # . . # .
. 2 1 . # # . #
. . 2 . # # . .
. 1 3 # . # # #
pzprv3
nonogram
10
10
. . . . . . . . . . . . . . .
. . . . . . . . . . . . . . .
. . . . . . 2 1 . . . 2 . . .
. . . . . . 2 1 . 4 3 1 . 2 .
. . . . . 5 2 4 3 1 2 3 2 4 5
. . . 1 2 . # . . . # # . . .
. . 2 3 1 . # # . # # # . # .
. . . 2 1 . . . . # # . . # .
. . 1 1 1 . . # . # . # . . .
. . 2 1 1 # # . . # . . . # .
. . 3 1 2 # # # . . . # . # #
. . 1 1 4 # . # . . . # # # #
. . 1 2 4 # . # # . . # # # #
. . 4 1 1 # # # # . # . . . #
. . 1 3 1 . # . # # # . . . #
pzprv3
nonogram
10
10
. . . . . . . . . . . . . . .
. . . . . . 1 . . . . . . . .
. . . . . . 1 5 1 1 . 1 3 1 .
. . . . . 2 1 1 1 2 2 2 1 1 5
. . . . . 2 3 1 4 4 3 1 1 3 2
. . . 2 5 . # # . # # # # # .
. 1 1 1 1 . . # . . # . # . #
. . 5 2 1 # # # # # . # # . #
1 1 1 1 1 # . # . # . # . . #
. . . 3 2 . # # # . . . . # #
. . 1 1 1 . . . . . # . # . #
. . 2 4 1 # # . # # # # . # .
. . . 6 2 # # # # # # . . # #
. . 1 2 3 . # . # # . . # # #
. . . . 3 . . # # # . . . . .
pzprv3
nonogram
15
15
. . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . 1 . 1 . . . . 2 . . 1 . 4 . 1
. . . . . . . . 1 . 1 3 4 . 4 1 . 4 1 2 1 2 1
. . . . . . . . 1 2 1 1 1 3 1 2 2 1 1 2 1 1 2
. . . . . . . . 1 3 4 1 3 1 2 1 1 3 1 2 2 2 1
. . . . . . . . 1 4 2 1 2 3 1 1 1 1 1 1 1 2 3
. . . . 1 2 2 1 . # . # # . # # . . . . # . .
. . . . . 7 2 3 . # # # # # # # . # # . # # #
. . . . . 4 1 3 . . . # # # # . . # . # # # .
. . . . 1 4 1 2 . . # . # # # # . # . # # . .
. . . . . 1 2 2 . . . # . . . . # # . . . # #
. . . . 3 3 1 1 # # # . # # # . # . . . # . .
. . . . 1 1 3 1 . # . . . . . # . # # # . . #
. . . 3 1 3 1 2 # # # . # . # # # . . # . # #
. . . . 1 1 1 1 . . # . # . # . . . . . . # .
. . . . 5 1 4 1 . # # # # # . # . # # # # . #
. . . 3 1 2 1 1 # # # . . # . . # # . # . # .
. . . . 1 4 1 2 . # . # # # # . . # . . # # .
. . . 2 1 1 1 1 # # . . # . . . . . # . # . #
. . . . 1 1 1 1 . . # . . . . . . # . # . . #
. . 1 1 1 1 1 1 # . # . . . . # . . # . # . #
pzprv3
nonogram
15
15
. . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . 1 . . . . . . . . . . .
. . . . . . . . . . 1 1 . . 1 . . . 2 . . . .
. . . . . . . . 1 3 1 2 2 3 3 1 . 1 1 1 . . 1
. . . . . . . . 1 4 1 1 1 1 2 1 4 4 2 2 . . 2
. . . . . . . . 3 1 3 1 1 1 1 2 2 2 3 2 1 4 2
. . . . . . . . 2 1 2 2 1 1 1 3 3 1 1 1 8 6 3
. . . . 3 2 2 2 . . # # # . # # . # # . . # #
. . . 1 2 1 1 1 . # . . # # . . # . # . . # .
. . . . 3 1 2 1 # # # . . # . . # # . . . # .
. . . . 1 1 1 8 . # . # . # . # # # # # # # #
. . . . 1 1 2 1 . . # . . . # . # # . . . . #
. . . . 2 5 2 1 # # . # # # # # . # # . # . .
. . . . 3 2 1 3 . # # # . . # # . . # . # # #
. . . . 3 1 1 4 # # # . . # . . . # . # # # #
. . . . . . 4 6 # # # # . . . . # # # # # # .
. . 1 1 1 1 1 3 # . . . # . # . # . # . # # #
. . . 1 1 3 1 3 . # . # . # # # . . # . # # #
. . . . . 1 1 4 # . . . . . . # . . . # # # #
. . . . . 2 4 2 # # . . . . # # # # . # # . .
. . . . . 3 1 1 . . # # # . . . # . # . . . .
. . . . 2 1 1 1 . . # # . . # . # . . # . . .
import os
import pytest
from genres.nonogram.nonogram import solve_nonogram
from genres.nonogram.nonogram_util import load_puzzle_and_solution
DATA_DIR = "tests/nonogram/data"
PUZZLES = [f"nonogram_{i}.txt" for i in range(5)]
@pytest.mark.parametrize("filename", PUZZLES)
@pytest.mark.timeout(10)
def test_nonogram(filename: str):
puzzle, solution = load_puzzle_and_solution(os.path.join(DATA_DIR, filename))
actual_solution = solve_nonogram(puzzle)
assert actual_solution == solution
if __name__ == "__main__":
pytest.main([__file__])
pzprv3
skyscrapers
5
5
. . . 4 2 2 .
. 5 3 2 4 1 .
. 4 2 1 3 5 .
. 1 5 3 2 4 2
3 2 1 4 5 3 .
. 3 4 5 1 2 2
. 3 2 . . . .
KrazyDad - 5x5 Skyscraper Volume 1, Book 100, Skyscraper #12
© 2020 KrazyDad.com
pzprv3
skyscrapers
5
5
. 4 3 . . . .
4 1 2 3 5 4 .
. 3 1 4 2 5 .
. 4 3 5 1 2 2
2 2 5 1 4 3 3
. 5 4 2 3 1 4
. . . 2 3 3 .
KrazyDad - 5x5 Skyscraper, Volume 1, Book 100, Skyscraper #11
© 2020 KrazyDad.com
pzprv3
skyscrapers
6
6
. . 3 3 4 . . .
. 6 3 4 1 5 2 .
. 5 1 2 3 4 6 .
4 1 4 5 2 6 3 2
2 4 6 3 5 2 1 4
2 3 2 6 4 1 5 .
. 2 5 1 6 3 4 2
. 5 . 2 . 2 . .
KrazyDad - 6x6 Skyscraper, Volume 1, Book 100, Skyscraper #11
© 2020 KrazyDad.com
\ No newline at end of file
pzprv3
skyscrapers
6
6
. . 4 3 2 . 4 .
2 5 3 1 4 6 2 .
. 1 4 2 6 5 3 .
. 3 2 6 1 4 5 .
. 6 5 4 2 3 1 5
. 2 6 5 3 1 4 .
3 4 1 3 5 2 6 .
. . . 3 2 5 . .
KrazyDad - 6x6 Skyscraper Volume 1, Book 100, Skyscraper #10
© 2020 KrazyDad.com
\ No newline at end of file
pzprv3
skyscrapers
6
6
. . . 3 3 2 . .
2 3 6 1 2 5 4 .
. 6 1 2 5 4 3 4
3 4 5 6 3 1 2 3
. 5 4 3 1 2 6 .
4 2 3 5 4 6 1 .
4 1 2 4 6 3 5 2
. . 5 3 1 . . .
KrazyDad - 6x6 Skyscraper Volume 2, Book 98, Skyscraper #2
© 2020 KrazyDad.com
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment