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
/ 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:
@@ -0,0 +1 @@
|
|||||||
|
**/*.csv filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -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
@@ -52,6 +52,9 @@ allow_untyped_decorators = true
|
|||||||
warn_unused_ignores = false
|
warn_unused_ignores = false
|
||||||
warn_return_any = false
|
warn_return_any = false
|
||||||
namespace_packages = true
|
namespace_packages = true
|
||||||
|
exclude = [
|
||||||
|
"zangramru/db/migrations/.*",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-p no:unraisableexception"
|
addopts = "-p no:unraisableexception"
|
||||||
@@ -150,7 +153,6 @@ enable_taskiq = "None"
|
|||||||
enable_routers = "True"
|
enable_routers = "True"
|
||||||
enable_kafka = "None"
|
enable_kafka = "None"
|
||||||
enable_nats = "None"
|
enable_nats = "None"
|
||||||
enable_loguru = "None"
|
|
||||||
traefik_labels = "None"
|
traefik_labels = "None"
|
||||||
add_dummy = "None"
|
add_dummy = "None"
|
||||||
orm = "piccolo"
|
orm = "piccolo"
|
||||||
|
|||||||
+115
@@ -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
@@ -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"]
|
||||||
@@ -1 +1,5 @@
|
|||||||
"""zangramru package."""
|
"""zangramru package."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ def main() -> None:
|
|||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"zangramru.web.application:get_app",
|
"zangramru.web.application:get_app",
|
||||||
workers=settings.workers_count,
|
workers=settings.workers_count,
|
||||||
host=settings.host,
|
host=settings.server_host,
|
||||||
port=settings.port,
|
port=settings.server_port,
|
||||||
reload=settings.reload,
|
reload=settings.reload,
|
||||||
log_level=settings.log_level.value.lower(),
|
log_level=settings.log_level.value.lower(),
|
||||||
access_log=True,
|
access_log=True,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ CURRENT_DIRECTORY = Path(__file__).parent
|
|||||||
APP_CONFIG = AppConfig(
|
APP_CONFIG = AppConfig(
|
||||||
app_name="zangramru_db",
|
app_name="zangramru_db",
|
||||||
migrations_folder_path=str(CURRENT_DIRECTORY / "migrations"),
|
migrations_folder_path=str(CURRENT_DIRECTORY / "migrations"),
|
||||||
table_classes=table_finder(modules=[]),
|
table_classes=table_finder(modules=["zangramru.db.models.puzzle"]),
|
||||||
migration_dependencies=[],
|
migration_dependencies=[],
|
||||||
commands=[],
|
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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -27,8 +27,8 @@ class Settings(BaseSettings):
|
|||||||
with environment variables.
|
with environment variables.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
host: str = "127.0.0.1"
|
server_host: str = "0.0.0.0" # noqa: S104
|
||||||
port: int = 8000
|
server_port: int = 8000
|
||||||
# quantity of workers for uvicorn
|
# quantity of workers for uvicorn
|
||||||
workers_count: int = 1
|
workers_count: int = 1
|
||||||
# Enable uvicorn reloading
|
# Enable uvicorn reloading
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
|
||||||
"""Simple message model."""
|
|
||||||
|
|
||||||
message: str
|
|
||||||
@@ -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
|
from .views import router
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
|
|
||||||
from zangramru.web.api import echo, monitoring
|
from zangramru.web.api import monitoring, puzzles
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(monitoring.router)
|
api_router.include_router(monitoring.router)
|
||||||
api_router.include_router(echo.router, prefix="/echo", tags=["echo"])
|
api_router.include_router(puzzles.router, prefix="/puzzles")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from zangramru import ROOT
|
||||||
from zangramru.web.api.router import api_router
|
from zangramru.web.api.router import api_router
|
||||||
from zangramru.web.lifespan import lifespan_setup
|
from zangramru.web.lifespan import lifespan_setup
|
||||||
|
|
||||||
@@ -23,4 +25,6 @@ def get_app() -> FastAPI:
|
|||||||
# Main router for the API.
|
# Main router for the API.
|
||||||
app.include_router(router=api_router, prefix="/api")
|
app.include_router(router=api_router, prefix="/api")
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory=ROOT / "static", html=True))
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from zangramru.services.puzzles import setup_generator
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan_setup(
|
async def lifespan_setup(
|
||||||
@@ -21,4 +23,6 @@ async def lifespan_setup(
|
|||||||
app.middleware_stack = None
|
app.middleware_stack = None
|
||||||
app.middleware_stack = app.build_middleware_stack()
|
app.middleware_stack = app.build_middleware_stack()
|
||||||
|
|
||||||
|
setup_generator(app)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|||||||
Reference in New Issue
Block a user