Files
zangramru/zangramru/services/puzzles.py
T
s3rius 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
Done service.
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 02:35:03 +02:00

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()