9 Commits

Author SHA1 Message Date
s3rius 55b4089e43 Updated dictionary according to 10_000 most fequent words form НКРЯ.
/ docker_build (push) Successful in 1m33s
/ helm_deploy (push) Successful in 35s
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 51s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 11:15:04 +02:00
s3rius 5355159e49 Updated dictionary.
Testing zangramru / lint (push) Successful in 30s
Testing zangramru / pytest (push) Failing after 51s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 04:05:25 +02:00
s3rius a34d63aebe Fixed finding words.
/ docker_build (push) Successful in 1m31s
/ helm_deploy (push) Successful in 26s
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 03:42:44 +02:00
s3rius 039d206ba0 Added og meta tags.
/ docker_build (push) Successful in 1m24s
/ helm_deploy (push) Successful in 35s
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 46s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 03:18:53 +02:00
s3rius 12b0eb08b4 added support for daily puzzles.
/ docker_build (push) Successful in 5m42s
/ helm_deploy (push) Successful in 49s
Testing zangramru / lint (push) Successful in 33s
Testing zangramru / pytest (push) Failing after 22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 02:57:09 +02:00
s3rius f1d2b0f6c8 Made logs always show up.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 49s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 14:28:14 +02:00
s3rius da37f240b1 Added logs.
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 50s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 13:59:18 +02:00
s3rius ef1d94cf68 Fixed pytest.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 49s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 13:56:32 +02:00
s3rius 4306676699 Simplified workflows.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 16s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 13:42:32 +02:00
17 changed files with 5015 additions and 17864 deletions
+1
View File
@@ -1 +1,2 @@
**/*.csv filter=lfs diff=lfs merge=lfs -text
**/*.png filter=lfs diff=lfs merge=lfs -text
+1
View File
@@ -25,6 +25,7 @@ jobs:
push: true
file: ./Dockerfile
platforms: linux/amd64
target: prod
tags: |
gitea.le-memese.com/s3rius/zangramru:latest
gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }}
+5 -10
View File
@@ -4,12 +4,6 @@ on: push
jobs:
lint:
strategy:
matrix:
cmd:
- ruff-format
- ruff
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -22,13 +16,11 @@ jobs:
- name: Install deps
run: uv sync
- name: Run lint check
run: uv run pre-commit run -a ${{ matrix.cmd }}
run: uv run pre-commit run -a
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create .env
run: touch .env
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -38,4 +30,7 @@ jobs:
with:
version: "2.28.0"
- name: run tests
run: docker-compose run --rm api pytest -vv
run: docker compose run --build --rm api pytest -vv
- name: show logs
if: 'always()'
run: docker compose logs
+8
View File
@@ -31,3 +31,11 @@ RUN --mount=from=uv,source=/usr/local/bin/uv,target=/bin/uv \
uv sync --locked --no-dev
CMD ["/usr/local/bin/python", "-m", "zangramru"]
FROM prod AS dev
RUN --mount=from=uv,source=/usr/local/bin/uv,target=/bin/uv \
--mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-groups
+4 -2
View File
@@ -10,6 +10,7 @@ from piccolo.table import create_tables, drop_tables
from zangramru.settings import settings
from zangramru.web.application import get_app
from zangramru.web.lifespan import lifespan_setup
@pytest.fixture(scope="session")
@@ -73,14 +74,15 @@ async def setup_db() -> AsyncGenerator[None, None]:
@pytest.fixture
def fastapi_app() -> FastAPI:
async def fastapi_app() -> AsyncGenerator[FastAPI, None]:
"""
Fixture for creating FastAPI app.
:return: fastapi app with mocked dependencies.
"""
application = get_app()
return application # noqa: RET504
async with lifespan_setup(application):
yield application
@pytest.fixture
-19
View File
@@ -1,19 +0,0 @@
import uuid
from fastapi import FastAPI
from httpx import AsyncClient
from starlette import status
async def test_echo(fastapi_app: FastAPI, client: AsyncClient) -> None:
"""
Tests that echo route works.
:param fastapi_app: current application.
:param client: client for the app.
"""
url = fastapi_app.url_path_for("send_echo_message")
message = uuid.uuid4().hex
response = await client.post(url, json={"message": message})
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == message
+20
View File
@@ -0,0 +1,20 @@
from fastapi import FastAPI
from httpx import AsyncClient
from starlette import status
from zangramru.web.api.puzzles.schema import PuzzleOutputDTO
async def test_get_random_puzzle(fastapi_app: FastAPI, client: AsyncClient) -> None:
"""
Tests that echo route works.
:param fastapi_app: current application.
:param client: client for the app.
"""
url = fastapi_app.url_path_for("random_puzzle")
response = await client.get(url)
assert response.status_code == status.HTTP_200_OK
dto = PuzzleOutputDTO.model_validate(response.json())
assert sum(len(row) for row in dto.board) == 16
assert len(dto.solutions) >= 10
+1
View File
@@ -12,6 +12,7 @@ env:
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
ZANGRAMRU_DB_USER: zangram
ZANGRAMRU_DB_BASE: zangram
ZANGRAMRU_START_DATE: "2026-07-01"
probes:
liveness:
@@ -0,0 +1,44 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Integer
from piccolo.columns.indexes import IndexMethod
ID = "2026-07-01T21:56:16:112157"
VERSION = "1.34.0"
DESCRIPTION = ""
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
)
manager.drop_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="date",
db_column_name="date",
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="number",
db_column_name="number",
column_class_name="Integer",
column_class=Integer,
params={
"default": 0,
"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
+5 -3
View File
@@ -1,13 +1,15 @@
from piccolo.columns import JSON, UUID, Date, Varchar
import uuid
from piccolo.columns import JSON, UUID, Integer, Varchar
from piccolo.table import Table
class Puzzle(Table):
"""Table for puzzles."""
id = UUID(primary_key=True)
id = UUID(primary_key=True, default=uuid.uuid4)
board = Varchar(length=16)
solutions = JSON()
extra = JSON()
definitions = JSON()
date = Date()
number = Integer()
+4517 -17463
View File
File diff suppressed because it is too large Load Diff
+18 -10
View File
@@ -1,7 +1,9 @@
import csv
import datetime
import secrets
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from pprint import pformat
from typing import Annotated
@@ -107,7 +109,7 @@ class PuzzleGenerator:
with dict_path.open("r") as f:
rows = csv.DictReader(f, delimiter="|")
for row in rows:
word = row["word"]
word = row["word"].lower().replace("ё", "е") # noqa: RUF001
self.words[word] = row["def"]
self.trie.add(word)
@@ -139,7 +141,10 @@ class PuzzleGenerator:
while paths:
path, trie_node = paths.pop()
if trie_node.valid_word and len(trie_node.value) >= 4:
solutions[trie_node.value] = path
word = trie_node.value
if word in solutions:
return {}
solutions[word] = path
for dx, dy in (
(1, 1),
(-1, -1),
@@ -234,6 +239,7 @@ class PuzzleGenerator:
prev = path[0]
for tile in path[1:]:
neigbours[prev].add(tile)
neigbours[tile].add(prev)
prev = tile
extras = {w: p for w, p in solutions.items() if w not in cover}
@@ -263,20 +269,24 @@ class PuzzleGenerator:
return used_cells
def generate(self, target: int, max_attempts: int) -> Puzzle | None:
def generate(self, target: int, timeout: int = 10) -> Puzzle:
"""
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.
:param timeout: since puzzles can fail to generate sometimes,
we use timeout to not let the puzzle generator hung forever!
: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):
started = datetime.datetime.now()
while True:
if datetime.datetime.now() - started > timedelta(seconds=timeout):
raise ValueError(
f"Coud not find a valid solution in {timeout} seconds!"
)
if single_board_attempt > 50:
valid_points = set()
@@ -301,8 +311,6 @@ class PuzzleGenerator:
word_defs=word_defs,
)
return None
def setup_generator(app: FastAPI) -> None:
"""Create puzzle-generator."""
@@ -319,7 +327,7 @@ PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]
if __name__ == "__main__":
generator = PuzzleGenerator(CWD / "dict.csv")
res = generator.generate(target=10, max_attempts=200)
res = generator.generate(target=10)
if res is None:
print("No solution found!") # noqa: T201
else:
+4
View File
@@ -1,4 +1,5 @@
import enum
from datetime import date
from pathlib import Path
from tempfile import gettempdir
@@ -46,6 +47,9 @@ class Settings(BaseSettings):
db_base: str = "zangramru"
db_echo: bool = False
# When first daily puzzle was generated.
start_date: date = date(2026, 7, 1)
@property
def db_url(self) -> URL:
"""
+317 -350
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Занграммы</title>
<meta property="og:title" content="Занграммы">
<meta property="og:description" content="Игра со словами — находи слова на сетке">
<meta property="og:image" content="/preview.png">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Занграммы">
<meta name="twitter:description" content="Игра со словами — находи слова на сетке">
<meta name="twitter:image" content="/preview.png">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -40,17 +48,17 @@
font-size: 2rem;
font-weight: bold;
color: #fff;
min-height: 3rem;
height: 3rem;
margin-bottom: 0.5rem;
letter-spacing: 0.3em;
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: background 0.2s;
display: inline-block;
text-align: center;
}
#wordDisplay:hover {
background: rgba(255,255,255,0.05);
}
#wordDisplay:hover { background: rgba(255,255,255,0.05); }
#message {
min-height: 1.5rem;
margin-bottom: 0.5rem;
@@ -65,11 +73,7 @@
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
#found {
margin-top: 1.5rem;
max-width: 500px;
width: 100%;
}
#found { margin-top: 1.5rem; max-width: 500px; width: 100%; }
#found h2 { margin-bottom: 0.5rem; font-size: 1.1rem; }
.word-item {
background: #16213e;
@@ -80,28 +84,11 @@
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 {
.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; }
.modal {
display: none;
position: fixed;
inset: 0;
@@ -110,66 +97,81 @@
justify-content: center;
align-items: center;
}
#gameComplete.show {
display: flex;
}
#gameComplete .popup {
.modal.show { display: flex; }
.modal .popup {
background: #16213e;
border-radius: 16px;
padding: 2rem 3rem;
text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
max-width: 400px;
}
#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;
.modal .popup h2 { color: #fff; font-size: 1.5rem; margin-bottom: 0.5rem; }
.modal .popup .time { color: #2ed573; font-size: 2rem; font-weight: bold; margin: 1rem 0; }
.modal .popup .buttons { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1.5rem; }
.modal .popup .buttons button { width: 100%; margin-top: 0; }
#numberSelector { background: rgba(0,0,0,0.5); z-index: 101; }
#numberSelector .popup { padding: 1.5rem 2rem; max-height: 70vh; overflow-y: auto; }
#numberSelector .popup h3 { color: #fff; margin-bottom: 1rem; text-align: center; }
#numberSelector .number-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
#numberSelector .number-grid button { padding: 0.5rem; font-size: 0.9rem; background: #0f3460; }
#numberSelector .number-grid button:hover { background: #1a4a80; }
#numberSelector .number-grid button.solved { background: #2ed573; color: #000; }
#numberSelector .number-grid button.current { border: 2px solid #feca57; }
#numberSelector .popup .close-btn { margin-top: 1rem; width: 100%; }
</style>
</head>
<body>
<h1>Занграммы</h1>
<div class="controls">
<span id="status">Загрузка...</span>
</div>
<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">
<!-- Game complete modal -->
<div id="gameComplete" class="modal">
<div class="popup">
<h2>Вы нашли все слова! Ваше время:</h2>
<div class="time" id="gameTime"></div>
<div class="buttons">
<button id="copyResultBtn">Скопировать результат</button>
<button id="prevPuzzlesBtn">Предыдущие занграммы</button>
<button id="randomPuzzleBtn">Случайная занграмма</button>
</div>
</div>
</div>
<!-- Number selector modal -->
<div id="numberSelector" class="modal">
<div class="popup">
<h3>Выберите занграмму</h3>
<div class="number-grid" id="numberGrid"></div>
<button class="close-btn" id="closeNumberBtn">Закрыть</button>
</div>
</div>
<script>
const canvas = document.getElementById('board');
(() => {
// ===================== DOM =====================
const $ = id => document.getElementById(id);
const canvas = $('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 statusEl = $('status');
const wordDisplay = $('wordDisplay');
const msgEl = $('message');
const foundDiv = $('found');
const gameCompleteEl = $('gameComplete');
const gameTimeEl = $('gameTime');
const numberSelectorEl = $('numberSelector');
const numberGridEl = $('numberGrid');
// ===================== Constants =====================
const CELL = 70, GAP = 20, GRID = 4, CELL_PITCH = CELL + GAP;
const PALETTE = [
'#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff',
'#5f27cd','#01a3a4','#f368e0','#ff6348','#7bed9f',
@@ -177,42 +179,30 @@
'#eccc68','#a4b0be','#ff7979','#badc58','#dff9fb'
];
// ===================== State =====================
let board = null;
let allSolutions = {};
let extraWords = {};
let wordDefs = {};
let foundWords = new Set();
let foundPaths = {};
let word_defs = {};
let selected = [];
let flashTimer = null;
let startTime = null;
let puzzleNumber = null;
let elapsed = 0;
let lastSaveTime = 0;
let timerInterval = null;
let OFFSET_X, OFFSET_Y;
// ===================== Helpers =====================
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;
const s = Math.floor(ms / 1000);
return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
}
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 cellKey(x, y) { return x + ',' + y; }
function resetTimer() {
if (timerInterval) clearInterval(timerInterval);
timerInterval = null;
startTime = null;
function cellCenter(x, y) {
return { x: OFFSET_X + x * CELL_PITCH + CELL / 2, y: OFFSET_Y + y * CELL_PITCH + CELL / 2 };
}
function initBoard() {
@@ -222,46 +212,11 @@
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]));
}
for (const c of path) cells.add(cellKey(c[0], c[1]));
}
return cells;
}
@@ -281,96 +236,118 @@
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;
const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
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.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
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 = Math.min(255, Math.max(0, Math.round(R * (100 + percent) / 100)));
G = Math.min(255, Math.max(0, Math.round(G * (100 + percent) / 100)));
B = Math.min(255, Math.max(0, Math.round(B * (100 + percent) / 100)));
return '#' + R.toString(16).padStart(2, '0') + G.toString(16).padStart(2, '0') + B.toString(16).padStart(2, '0');
}
function getWordColor(word) {
let h = 0;
for (let i = 0; i < word.length; i++) h = word.charCodeAt(i) + ((h << 5) - h);
return PALETTE[Math.abs(h) % PALETTE.length];
}
function boardsEqual(a, b) {
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].length !== b[i].length) return false;
for (let j = 0; j < a[i].length; j++) {
if (a[i][j] !== b[i][j]) return false;
}
}
return true;
}
// ===================== Persistence =====================
function save() {
if (puzzleNumber === null) return;
elapsed += Date.now() - lastSaveTime;
lastSaveTime = Date.now();
const state = { foundWords: Array.from(foundWords), elapsed, board, allSolutions, extraWords, wordDefs };
localStorage.setItem('puzzle_' + puzzleNumber, JSON.stringify(state));
}
function load() {
if (puzzleNumber === null) return null;
const raw = localStorage.getItem('puzzle_' + puzzleNumber);
if (!raw) return null;
const state = JSON.parse(raw);
lastSaveTime = Date.now();
return state;
}
function clear() {
if (puzzleNumber === null) return;
localStorage.removeItem('puzzle_' + puzzleNumber);
}
function getSolvedNumbers() {
const result = [];
for (let i = 0; i < 1000; i++) {
if (localStorage.getItem('puzzle_' + i)) result.push(i);
}
return result;
}
// ===================== Rendering =====================
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.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);
const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
}
ctx.stroke();
ctx.globalAlpha = 1;
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';
if (selected.length > 1) drawPath(selected, '#fff', GAP - 2, 0.8);
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 cx = OFFSET_X + x * CELL_PITCH, cy = OFFSET_Y + y * CELL_PITCH;
const key = cellKey(x, y);
const isVisible = activeCells.has(key);
const isSel = selSet.has(key);
const isVisible = activeCells.has(key), 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();
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.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.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('');
}
@@ -378,110 +355,51 @@
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.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);
const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
}
ctx.stroke();
ctx.globalAlpha = 1;
// Draw selected path
if (selected.length > 1) {
drawPath(selected, color, GAP - 2, 0.9);
}
// Draw cells with flash tint
ctx.stroke(); ctx.globalAlpha = 1;
if (selected.length > 1) drawPath(selected, color, GAP - 2, 0.9);
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 cx = OFFSET_X + x * CELL_PITCH, 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();
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.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.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];
flashTimer = setTimeout(() => { selected = []; draw(); }, 500);
}
function renderFound() {
foundDiv.innerHTML = '<h2>Найдено (' + foundWords.size + ')</h2>';
for (const word of foundWords) {
const color = getWordColor(word);
const def = word_defs[word] || '';
const def = wordDefs[word] || '';
const isExtra = word in extraWords;
const item = document.createElement('div');
item.className = 'word-item';
item.style.opacity = isExtra ? '0.6' : '1';
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>' : '');
@@ -495,159 +413,208 @@
}
function showMessage(text, color) {
msgEl.textContent = text;
msgEl.style.color = color || '#feca57';
msgEl.textContent = text; msgEl.style.color = color || '#feca57';
setTimeout(() => { msgEl.textContent = ''; }, 2000);
}
// ===================== Game Logic =====================
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;
selected = []; draw(); return;
}
// Valid solution word
if (word in allSolutions) {
foundWords.add(word);
foundPaths[word] = allSolutions[word];
flashPath(allSolutions[word], '#2ed573');
renderFound();
updateStatus();
return;
renderFound(); updateStatus(); save(); return;
}
// Valid extra word
if (word in extraWords) {
foundWords.add(word);
foundPaths[word] = extraWords[word];
flashPath(extraWords[word], '#feca57');
renderFound();
showMessage('Бонус!', '#feca57');
return;
renderFound(); showMessage('Бонус!', '#feca57'); save(); 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;
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;
}
if (selected.length < 4) { showMessage('Мало букв — нужно минимум 4', '#ff6b6b'); selected = []; draw(); return; }
if (!(word in allSolutions) && !(word in extraWords)) { showMessage('Слово не найдено!', '#ff6b6b'); selected = []; draw(); return; }
showMessage('Верно!', '#2ed573');
}
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]), kb = cellKey(b[0], b[1]);
const key = ka < kb ? ka + '-' + kb : kb + '-' + ka;
return getActiveConnections().has(key);
}
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 (idx >= 0) { selected = selected.slice(0, idx); draw(); return true; }
if (selected.length > 0) {
if (!isAdjacentAndConnected(selected[selected.length - 1], [col, row])) return false;
} else {
if (!getActiveCells().has(cellKey(col, row))) return false;
}
selected.push([col, row]);
draw();
checkWord();
return true;
draw(); checkWord(); return true;
}
let _pointerDown = false;
// ===================== Timer =====================
function startTimer() {
if (timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(() => {
const remaining = Object.keys(allSolutions).filter(w => !foundWords.has(w)).length;
if (remaining === 0) {
clearInterval(timerInterval); timerInterval = null;
gameTimeEl.textContent = formatTime(elapsed);
gameCompleteEl.classList.add('show');
}
}, 500);
}
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
_pointerDown = true;
const r = canvas.getBoundingClientRect();
handleCellClick(e.clientX - r.left, e.clientY - r.top);
});
function resetTimer() {
if (timerInterval) clearInterval(timerInterval); timerInterval = null;
elapsed = 0;
lastSaveTime = Date.now();
}
canvas.addEventListener('pointerup', (e) => {
_pointerDown = false;
});
canvas.addEventListener('pointercancel', (e) => {
e.preventDefault();
_pointerDown = false;
selected = [];
draw();
});
async function loadPuzzle() {
// ===================== Load Puzzle =====================
async function loadPuzzle(number) {
statusEl.textContent = 'Генерация...';
gameCompleteEl.classList.remove('show');
numberSelectorEl.classList.remove('show');
resetTimer();
try {
let data;
if (number != null) {
const res = await fetch('/api/puzzles/daily?number=' + number);
if (res.status === 404) { statusEl.textContent = 'Занграмма не найдена'; return; }
data = await res.json();
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
puzzleNumber = data.number;
} else {
const res = await fetch('/api/puzzles/daily');
data = await res.json();
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
puzzleNumber = data.number;
}
const raw = localStorage.getItem('puzzle_' + data.number);
if (raw) {
const parsed = JSON.parse(raw);
foundWords = new Set(parsed.foundWords);
elapsed = parsed.elapsed || 0;
lastSaveTime = Date.now();
statusEl.textContent = 'Возобновление...';
} else {
foundWords = new Set();
}
board = data.board;
allSolutions = data.solutions || {};
extraWords = data.extra || {};
wordDefs = data.word_defs || {};
selected = [];
initBoard(); draw(); renderFound(); updateStatus();
if (foundWords.size > 0 && foundWords.size === Object.keys(allSolutions).length) {
gameTimeEl.textContent = formatTime(elapsed);
gameCompleteEl.classList.add('show');
return;
}
startTimer();
}
// ===================== Random Puzzle =====================
async function loadRandomPuzzle() {
gameCompleteEl.classList.remove('show');
statusEl.textContent = 'Генерация...';
resetTimer();
puzzleNumber = null;
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();
board = data.board;
allSolutions = data.solutions || {};
extraWords = data.extra || {};
wordDefs = data.word_defs || {};
selected = [];
initBoard(); draw(); renderFound(); updateStatus();
startTimer();
} catch (e) {
statusEl.textContent = 'Не удалось загрузить головоломку';
}
}
// ===================== Number Selector =====================
async function showNumberSelector() {
numberGridEl.innerHTML = '';
const solved = getSolvedNumbers();
try {
const res = await fetch('/api/puzzles/daily');
const data = await res.json();
if (data.error) return;
const maxNum = data.number || 0;
for (let i = maxNum; i >= 1; i--) {
const btn = document.createElement('button');
btn.textContent = i;
if (solved.includes(i)) btn.classList.add('solved');
if (i === puzzleNumber) btn.classList.add('current');
btn.addEventListener('click', () => loadPuzzle(i));
numberGridEl.appendChild(btn);
}
} catch (e) {}
numberSelectorEl.classList.add('show');
}
// ===================== Copy Result =====================
function copyResult() {
const text = 'Занграммы #' + puzzleNumber + '\n\ud83c\udf89 Мне потребовалось всего лишь ' + formatTime(elapsed) + '\n\n' + window.location.href;
navigator.clipboard.writeText(text).then(
() => showMessage('Результат скопирован!', '#2ed573'),
() => showMessage('Не удалось скопировать', '#ff6b6b')
);
}
// ===================== Events =====================
wordDisplay.addEventListener('click', validateWord);
$('copyResultBtn').addEventListener('click', copyResult);
$('prevPuzzlesBtn').addEventListener('click', showNumberSelector);
$('randomPuzzleBtn').addEventListener('click', loadRandomPuzzle);
$('closeNumberBtn').addEventListener('click', () => numberSelectorEl.classList.remove('show'));
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
handleCellClick(e.clientX - r.left, e.clientY - r.top);
});
canvas.addEventListener('pointerup', () => {});
canvas.addEventListener('pointercancel', (e) => {
e.preventDefault();
selected = []; draw();
});
// ===================== Init =====================
loadPuzzle();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+6
View File
@@ -8,3 +8,9 @@ class PuzzleOutputDTO(BaseModel):
solutions: dict[str, list[tuple[int, int]]]
extra: dict[str, list[tuple[int, int]]]
word_defs: dict[str, str]
class DailyPuzzleDTO(PuzzleOutputDTO):
"""Daily puzzles DTO."""
number: int
+60 -3
View File
@@ -1,8 +1,15 @@
from fastapi import APIRouter
import json
from datetime import date, timedelta
from fastapi import APIRouter, HTTPException
from pydantic import PositiveInt
from starlette import status
from zangramru.db.models.puzzle import Puzzle
from zangramru.services.puzzles import PuzzleGeneratorDep
from zangramru.settings import settings
from .schema import PuzzleOutputDTO
from .schema import DailyPuzzleDTO, PuzzleOutputDTO
router = APIRouter()
@@ -10,5 +17,55 @@ router = APIRouter()
@router.get("/random")
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
"""Generate a random puzzle."""
puzzle = generator.generate(target=10, max_attempts=500)
puzzle = generator.generate(target=10)
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
@router.get("/daily")
async def daily_puzzle(
generator: PuzzleGeneratorDep,
number: PositiveInt | None = None,
) -> DailyPuzzleDTO:
"""Generate a random 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)
if target_date > date.today():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Doesn't exist",
)
number = (target_date - settings.start_date).days
daily_puzzle = await Puzzle.objects().where(Puzzle.number == number).first()
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,
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)
compressedboard = "".join("".join(row) for row in puzzle.board)
await Puzzle(
{
Puzzle.board: compressedboard,
Puzzle.number: number,
Puzzle.solutions: json.dumps(puzzle.solutions),
Puzzle.definitions: json.dumps(puzzle.word_defs),
Puzzle.extra: json.dumps(puzzle.extra),
}
).save()
return DailyPuzzleDTO(
number=number,
board=puzzle.board,
solutions=puzzle.solutions,
extra=puzzle.extra,
word_defs=puzzle.word_defs,
)