16 Commits

Author SHA1 Message Date
s3rius 0795dff811 Small updates.
/ docker_build (push) Successful in 1m48s
/ helm_deploy (push) Successful in 45s
Testing zangramru / lint (push) Successful in 1m8s
Testing zangramru / pytest (push) Successful in 40s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 23:31:10 +02:00
s3rius eb2c2771c4 Fixed lints.
Testing zangramru / lint (push) Successful in 1m5s
Testing zangramru / pytest (push) Successful in 1m5s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 10:24:45 +02:00
s3rius 9036816e97 Fixed docker ignore.
Testing zangramru / lint (push) Failing after 1m5s
Testing zangramru / pytest (push) Successful in 1m6s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 04:46:15 +02:00
s3rius ab7fb24b01 Added dict generators.
Testing zangramru / lint (push) Failing after 1m3s
Testing zangramru / pytest (push) Successful in 1m44s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 04:05:55 +02:00
s3rius a2f96e247a More dicts.
/ docker_build (push) Successful in 1m20s
/ helm_deploy (push) Successful in 33s
Testing zangramru / pytest (push) Successful in 18s
Testing zangramru / lint (push) Successful in 31s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 03:24:19 +02:00
s3rius cf98f5ccbd Dict update.
/ docker_build (push) Successful in 5m56s
/ helm_deploy (push) Successful in 48s
Testing zangramru / lint (push) Successful in 2m53s
Testing zangramru / pytest (push) Successful in 3m22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 01:07:08 +02:00
s3rius fcd7f6aaf9 Fixed time copying.
/ docker_build (push) Successful in 1m9s
/ helm_deploy (push) Successful in 23s
Testing zangramru / lint (push) Successful in 30s
Testing zangramru / pytest (push) Successful in 18s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 19:00:02 +02:00
s3rius 042e8b8581 Added random board sharing.
/ docker_build (push) Successful in 1m12s
/ helm_deploy (push) Successful in 23s
Testing zangramru / lint (push) Successful in 29s
Testing zangramru / pytest (push) Successful in 18s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 18:33:37 +02:00
s3rius 9069f751c3 Some small enhancements additional words.
Testing zangramru / lint (push) Has been cancelled
Testing zangramru / pytest (push) Has been cancelled
/ docker_build (push) Successful in 5m49s
/ helm_deploy (push) Successful in 36s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 15:20:25 +02:00
s3rius 472956c76d lint fixes.
Testing zangramru / lint (push) Successful in 4m16s
Testing zangramru / pytest (push) Failing after 3m22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 13:42:17 +02:00
s3rius 27c275bccb Possible tests fix.
Testing zangramru / lint (push) Failing after 48s
Testing zangramru / pytest (push) Failing after 43s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 13:23:45 +02:00
s3rius f89a031997 Fixed some backend issues.
Testing zangramru / lint (push) Failing after 34s
Testing zangramru / pytest (push) Failing after 47s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 13:05:20 +02:00
s3rius 8033b498f9 Merge pull request 'Increased contrast of letter connections for readability.' (#2) from task_frontend_relations_fix into master
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 22s
Reviewed-on: #2
Reviewed-by: s3rius <s3rius@noreply.gitea.le-memese.com>
2026-07-02 10:57:08 +00:00
shared_mutex 129a1ea5f7 Increased contrast of letter connections for readability.
Testing zangramru / lint (push) Successful in 33s
Testing zangramru / pytest (push) Has been cancelled
Bumped the board connection line color in both themes so the links
between letters read more clearly:
- light: #e2dbc7 -> #c7bb95
- dark:  #33333f -> #52525f

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:55:32 +03:00
s3rius c5dc537844 Merge pull request 'Redesigned frontend: neo-brutalist UI with swipe, keyboard input and animations.' (#1) from task_better_frontend into master
/ docker_build (push) Successful in 1m38s
/ helm_deploy (push) Successful in 37s
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 22s
Reviewed-on: #1
Reviewed-by: s3rius <s3rius@noreply.gitea.le-memese.com>
2026-07-02 10:30:50 +00:00
shared_mutex 2062083f80 Redesigned frontend: neo-brutalist UI with swipe, keyboard input and animations.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 49s
Single-file rewrite of static/index.html preserving the puzzle API contract,
board coordinates and localStorage format. Adds swipe-to-select, keyboard
tracing (type/Backspace/Enter/Esc), juicy tile animations + confetti, and a
light/dark theme toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:28:49 +03:00
22 changed files with 39212 additions and 4175 deletions
+1
View File
@@ -1,4 +1,5 @@
### Python template
scripts/
deploy/
.idea/
+2
View File
@@ -1,2 +1,4 @@
**/*.csv filter=lfs diff=lfs merge=lfs -text
**/*.png filter=lfs diff=lfs merge=lfs -text
scripts/dicts/data/*.txt filter=lfs diff=lfs merge=lfs -text
**/*.gz filter=lfs diff=lfs merge=lfs -text
+2 -10
View File
@@ -6,7 +6,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -20,15 +20,7 @@ jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Update docker-compose
uses: KengoTODA/actions-setup-docker-compose@v1
with:
version: "2.28.0"
- uses: actions/checkout@v7
- name: run tests
run: docker compose run --build --rm api pytest -vv
- name: show logs
+7 -6
View File
@@ -8,7 +8,7 @@ services:
restart: always
ports:
# Exposes application port.
- "8000:8000"
- "8000:8000"
env_file:
- path: .env
required: false
@@ -25,8 +25,8 @@ services:
ZANGRAMRU_DB_USER: zangramru
ZANGRAMRU_DB_PASS: zangramru
ZANGRAMRU_DB_BASE: zangramru
volumes:
- .:/app/src/
# volumes:
# - .:/app/src/
db:
image: postgres:18.3-trixie
@@ -49,13 +49,14 @@ services:
migrator:
<<: *main_app
restart: "no"
command: piccolo migrations forwards all
command:
- bash
- -c
- "ls && piccolo migrations forwards all"
depends_on:
db:
condition: service_healthy
volumes:
zangramru-db-data:
name: zangramru-db-data
-115
View File
@@ -1,115 +0,0 @@
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
}
+92
View File
@@ -0,0 +1,92 @@
import csv
import gzip
import json
import logging
from collections import defaultdict
from pathlib import Path
from zangramru.utils.trie import TrieNode
CWD = Path(__file__).parent
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
def build_trie(dict_path: Path) -> TrieNode:
"""Build trie for wikidict definitions."""
trie = TrieNode()
total_words = 0
with gzip.open(dict_path) as f_in:
for line in f_in:
word_info = json.loads(line)
# If it starts wit capital.
if word_info.get("lang_code") != "ru":
continue
if word_info.get("pos") != "noun":
continue
if word_info["word"][0].isupper():
continue
senses = word_info.get("senses", [])
multi_def = []
for sense in senses:
glosses = sense.get("glosses")
if glosses:
multi_def.extend(glosses)
total_words += 1
trie.add(word_info["word"], definitions=multi_def)
logger.info(f"Trie is ready. Total words: {total_words}")
return trie
def get_defs(
dict_file: Path,
defs: TrieNode,
stats: dict[str, int],
) -> dict[str, list[str]]:
"""Find all word definitions for all our dicts."""
word_def = {}
with dict_file.open("r") as f_in:
for line in f_in:
if len(line.strip()) < 4:
stats["too_short"] += 1
continue
node = defs.find_node(line)
if node is None:
stats["not_found"] += 1
continue
if not node.valid_word:
stats["invalid"] += 1
continue
word_def[node.value] = node.definitions
return word_def
def main() -> None:
"""Update dictionaries."""
trie = build_trie(Path(CWD / "wikidict.jsonl.gz"))
with Path(CWD.parent.parent / "zangramru/services/dict.csv").open("w") as f_out:
writer = csv.writer(f_out, delimiter="|")
writer.writerow(["word", "def"])
stats = defaultdict(int)
words: dict[str, list[str]] = {}
for file in Path(CWD / "data").iterdir():
logger.info(f"Processing {file}")
words.update(get_defs(file, trie, stats))
logger.info("Done updating")
logger.info(stats)
for word, defs in words.items():
all_defs = ""
if len(defs) == 1:
all_defs = defs[0]
else:
all_defs = "\n".join(f"{i + 1}: {defn}" for i, defn in enumerate(defs))
writer.writerow([word, all_defs])
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
и
в
не
на
я
быть
он
с
что
а
по
это
она
этот
к
но
они
мы
как
из
у
который
то
за
свой
что
весь
год
от
так
о
для
ты
же
все
тот
мочь
вы
человек
такой
его
сказать
только
или
ещё
бы
себя
один
как
уже
до
время
если
сам
когда
другой
вот
говорить
наш
мой
знать
стать
при
чтобы
дело
жизнь
кто
первый
очень
два
день
её
новый
рука
даже
во
со
раз
где
там
под
можно
ну
какой
после
их
работа
без
самый
потом
надо
хотеть
ли
слово
идти
большой
должен
место
иметь
ничто
+900
View File
@@ -0,0 +1,900 @@
то
сейчас
тут
лицо
каждый
друг
нет
теперь
ни
глаз
тоже
тогда
видеть
вопрос
через
да
здесь
дом
да
потому
сторона
какой-то
думать
сделать
страна
жить
чем
мир
об
последний
случай
голова
более
делать
что-то
смотреть
ребенок
просто
конечно
сила
российский
конец
перед
несколько
вид
система
всегда
работать
между
три
нет
понять
пойти
часть
спросить
город
дать
также
никто
понимать
получить
отношение
лишь
второй
именно
ваш
хотя
ни
сидеть
над
женщина
оказаться
русский
один
взять
прийти
являться
деньги
почему
вдруг
любить
стоить
почти
земля
общий
ведь
машина
однако
сразу
хорошо
вода
отец
высокий
остаться
выйти
много
проблема
начать
хороший
час
это
сегодня
право
совсем
нога
считать
главный
решение
увидеть
дверь
казаться
образ
писать
история
лучший
власть
закон
все
война
бог
голос
найти
поэтому
стоять
вообще
тысяча
больше
вместе
маленький
книга
некоторый
решить
любой
возможность
результат
ночь
стол
никогда
имя
область
молодой
пройти
например
статья
оно
число
компания
про
государственный
полный
принять
народ
никакой
советский
жена
настоящий
всякий
группа
развитие
процесс
суд
давать
ответить
старый
условие
твой
пока
средство
помнить
начало
ждать
свет
пора
путь
душа
куда
нужно
разный
нужный
уровень
иной
форма
связь
уж
минута
кроме
находиться
опять
многий
белый
собственный
улица
черный
написать
вечер
снова
основной
качество
мысль
дорога
мать
действие
месяц
оставаться
государство
язык
любовь
взгляд
мама
играть
далекий
лежать
нельзя
век
школа
подумать
уйти
цель
среди
общество
посмотреть
деятельность
организация
кто-то
вернуться
президент
комната
порядок
момент
театр
следовать
читать
письмо
подобный
следующий
утро
особенно
помощь
ситуация
роль
бывать
ходить
рубль
начинать
появиться
смысл
состояние
называть
рядом
квартира
назад
равный
из-за
орган
внимание
тело
труд
прийтись
хотеться
сын
мера
пять
смерть
живой
рынок
программа
задача
предприятие
известный
окно
вести
совершенно
военный
разговор
показать
правительство
важный
семья
великий
производство
простой
значит
третий
сколько
огромный
давно
политический
информация
действительно
положение
поставить
бояться
наконец
центр
происходить
ответ
муж
автор
все-таки
стена
существовать
даже
интерес
становиться
федерация
правило
оба
часто
московский
управление
слышать
быстро
смочь
заметить
как-то
мужчина
долго
правда
идея
партия
иногда
использовать
пытаться
готовый
чуть
зачем
представить
чувствовать
создать
совет
счет
сердце
движение
вещь
материал
неделя
чувство
затем
данный
заниматься
продолжать
красный
глава
ко
слушать
наука
узнать
ряд
газета
причина
против
плечо
современный
цена
план
приехать
речь
четыре
отвечать
точка
основа
товарищ
культура
слишком
рассказывать
вполне
далее
рассказать
данные
представлять
мнение
социальный
около
документ
институт
ход
брать
забыть
проект
ранний
встреча
особый
целый
директор
провести
спать
плохой
может
впрочем
сильный
наверное
скорый
ведь
срок
палец
опыт
помочь
больше
приходить
служба
крупный
внутренний
просить
вспомнить
открыть
привести
судьба
пока
девушка
поскольку
очередь
лес
пусть
экономический
оставить
правый
состав
словно
федеральный
спрашивать
принимать
член
искать
близкий
количество
похожий
событие
объект
зал
создание
войти
различный
значение
назвать
достаточно
период
хоть
шаг
необходимый
успеть
произойти
брат
искусство
единственный
легкий
структура
выходить
номер
предложить
пример
пить
исследование
гражданин
глядеть
человеческий
игра
начальник
сей
рост
ехать
международный
тема
принцип
дорогой
попасть
десять
начаться
верить
метод
тип
фильм
небольшой
держать
либо
позволять
край
местный
менее
гость
купить
уходить
собираться
воздух
туда
относиться
бывший
требовать
характер
борьба
использование
кстати
подойти
размер
удаться
образование
получать
мальчик
кровь
район
небо
американский
армия
класс
представитель
участие
девочка
политика
сначала
герой
картина
широкий
доллар
спина
территория
мировой
пол
тяжелый
довольно
поле
ж
изменение
умереть
более
направление
рисунок
течение
возможный
церковь
банк
отдельный
средний
красивый
сцена
население
большинство
сесть
двадцать
случиться
музыка
короткий
правда
проходить
составлять
свобода
память
приходиться
причем
команда
установить
союз
будто
поднять
врач
серьезный
договор
стараться
уметь
встать
дерево
интересный
факт
добрый
всего
хозяин
национальный
однажды
длинный
природа
домой
страшный
прошлый
будто
общественный
угол
чтоб
телефон
позиция
проводить
скоро
наиболее
двор
обычно
бросить
разве
писатель
самолет
объем
далеко
род
солнце
вера
берег
спектакль
фирма
способ
завод
цвет
трудно
журнал
руководитель
специалист
возможно
детский
точно
объяснить
оценка
единый
снять
определенный
низкий
нравиться
услышать
регион
связать
песня
процент
родитель
позволить
чужой
море
странный
требование
чистый
весьма
какой-нибудь
основание
половина
поехать
положить
входить
легко
поздний
роман
круг
анализ
стихи
автомобиль
специальный
экономика
литература
бумага
вместо
впервые
видно
научный
оказываться
поэт
показывать
степень
вызвать
касаться
господин
надежда
сложный
вокруг
предмет
отметить
заявить
вариант
министр
откуда
реальный
граница
действовать
дух
модель
операция
пара
сон
немного
название
ум
повод
старик
способный
мало
миллион
малый
старший
успех
практически
получиться
личный
счастье
необходимо
свободный
ребята
обычный
кабинет
прекрасный
высший
кричать
прежде
магазин
пространство
выход
остановиться
удар
база
знание
текст
сюда
темный
защита
предлагать
руководство
вовсе
площадь
сознание
гражданский
убить
возраст
молчать
согласиться
участник
участок
рано
пункт
несмотря
сильно
столь
сообщить
линия
бежать
желание
папа
кажется
петь
доктор
губа
известно
дома
вызывать
дочь
показаться
среда
председатель
представление
солдат
художник
принести
волос
оружие
выглядеть
соответствие
никак
ветер
внешний
парень
служить
зрение
попросить
генерал
состоять
огонь
отдать
боевой
понятие
строительство
ухо
выступать
грудь
нос
ставить
завтра
возникать
когда
страх
услуга
рабочий
что-нибудь
глубокий
содержание
радость
безопасность
надеяться
продукт
видимо
комплекс
бизнес
подняться
вспоминать
мало
сад
долгий
одновременно
называться
сотрудник
лето
тихо
зато
прямой
курс
помогать
предложение
финансовый
открытый
почему-то
значить
возникнуть
рот
где-то
технология
знакомый
недавно
реформа
отсутствие
нынешний
собака
камень
будущее
звать
рассказ
контроль
позвонить
река
хватать
продукция
сумма
техника
исторический
вновь
народный
прямо
ибо
выпить
здание
сфера
знаменитый
иначе
потерять
необходимость
больший
фонд
иметься
вперед
подготовка
вчера
лист
пустой
очередной
республика
хозяйство
полностью
получаться
учиться
плохо
воля
судебный
бюджет
возвращаться
расти
снег
деревня
обнаружить
мужик
постоянно
зеленый
элемент
обстоятельство
почувствовать
немец
многое
победа
источник
немецкий
золотой
передать
технический
нормальный
едва
желать
ожидать
некий
звезда
городской
выбор
соответствующий
масса
составить
итог
сестра
что
шесть
ясно
практика
сто
нести
определить
проведение
карман
любимый
родной
западный
обязательно
слава
кухня
определение
пользоваться
быстрый
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
фрак
тина
зонт
некроз
норка
порка
фигня
понт
пони
рокот
топот
потоп
жакет
гопак
колпак
сено
навес
воск
хакер
рокер
срам
меркурий
венера
марс
юпитер
сатурн
уран
нептун
плутон
резка
резак
пилон
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -0,0 +1,26 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Integer
ID = "2026-07-02T12:57:47:552419"
VERSION = "1.34.0"
DESCRIPTION = ""
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
)
manager.alter_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="number",
db_column_name="number",
params={"unique": True},
old_params={"unique": False},
column_class=Integer,
old_column_class=Integer,
schema=None,
)
return manager
+1 -1
View File
@@ -12,4 +12,4 @@ class Puzzle(Table):
solutions = JSON()
extra = JSON()
definitions = JSON()
number = Integer()
number = Integer(unique=True)
+22850 -3652
View File
File diff suppressed because it is too large Load Diff
+34 -69
View File
@@ -10,6 +10,8 @@ from typing import Annotated
from fastapi import Depends, FastAPI, Request
from zangramru.utils.trie import TrieNode
CWD = Path(__file__).parent
# Weighted toward common Russian letters
@@ -42,61 +44,6 @@ class Puzzle:
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."""
@@ -113,7 +60,7 @@ class PuzzleGenerator:
self.words[word] = row["def"]
self.trie.add(word)
def find_solutions( # noqa: C901
def _find_solutions( # noqa: C901
self, board: list[list[str]]
) -> dict[str, list[tuple[int, int]]]:
"""
@@ -258,7 +205,7 @@ class PuzzleGenerator:
return cover, extras
def get_valid_points(
def _get_valid_points(
self,
solutions: dict[str, list[tuple[int, int]]],
) -> set[tuple[int, int]]:
@@ -269,6 +216,32 @@ class PuzzleGenerator:
return used_cells
def _verify_solution(
self, board: list[list[str]], target: int
) -> tuple[set[tuple[int, int]], Puzzle | None]:
solutions = self._find_solutions(board)
cover, extra = self._find_minimal_cover(solutions, target)
valid_points = self._get_valid_points(cover)
# We found a valid solution | board combination
if len(valid_points) != 16 or len(cover) < target:
return valid_points, None
word_defs = {word: self.words[word] for word in cover | extra}
return valid_points, Puzzle(
board=board,
solutions=cover,
extra=extra,
word_defs=word_defs,
)
def check_board(self, compressed_board: str, target: int = 10) -> Puzzle | None:
"""Check if given board is valid."""
board = [list(compressed_board[i * 4 : i * 4 + 4]) for i in range(4)]
_, puzzle = self._verify_solution(board, target)
return puzzle
def generate(self, target: int, timeout: int = 10) -> Puzzle:
"""
Generate a valid puzzle that has solutions.
@@ -289,27 +262,19 @@ class PuzzleGenerator:
)
if single_board_attempt > 50:
valid_points = set()
single_board_attempt = 0
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:
valid_points, puzzle = self._verify_solution(board, target)
if not puzzle:
single_board_attempt += 1
continue
return Puzzle(
board=board,
solutions=cover,
extra=extra,
word_defs=word_defs,
)
return puzzle
def setup_generator(app: FastAPI) -> None:
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File
+56
View File
@@ -0,0 +1,56 @@
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
self.definitions: list[str] = []
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, definitions: list[str] | None = None) -> None:
"""Add a word to the trie."""
word = word.lower().strip().replace("ё", "е") # noqa: RUF001
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
if definitions:
node.definitions.extend(definitions)
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().strip().replace("ё", "е") # noqa: RUF001
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
+19 -5
View File
@@ -1,3 +1,4 @@
import asyncio
import json
from datetime import date, timedelta
@@ -15,9 +16,20 @@ router = APIRouter()
@router.get("/random")
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
def random_puzzle(
generator: PuzzleGeneratorDep,
board: str | None = None,
) -> PuzzleOutputDTO:
"""Generate a random puzzle."""
puzzle = generator.generate(target=10)
if board:
puzzle = generator.check_board(board, target=10)
if puzzle is None:
raise HTTPException(
status_code=status.HTTP_421_MISDIRECTED_REQUEST,
detail="Given board has no solution",
)
else:
puzzle = generator.generate(10)
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
@@ -30,7 +42,7 @@ async def daily_puzzle(
# Because they start at one, so we need to subtract one.
target_date = date.today()
if number is not None:
target_date = settings.start_date + timedelta(days=number - 1)
target_date = settings.start_date + timedelta(days=number)
if target_date > date.today():
raise HTTPException(
@@ -45,13 +57,15 @@ async def daily_puzzle(
if daily_puzzle is not None:
board = [list(daily_puzzle.board[i * 4 : i * 4 + 4]) for i in range(4)]
return DailyPuzzleDTO(
number=(date.today() - settings.start_date).days,
number=daily_puzzle.number,
board=board,
solutions=json.loads(daily_puzzle.solutions),
extra=json.loads(daily_puzzle.extra),
word_defs=json.loads(daily_puzzle.definitions),
)
puzzle = generator.generate(target=10)
loop = asyncio.get_running_loop()
puzzle = await loop.run_in_executor(None, generator.generate, 10)
compressedboard = "".join("".join(row) for row in puzzle.board)
await Puzzle(
{