Done service.
/ 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>
This commit is contained in:
2026-07-01 02:34:47 +02:00
parent b8c7eaddbf
commit debbe6821a
23 changed files with 18871 additions and 97 deletions
+1
View File
@@ -0,0 +1 @@
**/*.csv filter=lfs diff=lfs merge=lfs -text
+52
View File
@@ -0,0 +1,52 @@
on:
push:
tags: ["*"]
jobs:
docker_build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v1
with:
registry: gitea.le-memese.com
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_PAT }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
file: ./Dockerfile
platforms: linux/amd64
tags: |
gitea.le-memese.com/s3rius/zangramru:latest
gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }}
helm_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup helm
uses: azure/setup-helm@v4.3.0
- name: Deploy
run: |
echo "${{secrets.KUBE_CONFIG}}" > /tmp/kubeconfig
helm upgrade \
zangramru \
'oci://gitea.le-memese.com/common/charts/py-app' \
--install \
--wait \
--atomic \
--kubeconfig "/tmp/kubeconfig" \
--namespace=zangramru \
--values=./values.yaml \
--version "0.1.0" \
--set "image.tag=${{ gitea.ref_name }}" \
--set "env.ZANGRAMRU_DB_PASS=${{secrets.DB_PASSWORD}}"
+3 -1
View File
@@ -52,6 +52,9 @@ allow_untyped_decorators = true
warn_unused_ignores = false
warn_return_any = false
namespace_packages = true
exclude = [
"zangramru/db/migrations/.*",
]
[tool.pytest.ini_options]
addopts = "-p no:unraisableexception"
@@ -150,7 +153,6 @@ enable_taskiq = "None"
enable_routers = "True"
enable_kafka = "None"
enable_nats = "None"
enable_loguru = "None"
traefik_labels = "None"
add_dummy = "None"
orm = "piccolo"
+115
View File
@@ -0,0 +1,115 @@
digraph model_graph {
fontname = "Roboto"
fontsize = 8
splines = true
rankdir = "LR";
node [
fontname = "Roboto"
fontsize = 8
shape = "plaintext"
]
edge [
fontname = "Roboto"
fontsize = 8
]
// Tables
TABLE_Puzzle [label=<
<TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
<TR>
<TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#4C89C8">
<FONT FACE="Roboto" COLOR="white" POINT-SIZE="10">
<B>Puzzle</B>
</FONT>
</TD>
</TR>
<TR>
<TD ALIGN="LEFT" BORDER="0">
<FONT FACE="Roboto">
<B>id</B>
</FONT>
</TD>
<TD ALIGN="LEFT">
<FONT FACE="Roboto">
<B>UUID</B>
</FONT>
</TD>
</TR>
<TR>
<TD ALIGN="LEFT" BORDER="0">
<FONT FACE="Roboto">
<B>board</B>
</FONT>
</TD>
<TD ALIGN="LEFT">
<FONT FACE="Roboto">
<B>Varchar</B>
</FONT>
</TD>
</TR>
<TR>
<TD ALIGN="LEFT" BORDER="0">
<FONT FACE="Roboto">
<B>solutions</B>
</FONT>
</TD>
<TD ALIGN="LEFT">
<FONT FACE="Roboto">
<B>JSON</B>
</FONT>
</TD>
</TR>
<TR>
<TD ALIGN="LEFT" BORDER="0">
<FONT FACE="Roboto">
<B>extra</B>
</FONT>
</TD>
<TD ALIGN="LEFT">
<FONT FACE="Roboto">
<B>JSON</B>
</FONT>
</TD>
</TR>
<TR>
<TD ALIGN="LEFT" BORDER="0">
<FONT FACE="Roboto">
<B>definitions</B>
</FONT>
</TD>
<TD ALIGN="LEFT">
<FONT FACE="Roboto">
<B>JSON</B>
</FONT>
</TD>
</TR>
<TR>
<TD ALIGN="LEFT" BORDER="0">
<FONT FACE="Roboto">
<B>date</B>
</FONT>
</TD>
<TD ALIGN="LEFT">
<FONT FACE="Roboto">
<B>Date</B>
</FONT>
</TD>
</TR>
</TABLE>
>]
// Relations
}
+45
View File
@@ -0,0 +1,45 @@
nameOverride: "zangramru"
image:
repository: gitea.le-memese.com/s3rius/zangramru
tag: "latest"
service:
port: 8000
env:
ZANGRAMRU_server_port: 8000
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
ZANGRAMRU_DB_USER: zangram
ZANGRAMRU_DB_BASE: zangram
probes:
liveness:
httpGet:
path: /api/health
port: http
readiness:
httpGet:
path: /api/health
port: http
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: cert-issuer
external-dns.alpha.kubernetes.io/hostname: zangram.le-memese.com
tls:
- hosts:
- zangram.le-memese.com
secretName: zangram-tls
hosts:
- host: zangram.le-memese.com
paths:
- path: /
pathType: Prefix
migrators:
pg:
annotations:
"helm.sh/hook-weight": "5"
command: ["piccolo", "migrations", "forward", "all"]
+4
View File
@@ -1 +1,5 @@
"""zangramru package."""
from pathlib import Path
ROOT = Path(__file__).parent
+2 -2
View File
@@ -11,8 +11,8 @@ def main() -> None:
uvicorn.run(
"zangramru.web.application:get_app",
workers=settings.workers_count,
host=settings.host,
port=settings.port,
host=settings.server_host,
port=settings.server_port,
reload=settings.reload,
log_level=settings.log_level.value.lower(),
access_log=True,
+1 -1
View File
@@ -8,7 +8,7 @@ CURRENT_DIRECTORY = Path(__file__).parent
APP_CONFIG = AppConfig(
app_name="zangramru_db",
migrations_folder_path=str(CURRENT_DIRECTORY / "migrations"),
table_classes=table_finder(modules=[]),
table_classes=table_finder(modules=["zangramru.db.models.puzzle"]),
migration_dependencies=[],
commands=[],
)
@@ -0,0 +1,151 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Date
from piccolo.columns.column_types import JSON
from piccolo.columns.column_types import UUID
from piccolo.columns.column_types import Varchar
from piccolo.columns.defaults.date import DateNow
from piccolo.columns.defaults.uuid import UUID4
from piccolo.columns.indexes import IndexMethod
ID = "2026-06-30T23:17:22:296070"
VERSION = "1.34.0"
DESCRIPTION = ""
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
)
manager.add_table(
class_name="Puzzle", tablename="puzzle", schema=None, columns=None
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="id",
db_column_name="id",
column_class_name="UUID",
column_class=UUID,
params={
"default": UUID4(),
"null": False,
"primary_key": True,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="board",
db_column_name="board",
column_class_name="Varchar",
column_class=Varchar,
params={
"length": 16,
"default": "",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="solutions",
db_column_name="solutions",
column_class_name="JSON",
column_class=JSON,
params={
"default": "{}",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="extra",
db_column_name="extra",
column_class_name="JSON",
column_class=JSON,
params={
"default": "{}",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="definitions",
db_column_name="definitions",
column_class_name="JSON",
column_class=JSON,
params={
"default": "{}",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="date",
db_column_name="date",
column_class_name="Date",
column_class=Date,
params={
"default": DateNow(),
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
return manager
+13
View File
@@ -0,0 +1,13 @@
from piccolo.columns import JSON, UUID, Date, Varchar
from piccolo.table import Table
class Puzzle(Table):
"""Table for puzzles."""
id = UUID(primary_key=True)
board = Varchar(length=16)
solutions = JSON()
extra = JSON()
definitions = JSON()
date = Date()
-62
View File
@@ -1,62 +0,0 @@
import logging
import sys
from loguru import logger
from zangramru.settings import settings
class InterceptHandler(logging.Handler):
"""
Default handler from examples in loguru documentation.
This handler intercepts all log requests and
passes them to loguru.
For more info see:
https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
"""
def emit(self, record: logging.LogRecord) -> None: # pragma: no cover
"""
Propagates logs to loguru.
:param record: record to log.
"""
try:
level: str | int = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# Find caller from where originated the logged message
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__:
frame = frame.f_back # type: ignore
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level,
record.getMessage(),
)
def configure_logging() -> None: # pragma: no cover
"""Configures logging."""
intercept_handler = InterceptHandler()
logging.basicConfig(handlers=[intercept_handler], level=logging.NOTSET)
for logger_name in logging.root.manager.loggerDict:
if logger_name.startswith("uvicorn."):
logging.getLogger(logger_name).handlers = []
# change handler for default uvicorn logger
logging.getLogger("uvicorn").handlers = [intercept_handler]
logging.getLogger("uvicorn.access").handlers = [intercept_handler]
# set logs output, level and format
logger.remove()
logger.add(
sys.stdout,
level=settings.log_level.value,
)
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
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()
+2 -2
View File
@@ -27,8 +27,8 @@ class Settings(BaseSettings):
with environment variables.
"""
host: str = "127.0.0.1"
port: int = 8000
server_host: str = "0.0.0.0" # noqa: S104
server_port: int = 8000
# quantity of workers for uvicorn
workers_count: int = 1
# Enable uvicorn reloading
+653
View File
@@ -0,0 +1,653 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Занграммы</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 2rem;
user-select: none;
}
h1 { margin-bottom: 0.5rem; color: #fff; }
.controls {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
}
button {
padding: 0.5rem 1.2rem;
border: none;
border-radius: 8px;
background: #16213e;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #0f3460; }
#status { font-size: 0.95rem; color: #aaa; }
#wordDisplay {
font-size: 2rem;
font-weight: bold;
color: #fff;
min-height: 3rem;
margin-bottom: 0.5rem;
letter-spacing: 0.3em;
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: background 0.2s;
}
#wordDisplay:hover {
background: rgba(255,255,255,0.05);
}
#message {
min-height: 1.5rem;
margin-bottom: 0.5rem;
font-size: 0.95rem;
color: #feca57;
}
#board {
background: #16213e;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
cursor: pointer;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
#found {
margin-top: 1.5rem;
max-width: 500px;
width: 100%;
}
#found h2 { margin-bottom: 0.5rem; font-size: 1.1rem; }
.word-item {
background: #16213e;
border-radius: 10px;
padding: 0.8rem 1rem;
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.word-item-inner {
display: flex;
align-items: center;
gap: 0.5rem;
}
.word-color {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
}
.word-text {
font-weight: 700;
font-size: 1.1rem;
color: #fff;
}
.word-def {
color: #888;
font-size: 0.9rem;
padding-left: 1.5rem;
}
#gameComplete {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
z-index: 100;
justify-content: center;
align-items: center;
}
#gameComplete.show {
display: flex;
}
#gameComplete .popup {
background: #16213e;
border-radius: 16px;
padding: 2rem 3rem;
text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
#gameComplete .popup h2 {
color: #fff;
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
#gameComplete .popup .time {
color: #2ed573;
font-size: 2rem;
font-weight: bold;
margin: 1rem 0;
}
#gameComplete .popup button {
margin-top: 1rem;
padding: 0.7rem 2rem;
font-size: 1rem;
}
</style>
</head>
<body>
<h1>Занграммы</h1>
<div class="controls">
<span id="status">Загрузка...</span>
</div>
<div id="wordDisplay"></div>
<div id="message"></div>
<canvas id="board"></canvas>
<div id="found"></div>
<div id="gameComplete">
<div class="popup">
<h2>Вы нашли все слова! Ваше время:</h2>
<div class="time" id="gameTime"></div>
</div>
</div>
<script>
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const wordDisplay = document.getElementById('wordDisplay');
const msgEl = document.getElementById('message');
const foundDiv = document.getElementById('found');
const gameCompleteEl = document.getElementById('gameComplete');
const gameTimeEl = document.getElementById('gameTime');
const CELL = 70;
const GAP = 20;
const GRID = 4;
const CELL_PITCH = CELL + GAP;
let OFFSET_X, OFFSET_Y;
const PALETTE = [
'#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff',
'#5f27cd','#01a3a4','#f368e0','#ff6348','#7bed9f',
'#70a1ff','#ffa502','#2ed573','#1e90ff','#ff4757',
'#eccc68','#a4b0be','#ff7979','#badc58','#dff9fb'
];
let board = null;
let allSolutions = {};
let extraWords = {};
let foundWords = new Set();
let foundPaths = {};
let word_defs = {};
let selected = [];
let flashTimer = null;
let startTime = null;
let timerInterval = null;
function formatTime(ms) {
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60).toString().padStart(2, '0');
const sec = (totalSec % 60).toString().padStart(2, '0');
return min + ':' + sec;
}
function startTimer() {
startTime = Date.now();
if (timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(() => {
const remaining = Object.keys(allSolutions).filter(w => !foundWords.has(w)).length;
if (remaining === 0) {
clearInterval(timerInterval);
const elapsed = Date.now() - startTime;
gameTimeEl.textContent = formatTime(elapsed);
gameCompleteEl.classList.add('show');
}
}, 500);
}
function resetTimer() {
if (timerInterval) clearInterval(timerInterval);
timerInterval = null;
startTime = null;
}
function initBoard() {
canvas.width = GRID * CELL_PITCH + 40;
canvas.height = GRID * CELL_PITCH + 80;
OFFSET_X = (canvas.width - GRID * CELL_PITCH) / 2;
OFFSET_Y = 40;
}
function cellCenter(x, y) {
return {
x: OFFSET_X + x * CELL_PITCH + CELL / 2,
y: OFFSET_Y + y * CELL_PITCH + CELL / 2
};
}
function cellKey(x, y) {
return x + ',' + y;
}
function getSolutionConnections() {
const conns = new Set();
for (const [word, path] of Object.entries(allSolutions)) {
for (let i = 0; i < path.length - 1; i++) {
const a = cellKey(path[i][0], path[i][1]);
const b = cellKey(path[i + 1][0], path[i + 1][1]);
conns.add(a < b ? a + '-' + b : b + '-' + a);
}
}
return conns;
}
function isAdjacentAndConnected(a, b) {
if (Math.abs(a[0] - b[0]) > 1 || Math.abs(a[1] - b[1]) > 1) return false;
if (a[0] === b[0] && a[1] === b[1]) return false;
const ka = cellKey(a[0], a[1]);
const kb = cellKey(b[0], b[1]);
const key = ka < kb ? ka + '-' + kb : kb + '-' + ka;
const conns = getSolutionConnections();
return conns.has(key);
}
function getActiveCells() {
const cells = new Set();
for (const [word, path] of Object.entries(allSolutions)) {
if (foundWords.has(word)) continue;
for (const cell of path) {
cells.add(cellKey(cell[0], cell[1]));
}
}
return cells;
}
function getActiveConnections() {
const conns = new Set();
for (const [word, path] of Object.entries(allSolutions)) {
if (foundWords.has(word)) continue;
for (let i = 0; i < path.length - 1; i++) {
const a = cellKey(path[i][0], path[i][1]);
const b = cellKey(path[i + 1][0], path[i + 1][1]);
conns.add(a < b ? a + '-' + b : b + '-' + a);
}
}
return conns;
}
function drawPath(path, color, lineWidth, alpha) {
if (path.length < 2) return;
const t1 = CELL / (2 * CELL_PITCH);
const t2 = 1 - CELL / (2 * CELL_PITCH);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.globalAlpha = alpha;
ctx.beginPath();
for (let i = 0; i < path.length - 1; i++) {
const c1 = cellCenter(path[i][0], path[i][1]);
const c2 = cellCenter(path[i + 1][0], path[i + 1][1]);
const x1 = c1.x + (c2.x - c1.x) * t1;
const y1 = c1.y + (c2.y - c1.y) * t1;
const x2 = c1.x + (c2.x - c1.x) * t2;
const y2 = c1.y + (c2.y - c1.y) * t2;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
const activeCells = getActiveCells();
const activeConns = getActiveConnections();
// Draw connections between active cells
ctx.strokeStyle = '#2a4a7f';
ctx.lineWidth = GAP - 6;
ctx.lineCap = 'round';
ctx.globalAlpha = 0.6;
ctx.beginPath();
for (const key of activeConns) {
const [ka, kb] = key.split('-');
const [ax, ay] = ka.split(',').map(Number);
const [bx, by] = kb.split(',').map(Number);
const c1 = cellCenter(ax, ay);
const c2 = cellCenter(bx, by);
const t1 = CELL / (2 * CELL_PITCH);
const t2 = 1 - CELL / (2 * CELL_PITCH);
const x1 = c1.x + (c2.x - c1.x) * t1;
const y1 = c1.y + (c2.y - c1.y) * t1;
const x2 = c1.x + (c2.x - c1.x) * t2;
const y2 = c1.y + (c2.y - c1.y) * t2;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.globalAlpha = 1;
// Draw selected path
if (selected.length > 1) {
drawPath(selected, '#fff', GAP - 2, 0.8);
}
// Draw cells
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) {
const cx = OFFSET_X + x * CELL_PITCH;
const cy = OFFSET_Y + y * CELL_PITCH;
const key = cellKey(x, y);
const isVisible = activeCells.has(key);
const isSel = selSet.has(key);
ctx.globalAlpha = isVisible ? 1 : 0.08;
ctx.fillStyle = isSel ? '#16213e' : '#0f3460';
ctx.beginPath();
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2);
ctx.fill();
if (isSel && isVisible) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = '#fff';
ctx.font = 'bold 30px sans-serif';
ctx.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
}
}
ctx.globalAlpha = 1;
wordDisplay.textContent = selected.map(([x, y]) => board[x][y]).join('');
}
function flashPath(path, color = '#2ed573') {
if (flashTimer) clearTimeout(flashTimer);
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
const flashSet = new Set((path || selected).map(([x, y]) => cellKey(x, y)));
ctx.clearRect(0, 0, canvas.width, canvas.height);
const activeCells = getActiveCells();
const activeConns = getActiveConnections();
// Draw connections
ctx.strokeStyle = '#2a4a7f';
ctx.lineWidth = GAP - 6;
ctx.lineCap = 'round';
ctx.globalAlpha = 0.6;
ctx.beginPath();
for (const key of activeConns) {
const [ka, kb] = key.split('-');
const [ax, ay] = ka.split(',').map(Number);
const [bx, by] = kb.split(',').map(Number);
const c1 = cellCenter(ax, ay);
const c2 = cellCenter(bx, by);
const t1 = CELL / (2 * CELL_PITCH);
const t2 = 1 - CELL / (2 * CELL_PITCH);
const x1 = c1.x + (c2.x - c1.x) * t1;
const y1 = c1.y + (c2.y - c1.y) * t1;
const x2 = c1.x + (c2.x - c1.x) * t2;
const y2 = c1.y + (c2.y - c1.y) * t2;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.globalAlpha = 1;
// Draw selected path
if (selected.length > 1) {
drawPath(selected, color, GAP - 2, 0.9);
}
// Draw cells with flash tint
for (let x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) {
const cx = OFFSET_X + x * CELL_PITCH;
const cy = OFFSET_Y + y * CELL_PITCH;
const key = cellKey(x, y);
const isSel = selSet.has(key);
const isActive = activeCells.has(key);
ctx.globalAlpha = flashSet.has(key) ? 1 : (isActive ? 1 : 0.08);
ctx.fillStyle = flashSet.has(key) ? shadeColor(color, -40) : '#0f3460';
ctx.beginPath();
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2);
ctx.fill();
if (flashSet.has(key)) {
ctx.strokeStyle = color;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = '#fff';
ctx.font = 'bold 30px sans-serif';
ctx.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
}
}
ctx.globalAlpha = 1;
flashTimer = setTimeout(() => {
selected = [];
draw();
}, 500);
}
function shadeColor(color, percent) {
let R = parseInt(color.substring(1, 3), 16);
let G = parseInt(color.substring(3, 5), 16);
let B = parseInt(color.substring(5, 7), 16);
R = parseInt(R * (100 + percent) / 100);
G = parseInt(G * (100 + percent) / 100);
B = parseInt(B * (100 + percent) / 100);
R = (R < 255) ? R : 255;
G = (G < 255) ? G : 255;
B = (B < 255) ? B : 255;
R = Math.round(R < 0 ? 0 : R);
G = Math.round(G < 0 ? 0 : G);
B = Math.round(B < 0 ? 0 : B);
return '#' + (R.toString(16).padStart(2, '0')) + (G.toString(16).padStart(2, '0')) + (B.toString(16).padStart(2, '0'));
}
function getWordColor(word) {
let hash = 0;
for (let i = 0; i < word.length; i++) {
hash = word.charCodeAt(i) + ((hash << 5) - hash);
}
return PALETTE[Math.abs(hash) % PALETTE.length];
}
function renderFound() {
foundDiv.innerHTML = '<h2>Найдено (' + foundWords.size + ')</h2>';
for (const word of foundWords) {
const color = getWordColor(word);
const def = word_defs[word] || '';
const isExtra = word in extraWords;
const item = document.createElement('div');
item.className = 'word-item';
item.style.opacity = isExtra ? '0.6' : '1';
item.innerHTML =
'<div class="word-item-inner"><span class="word-color" style="background:' + color + '"></span><span class="word-text">' + word + '</span></div>' +
(def ? '<span class="word-def">— ' + def + '</span>' : '');
foundDiv.appendChild(item);
}
}
function updateStatus() {
const remaining = Object.keys(allSolutions).filter(w => !foundWords.has(w)).length;
statusEl.textContent = remaining + ' осталось';
}
function showMessage(text, color) {
msgEl.textContent = text;
msgEl.style.color = color || '#feca57';
setTimeout(() => { msgEl.textContent = ''; }, 2000);
}
function checkWord() {
if (selected.length < 4) return;
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
// Already found
if (foundWords.has(word)) {
// Check if it's a prefix of an unfound word
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
if (isPrefixOfUnfound) return;
showMessage('Уже найдено!', '#feca57');
selected = [];
draw();
return;
}
// Valid solution word
if (word in allSolutions) {
foundWords.add(word);
foundPaths[word] = allSolutions[word];
flashPath(allSolutions[word], '#2ed573');
renderFound();
updateStatus();
return;
}
// Valid extra word
if (word in extraWords) {
foundWords.add(word);
foundPaths[word] = extraWords[word];
flashPath(extraWords[word], '#feca57');
renderFound();
showMessage('Бонус!', '#feca57');
return;
}
// Not a valid word, silently ignore (user still building)
}
function validateWord() {
if (selected.length === 0) return;
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
// Already found
if (foundWords.has(word)) {
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
if (isPrefixOfUnfound) return;
showMessage('Уже найдено!', '#feca57');
selected = [];
draw();
return;
}
// Too short
if (selected.length < 4) {
showMessage('Мало букв — нужно минимум 4', '#ff6b6b');
selected = [];
draw();
return;
}
// Not found
if (!(word in allSolutions) && !(word in extraWords)) {
showMessage('Слово не найдено!', '#ff6b6b');
selected = [];
draw();
return;
}
showMessage('Верно!', '#2ed573');
}
function handleCellClick(mx, my) {
const col = Math.floor((mx - OFFSET_X) / CELL_PITCH);
const row = Math.floor((my - OFFSET_Y) / CELL_PITCH);
if (col < 0 || col >= GRID || row < 0 || row >= GRID) return false;
// Deselect if clicking already selected tile
const idx = selected.findIndex(s => s[0] === col && s[1] === row);
if (idx >= 0) {
selected = selected.slice(0, idx);
draw();
return true;
}
// Check connection
if (selected.length > 0) {
if (!isAdjacentAndConnected(selected[selected.length - 1], [col, row])) return false;
}
selected.push([col, row]);
draw();
checkWord();
return true;
}
let _pointerDown = false;
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
_pointerDown = true;
const r = canvas.getBoundingClientRect();
handleCellClick(e.clientX - r.left, e.clientY - r.top);
});
canvas.addEventListener('pointerup', (e) => {
_pointerDown = false;
});
canvas.addEventListener('pointercancel', (e) => {
e.preventDefault();
_pointerDown = false;
selected = [];
draw();
});
async function loadPuzzle() {
statusEl.textContent = 'Генерация...';
gameCompleteEl.classList.remove('show');
resetTimer();
try {
const res = await fetch('/api/puzzles/random');
const data = await res.json();
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
board = data.board;
allSolutions = data.solutions;
extraWords = data.extra || {};
word_defs = data.word_defs || {};
selected = [];
foundWords = new Set();
foundPaths = {};
initBoard();
draw();
renderFound();
updateStatus();
startTimer();
} catch (e) {
statusEl.textContent = 'Не удалось загрузить головоломку';
}
}
wordDisplay.addEventListener('click', validateWord);
loadPuzzle();
</script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
from pydantic import BaseModel
class Message(BaseModel):
"""Simple message model."""
message: str
-18
View File
@@ -1,18 +0,0 @@
from fastapi import APIRouter
from zangramru.web.api.echo.schema import Message
router = APIRouter()
@router.post("/", response_model=Message)
async def send_echo_message(
incoming_message: Message,
) -> Message:
"""
Sends echo back to user.
:param incoming_message: incoming message.
:returns: message same as the incoming.
"""
return incoming_message
@@ -1,5 +1,3 @@
"""Echo API."""
from .views import router
__all__ = ["router"]
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class PuzzleOutputDTO(BaseModel):
"""DTO for puzzle responses."""
board: list[list[str]]
solutions: dict[str, list[tuple[int, int]]]
extra: dict[str, list[tuple[int, int]]]
word_defs: dict[str, str]
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from zangramru.services.puzzles import PuzzleGeneratorDep
from .schema import PuzzleOutputDTO
router = APIRouter()
@router.get("/random")
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
"""Generate a random puzzle."""
puzzle = generator.generate(target=10, max_attempts=500)
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
+2 -2
View File
@@ -1,7 +1,7 @@
from fastapi.routing import APIRouter
from zangramru.web.api import echo, monitoring
from zangramru.web.api import monitoring, puzzles
api_router = APIRouter()
api_router.include_router(monitoring.router)
api_router.include_router(echo.router, prefix="/echo", tags=["echo"])
api_router.include_router(puzzles.router, prefix="/puzzles")
+4
View File
@@ -1,5 +1,7 @@
from fastapi import FastAPI
from starlette.staticfiles import StaticFiles
from zangramru import ROOT
from zangramru.web.api.router import api_router
from zangramru.web.lifespan import lifespan_setup
@@ -23,4 +25,6 @@ def get_app() -> FastAPI:
# Main router for the API.
app.include_router(router=api_router, prefix="/api")
app.mount("/", StaticFiles(directory=ROOT / "static", html=True))
return app
+4
View File
@@ -3,6 +3,8 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from zangramru.services.puzzles import setup_generator
@asynccontextmanager
async def lifespan_setup(
@@ -21,4 +23,6 @@ async def lifespan_setup(
app.middleware_stack = None
app.middleware_stack = app.build_middleware_stack()
setup_generator(app)
yield