debbe6821a
/ docker_build (push) Successful in 6m37s
Testing zangramru / lint (ruff-format) (push) Failing after 4s
/ helm_deploy (push) Failing after 25s
Testing zangramru / lint (mypy) (push) Failing after 4s
Testing zangramru / lint (ruff) (push) Failing after 4s
Testing zangramru / pytest (push) Failing after 4s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
327 lines
10 KiB
Python
327 lines
10 KiB
Python
import csv
|
|
import secrets
|
|
from collections import defaultdict, deque
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from pprint import pformat
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, FastAPI, Request
|
|
|
|
CWD = Path(__file__).parent
|
|
|
|
# Weighted toward common Russian letters
|
|
WEIGHTED_ALPHABET = (
|
|
"ааааааааоооооооороророрккккккееееетететилннннннснснснсппупмддддввззггяячшхфжыцйюъ"
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Puzzle:
|
|
"""Valid puzzle."""
|
|
|
|
board: list[list[str]]
|
|
solutions: dict[str, list[tuple[int, int]]]
|
|
extra: dict[str, list[tuple[int, int]]]
|
|
word_defs: dict[str, str]
|
|
|
|
def print(self) -> None:
|
|
"""Print information about the solution."""
|
|
data = (
|
|
f"Solutions: {len(self.solutions)}\n"
|
|
f"Extra:{len(self.extra)}\n"
|
|
f"Total words: {len(self.word_defs)}\n\n"
|
|
)
|
|
for row in self.board:
|
|
data += "|".join(row) + "\n"
|
|
|
|
data += pformat(self.solutions) + "\n"
|
|
data += pformat(self.extra) + "\n"
|
|
print(data) # noqa: T201
|
|
|
|
|
|
class TrieNode:
|
|
"""Prefix tree for optimized solution search."""
|
|
|
|
def __init__(self, value: str = "") -> None:
|
|
self.value = value
|
|
self.children: dict[str, TrieNode] = {}
|
|
self.valid_word = False
|
|
|
|
def get_next(self, symbol: str) -> "TrieNode | None":
|
|
"""
|
|
Get next possible node in from a given symobl.
|
|
|
|
Simple lookup in children of this prefix.
|
|
"""
|
|
return self.children.get(symbol)
|
|
|
|
def add(self, word: str) -> None:
|
|
"""Add a word to the trie."""
|
|
word = word.lower()
|
|
node = self
|
|
for i in range(len(word)):
|
|
if word[i] not in node.children:
|
|
node.children[word[i]] = TrieNode(word[: i + 1])
|
|
node = node.children[word[i]]
|
|
node.valid_word = True
|
|
|
|
def find_node(self, word: str) -> "TrieNode | None":
|
|
"""
|
|
Find a node in the trie.
|
|
|
|
The node will only be returned if
|
|
the word is in the trie, or
|
|
is a prefix of a longer word.
|
|
"""
|
|
word = word.lower()
|
|
node = self
|
|
for i in range(len(word)):
|
|
if word[i] not in node.children:
|
|
return None
|
|
node = node.children[word[i]]
|
|
return node
|
|
|
|
def __str__(self) -> str:
|
|
ret = "Node"
|
|
if self.value:
|
|
ret += f"(val={self.value})"
|
|
ret += "\n"
|
|
for prefix in self.children:
|
|
ret += f" {prefix}:\n"
|
|
child = str(self.children[prefix])
|
|
for line in child.splitlines():
|
|
ret += f" {line}\n"
|
|
return ret
|
|
|
|
|
|
class PuzzleGenerator:
|
|
"""Helper for generating puzzles."""
|
|
|
|
def __init__(self, dict_path: Path) -> None:
|
|
self.words: dict[str, str] = {}
|
|
self.trie = TrieNode()
|
|
self._load_dict(dict_path)
|
|
|
|
def _load_dict(self, dict_path: Path) -> None:
|
|
with dict_path.open("r") as f:
|
|
rows = csv.DictReader(f, delimiter="|")
|
|
for row in rows:
|
|
word = row["word"]
|
|
self.words[word] = row["def"]
|
|
self.trie.add(word)
|
|
|
|
def find_solutions( # noqa: C901
|
|
self, board: list[list[str]]
|
|
) -> dict[str, list[tuple[int, int]]]:
|
|
"""
|
|
Find all solutions for the given board.
|
|
|
|
It will look at all possible words
|
|
that can be found for the board,
|
|
starting from any point.
|
|
"""
|
|
solutions = {}
|
|
# This thing has a path and a reference
|
|
# to a trie node.
|
|
paths: deque[tuple[list[tuple[int, int]], TrieNode]] = deque()
|
|
|
|
# Initialize paths.
|
|
for x in range(4):
|
|
for y in range(4):
|
|
cell = board[x][y]
|
|
if not cell:
|
|
continue
|
|
prefix = self.trie.get_next(cell)
|
|
if prefix is not None:
|
|
paths.append(([(x, y)], prefix))
|
|
|
|
while paths:
|
|
path, trie_node = paths.pop()
|
|
if trie_node.valid_word and len(trie_node.value) >= 4:
|
|
solutions[trie_node.value] = path
|
|
for dx, dy in (
|
|
(1, 1),
|
|
(-1, -1),
|
|
(-1, 1),
|
|
(1, -1),
|
|
(1, 0),
|
|
(-1, 0),
|
|
(0, 1),
|
|
(0, -1),
|
|
):
|
|
newpoint = (path[-1][0] + dx, path[-1][1] + dy)
|
|
# If we try to get to the same point twice
|
|
if newpoint in path:
|
|
continue
|
|
# If the word is within board bounds,
|
|
# we check if the current path is a valid word.
|
|
# If no, continue.
|
|
if 0 <= newpoint[0] < 4 and 0 <= newpoint[1] < 4:
|
|
next_letter = board[newpoint[0]][newpoint[1]]
|
|
if next_letter is None:
|
|
continue
|
|
next_node = trie_node.get_next(next_letter)
|
|
if next_node is not None:
|
|
full_path = [*path, newpoint]
|
|
paths.append((full_path, next_node))
|
|
|
|
return solutions
|
|
|
|
def _find_minimal_cover( # noqa: C901, PLR0912
|
|
self, solutions: dict[str, list[tuple[int, int]]], target: int
|
|
) -> tuple[dict[str, list[tuple[int, int]]], dict[str, list[tuple[int, int]]]]:
|
|
"""
|
|
Find minimum cover of the board.
|
|
|
|
This function is used to find minimum
|
|
possible combination of words that
|
|
cover the whole board.
|
|
|
|
Also, at the end we find extra words that can
|
|
be found along the cover ones using same paths.
|
|
"""
|
|
all_cells = set()
|
|
for path in solutions.values():
|
|
all_cells.update(path)
|
|
|
|
if len(all_cells) < 16:
|
|
return solutions, {}
|
|
|
|
uncovered = set(all_cells)
|
|
cover: dict[str, list[tuple[int, int]]] = {}
|
|
sorted_words = sorted(solutions.items(), key=lambda x: len(x[1]), reverse=True)
|
|
|
|
for word, path in sorted_words:
|
|
new_cells = set(path) & uncovered
|
|
if new_cells:
|
|
cover[word] = path
|
|
uncovered -= new_cells
|
|
if not uncovered:
|
|
break
|
|
|
|
# Count how many times each cell is covered by minimal cover
|
|
cell_coverage: defaultdict[tuple[int, int], int] = defaultdict(int)
|
|
for path in cover.values():
|
|
for tile in path:
|
|
cell_coverage[tile] += 1
|
|
|
|
# Fill to target: prefer longer words that cover least-covered cells
|
|
while len(cover) < target:
|
|
best_word = None
|
|
best_score: float = -1
|
|
for word, path in sorted_words:
|
|
if word in cover:
|
|
continue
|
|
score = len(path) * 10 + sum(
|
|
1 / (1 + cell_coverage[tile]) for tile in path
|
|
)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_word = word
|
|
if best_word is not None:
|
|
cover[best_word] = solutions[best_word]
|
|
for tile in solutions[best_word]:
|
|
cell_coverage[tile] += 1
|
|
else:
|
|
break
|
|
|
|
# We want to remove all unnecessary neighbours
|
|
|
|
neigbours: defaultdict[tuple[int, int], set[tuple[int, int]]] = defaultdict(set)
|
|
|
|
for path in cover.values():
|
|
prev = path[0]
|
|
for tile in path[1:]:
|
|
neigbours[prev].add(tile)
|
|
prev = tile
|
|
|
|
extras = {w: p for w, p in solutions.items() if w not in cover}
|
|
|
|
to_delete = []
|
|
for word, path in extras.items():
|
|
current = path[0]
|
|
for next_point in path[1:]:
|
|
if next_point not in neigbours[current]:
|
|
to_delete.append(word)
|
|
break
|
|
current = next_point
|
|
|
|
for word in to_delete:
|
|
extras.pop(word)
|
|
|
|
return cover, extras
|
|
|
|
def get_valid_points(
|
|
self,
|
|
solutions: dict[str, list[tuple[int, int]]],
|
|
) -> set[tuple[int, int]]:
|
|
"""Get all points that are used in solutions."""
|
|
used_cells = set()
|
|
for solution in solutions.values():
|
|
used_cells |= set(solution)
|
|
|
|
return used_cells
|
|
|
|
def generate(self, target: int, max_attempts: int) -> Puzzle | None:
|
|
"""
|
|
Generate a valid puzzle that has solutions.
|
|
|
|
:param target: minimum number of words required to solve the puzzle.
|
|
:param max_attempts: Since sometimes we might fail to generate a correct
|
|
puzzle. We try it multiple times, this parameters limits how
|
|
many attempts we should have before giving up.
|
|
:return: puzzle.
|
|
"""
|
|
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
|
|
valid_points: set[tuple[int, int]] = set()
|
|
single_board_attempt = 0
|
|
for _ in range(max_attempts):
|
|
if single_board_attempt > 50:
|
|
valid_points = set()
|
|
|
|
for x in range(4):
|
|
for y in range(4):
|
|
if (x, y) not in valid_points:
|
|
board[x][y] = secrets.choice(WEIGHTED_ALPHABET)
|
|
|
|
solutions = self.find_solutions(board)
|
|
cover, extra = self._find_minimal_cover(solutions, target)
|
|
valid_points = self.get_valid_points(cover)
|
|
word_defs = {word: self.words[word] for word in cover | extra}
|
|
# We found a valid solution | board combination
|
|
if len(valid_points) != 16 or len(cover) < target:
|
|
single_board_attempt += 1
|
|
continue
|
|
|
|
return Puzzle(
|
|
board=board,
|
|
solutions=cover,
|
|
extra=extra,
|
|
word_defs=word_defs,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def setup_generator(app: FastAPI) -> None:
|
|
"""Create puzzle-generator."""
|
|
app.state.generator = PuzzleGenerator(CWD / "dict.csv")
|
|
|
|
|
|
def get_generator(request: Request) -> PuzzleGenerator:
|
|
"""Get puzzle generator from the state."""
|
|
return request.app.state.generator
|
|
|
|
|
|
PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
generator = PuzzleGenerator(CWD / "dict.csv")
|
|
res = generator.generate(target=10, max_attempts=200)
|
|
if res is None:
|
|
print("No solution found!") # noqa: T201
|
|
else:
|
|
res.print()
|