added support for daily puzzles.
Testing zangramru / lint (push) Failing after 29s
Testing zangramru / pytest (push) Failing after 51s

Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
This commit is contained in:
2026-07-01 20:38:15 +02:00
parent f1d2b0f6c8
commit 9c69afe7be
9 changed files with 17688 additions and 17837 deletions
+1
View File
@@ -12,6 +12,7 @@ env:
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
ZANGRAMRU_DB_USER: zangram ZANGRAMRU_DB_USER: zangram
ZANGRAMRU_DB_BASE: zangram ZANGRAMRU_DB_BASE: zangram
ZANGRAMRU_START_DATE: "2026-07-01"
probes: probes:
liveness: 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 from piccolo.table import Table
class Puzzle(Table): class Puzzle(Table):
"""Table for puzzles.""" """Table for puzzles."""
id = UUID(primary_key=True) id = UUID(primary_key=True, default=uuid.uuid4)
board = Varchar(length=16) board = Varchar(length=16)
solutions = JSON() solutions = JSON()
extra = JSON() extra = JSON()
definitions = JSON() definitions = JSON()
date = Date() number = Integer()
+17242 -17469
View File
File diff suppressed because it is too large Load Diff
+16 -9
View File
@@ -1,4 +1,6 @@
import csv import csv
from datetime import time, timedelta
import datetime
import secrets import secrets
from collections import defaultdict, deque from collections import defaultdict, deque
from dataclasses import dataclass from dataclasses import dataclass
@@ -107,7 +109,7 @@ class PuzzleGenerator:
with dict_path.open("r") as f: with dict_path.open("r") as f:
rows = csv.DictReader(f, delimiter="|") rows = csv.DictReader(f, delimiter="|")
for row in rows: for row in rows:
word = row["word"] word = row["word"].lower().replace("ё", "е")
self.words[word] = row["def"] self.words[word] = row["def"]
self.trie.add(word) self.trie.add(word)
@@ -139,7 +141,10 @@ class PuzzleGenerator:
while paths: while paths:
path, trie_node = paths.pop() path, trie_node = paths.pop()
if trie_node.valid_word and len(trie_node.value) >= 4: 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 ( for dx, dy in (
(1, 1), (1, 1),
(-1, -1), (-1, -1),
@@ -263,20 +268,24 @@ class PuzzleGenerator:
return used_cells 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. Generate a valid puzzle that has solutions.
:param target: minimum number of words required to solve the puzzle. :param target: minimum number of words required to solve the puzzle.
:param max_attempts: Since sometimes we might fail to generate a correct :param timeout: since puzzles can fail to generate sometimes,
puzzle. We try it multiple times, this parameters limits how we use timeout to not let the puzzle generator hung forever!
many attempts we should have before giving up.
:return: puzzle. :return: puzzle.
""" """
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)] board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
valid_points: set[tuple[int, int]] = set() valid_points: set[tuple[int, int]] = set()
single_board_attempt = 0 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: if single_board_attempt > 50:
valid_points = set() valid_points = set()
@@ -301,8 +310,6 @@ class PuzzleGenerator:
word_defs=word_defs, word_defs=word_defs,
) )
return None
def setup_generator(app: FastAPI) -> None: def setup_generator(app: FastAPI) -> None:
"""Create puzzle-generator.""" """Create puzzle-generator."""
+4
View File
@@ -1,3 +1,4 @@
from datetime import date
import enum import enum
from pathlib import Path from pathlib import Path
from tempfile import gettempdir from tempfile import gettempdir
@@ -46,6 +47,9 @@ class Settings(BaseSettings):
db_base: str = "zangramru" db_base: str = "zangramru"
db_echo: bool = False db_echo: bool = False
# When first daily puzzle was generated.
start_date: date = date(2026, 7, 1)
@property @property
def db_url(self) -> URL: def db_url(self) -> URL:
""" """
+310 -353
View File
@@ -48,9 +48,7 @@
border-radius: 8px; border-radius: 8px;
transition: background 0.2s; transition: background 0.2s;
} }
#wordDisplay:hover { #wordDisplay:hover { background: rgba(255,255,255,0.05); }
background: rgba(255,255,255,0.05);
}
#message { #message {
min-height: 1.5rem; min-height: 1.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -65,11 +63,7 @@
touch-action: none; touch-action: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
#found { #found { margin-top: 1.5rem; max-width: 500px; width: 100%; }
margin-top: 1.5rem;
max-width: 500px;
width: 100%;
}
#found h2 { margin-bottom: 0.5rem; font-size: 1.1rem; } #found h2 { margin-bottom: 0.5rem; font-size: 1.1rem; }
.word-item { .word-item {
background: #16213e; background: #16213e;
@@ -80,28 +74,11 @@
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.3rem;
} }
.word-item-inner { .word-item-inner { display: flex; align-items: center; gap: 0.5rem; }
display: flex; .word-color { width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; }
align-items: center; .word-text { font-weight: 700; font-size: 1.1rem; color: #fff; }
gap: 0.5rem; .word-def { color: #888; font-size: 0.9rem; padding-left: 1.5rem; }
} .modal {
.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; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -110,66 +87,81 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
#gameComplete.show { .modal.show { display: flex; }
display: flex; .modal .popup {
}
#gameComplete .popup {
background: #16213e; background: #16213e;
border-radius: 16px; border-radius: 16px;
padding: 2rem 3rem; padding: 2rem 3rem;
text-align: center; text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
max-width: 400px;
} }
#gameComplete .popup h2 { .modal .popup h2 { color: #fff; font-size: 1.5rem; margin-bottom: 0.5rem; }
color: #fff; .modal .popup .time { color: #2ed573; font-size: 2rem; font-weight: bold; margin: 1rem 0; }
font-size: 1.5rem; .modal .popup .buttons { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1.5rem; }
margin-bottom: 0.5rem; .modal .popup .buttons button { width: 100%; margin-top: 0; }
} #numberSelector { background: rgba(0,0,0,0.5); z-index: 101; }
#gameComplete .popup .time { #numberSelector .popup { padding: 1.5rem 2rem; max-height: 70vh; overflow-y: auto; }
color: #2ed573; #numberSelector .popup h3 { color: #fff; margin-bottom: 1rem; text-align: center; }
font-size: 2rem; #numberSelector .number-grid {
font-weight: bold; display: grid;
margin: 1rem 0; grid-template-columns: repeat(5, 1fr);
} gap: 0.5rem;
#gameComplete .popup button {
margin-top: 1rem;
padding: 0.7rem 2rem;
font-size: 1rem;
} }
#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> </style>
</head> </head>
<body> <body>
<h1>Занграммы</h1> <h1>Занграммы</h1>
<div class="controls"> <div class="controls"><span id="status">Загрузка...</span></div>
<span id="status">Загрузка...</span>
</div>
<div id="wordDisplay"></div> <div id="wordDisplay"></div>
<div id="message"></div> <div id="message"></div>
<canvas id="board"></canvas> <canvas id="board"></canvas>
<div id="found"></div> <div id="found"></div>
<div id="gameComplete">
<!-- Game complete modal -->
<div id="gameComplete" class="modal">
<div class="popup"> <div class="popup">
<h2>Вы нашли все слова! Ваше время:</h2> <h2>Вы нашли все слова! Ваше время:</h2>
<div class="time" id="gameTime"></div> <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>
</div> </div>
<script> <script>
const canvas = document.getElementById('board'); (() => {
// ===================== DOM =====================
const $ = id => document.getElementById(id);
const canvas = $('board');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status'); const statusEl = $('status');
const wordDisplay = document.getElementById('wordDisplay'); const wordDisplay = $('wordDisplay');
const msgEl = document.getElementById('message'); const msgEl = $('message');
const foundDiv = document.getElementById('found'); const foundDiv = $('found');
const gameCompleteEl = document.getElementById('gameComplete'); const gameCompleteEl = $('gameComplete');
const gameTimeEl = document.getElementById('gameTime'); const gameTimeEl = $('gameTime');
const numberSelectorEl = $('numberSelector');
const CELL = 70; const numberGridEl = $('numberGrid');
const GAP = 20;
const GRID = 4;
const CELL_PITCH = CELL + GAP;
let OFFSET_X, OFFSET_Y;
// ===================== Constants =====================
const CELL = 70, GAP = 20, GRID = 4, CELL_PITCH = CELL + GAP;
const PALETTE = [ const PALETTE = [
'#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff', '#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff',
'#5f27cd','#01a3a4','#f368e0','#ff6348','#7bed9f', '#5f27cd','#01a3a4','#f368e0','#ff6348','#7bed9f',
@@ -177,42 +169,30 @@
'#eccc68','#a4b0be','#ff7979','#badc58','#dff9fb' '#eccc68','#a4b0be','#ff7979','#badc58','#dff9fb'
]; ];
// ===================== State =====================
let board = null; let board = null;
let allSolutions = {}; let allSolutions = {};
let extraWords = {}; let extraWords = {};
let wordDefs = {};
let foundWords = new Set(); let foundWords = new Set();
let foundPaths = {};
let word_defs = {};
let selected = []; let selected = [];
let flashTimer = null; let flashTimer = null;
let startTime = null; let puzzleNumber = null;
let elapsed = 0;
let lastSaveTime = 0;
let timerInterval = null; let timerInterval = null;
let OFFSET_X, OFFSET_Y;
// ===================== Helpers =====================
function formatTime(ms) { function formatTime(ms) {
const totalSec = Math.floor(ms / 1000); const s = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60).toString().padStart(2, '0'); return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
const sec = (totalSec % 60).toString().padStart(2, '0');
return min + ':' + sec;
} }
function startTimer() { function cellKey(x, y) { return x + ',' + y; }
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() { function cellCenter(x, y) {
if (timerInterval) clearInterval(timerInterval); return { x: OFFSET_X + x * CELL_PITCH + CELL / 2, y: OFFSET_Y + y * CELL_PITCH + CELL / 2 };
timerInterval = null;
startTime = null;
} }
function initBoard() { function initBoard() {
@@ -222,46 +202,11 @@
OFFSET_Y = 40; 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() { function getActiveCells() {
const cells = new Set(); const cells = new Set();
for (const [word, path] of Object.entries(allSolutions)) { for (const [word, path] of Object.entries(allSolutions)) {
if (foundWords.has(word)) continue; if (foundWords.has(word)) continue;
for (const cell of path) { for (const c of path) cells.add(cellKey(c[0], c[1]));
cells.add(cellKey(cell[0], cell[1]));
}
} }
return cells; return cells;
} }
@@ -281,96 +226,118 @@
function drawPath(path, color, lineWidth, alpha) { function drawPath(path, color, lineWidth, alpha) {
if (path.length < 2) return; if (path.length < 2) return;
const t1 = CELL / (2 * CELL_PITCH); const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
const t2 = 1 - CELL / (2 * CELL_PITCH); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
const c1 = cellCenter(path[i][0], path[i][1]); const c1 = cellCenter(path[i][0], path[i][1]);
const c2 = cellCenter(path[i + 1][0], path[i + 1][1]); const c2 = cellCenter(path[i + 1][0], path[i + 1][1]);
const x1 = c1.x + (c2.x - c1.x) * t1; ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
const y1 = c1.y + (c2.y - c1.y) * t1; ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
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.stroke();
ctx.globalAlpha = 1; 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() { function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y))); const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
const activeCells = getActiveCells(); const activeCells = getActiveCells();
const activeConns = getActiveConnections(); 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(); ctx.beginPath();
for (const key of activeConns) { for (const key of activeConns) {
const [ka, kb] = key.split('-'); const [ka, kb] = key.split('-');
const [ax, ay] = ka.split(',').map(Number); const [ax, ay] = ka.split(',').map(Number);
const [bx, by] = kb.split(',').map(Number); const [bx, by] = kb.split(',').map(Number);
const c1 = cellCenter(ax, ay); const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
const c2 = cellCenter(bx, by); const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
const t1 = CELL / (2 * CELL_PITCH); ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
const t2 = 1 - CELL / (2 * CELL_PITCH); ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
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.stroke(); ctx.globalAlpha = 1;
ctx.globalAlpha = 1;
// Draw selected path if (selected.length > 1) drawPath(selected, '#fff', GAP - 2, 0.8);
if (selected.length > 1) {
drawPath(selected, '#fff', GAP - 2, 0.8);
}
// Draw cells
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (let x = 0; x < GRID; x++) { for (let x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) { for (let y = 0; y < GRID; y++) {
const cx = OFFSET_X + x * CELL_PITCH; const cx = OFFSET_X + x * CELL_PITCH, cy = OFFSET_Y + y * CELL_PITCH;
const cy = OFFSET_Y + y * CELL_PITCH;
const key = cellKey(x, y); const key = cellKey(x, y);
const isVisible = activeCells.has(key); const isVisible = activeCells.has(key), isSel = selSet.has(key);
const isSel = selSet.has(key);
ctx.globalAlpha = isVisible ? 1 : 0.08; ctx.globalAlpha = isVisible ? 1 : 0.08;
ctx.fillStyle = isSel ? '#16213e' : '#0f3460'; ctx.fillStyle = isSel ? '#16213e' : '#0f3460';
ctx.beginPath(); ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2); ctx.fill();
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2);
ctx.fill();
if (isSel && isVisible) { if (isSel && isVisible) {
ctx.strokeStyle = '#fff'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 3;
ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2); ctx.stroke();
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.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
} }
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
wordDisplay.textContent = selected.map(([x, y]) => board[x][y]).join(''); wordDisplay.textContent = selected.map(([x, y]) => board[x][y]).join('');
} }
@@ -378,110 +345,51 @@
if (flashTimer) clearTimeout(flashTimer); if (flashTimer) clearTimeout(flashTimer);
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y))); const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
const flashSet = new Set((path || 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); ctx.clearRect(0, 0, canvas.width, canvas.height);
const activeCells = getActiveCells(); const activeCells = getActiveCells();
const activeConns = getActiveConnections(); const activeConns = getActiveConnections();
ctx.strokeStyle = '#2a4a7f'; ctx.lineWidth = GAP - 6; ctx.lineCap = 'round'; ctx.globalAlpha = 0.6;
// Draw connections
ctx.strokeStyle = '#2a4a7f';
ctx.lineWidth = GAP - 6;
ctx.lineCap = 'round';
ctx.globalAlpha = 0.6;
ctx.beginPath(); ctx.beginPath();
for (const key of activeConns) { for (const key of activeConns) {
const [ka, kb] = key.split('-'); const [ka, kb] = key.split('-');
const [ax, ay] = ka.split(',').map(Number); const [ax, ay] = ka.split(',').map(Number);
const [bx, by] = kb.split(',').map(Number); const [bx, by] = kb.split(',').map(Number);
const c1 = cellCenter(ax, ay); const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
const c2 = cellCenter(bx, by); const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
const t1 = CELL / (2 * CELL_PITCH); ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
const t2 = 1 - CELL / (2 * CELL_PITCH); ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
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.stroke(); ctx.globalAlpha = 1;
ctx.globalAlpha = 1; if (selected.length > 1) drawPath(selected, color, GAP - 2, 0.9);
// 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 x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) { for (let y = 0; y < GRID; y++) {
const cx = OFFSET_X + x * CELL_PITCH; const cx = OFFSET_X + x * CELL_PITCH, cy = OFFSET_Y + y * CELL_PITCH;
const cy = OFFSET_Y + y * CELL_PITCH;
const key = cellKey(x, y); const key = cellKey(x, y);
const isSel = selSet.has(key); const isSel = selSet.has(key);
const isActive = activeCells.has(key); const isActive = activeCells.has(key);
ctx.globalAlpha = flashSet.has(key) ? 1 : (isActive ? 1 : 0.08); ctx.globalAlpha = flashSet.has(key) ? 1 : (isActive ? 1 : 0.08);
ctx.fillStyle = flashSet.has(key) ? shadeColor(color, -40) : '#0f3460'; ctx.fillStyle = flashSet.has(key) ? shadeColor(color, -40) : '#0f3460';
ctx.beginPath(); ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2); ctx.fill();
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2);
ctx.fill();
if (flashSet.has(key)) { if (flashSet.has(key)) {
ctx.strokeStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 4;
ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2); ctx.stroke();
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.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
} }
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
flashTimer = setTimeout(() => { selected = []; draw(); }, 500);
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() { function renderFound() {
foundDiv.innerHTML = '<h2>Найдено (' + foundWords.size + ')</h2>'; foundDiv.innerHTML = '<h2>Найдено (' + foundWords.size + ')</h2>';
for (const word of foundWords) { for (const word of foundWords) {
const color = getWordColor(word); const color = getWordColor(word);
const def = word_defs[word] || ''; const def = wordDefs[word] || '';
const isExtra = word in extraWords; const isExtra = word in extraWords;
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'word-item'; item.className = 'word-item'; item.style.opacity = isExtra ? '0.6' : '1';
item.style.opacity = isExtra ? '0.6' : '1';
item.innerHTML = item.innerHTML =
'<div class="word-item-inner"><span class="word-color" style="background:' + color + '"></span><span class="word-text">' + word + '</span></div>' + '<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>' : ''); (def ? '<span class="word-def">— ' + def + '</span>' : '');
@@ -495,159 +403,208 @@
} }
function showMessage(text, color) { function showMessage(text, color) {
msgEl.textContent = text; msgEl.textContent = text; msgEl.style.color = color || '#feca57';
msgEl.style.color = color || '#feca57';
setTimeout(() => { msgEl.textContent = ''; }, 2000); setTimeout(() => { msgEl.textContent = ''; }, 2000);
} }
// ===================== Game Logic =====================
function checkWord() { function checkWord() {
if (selected.length < 4) return; if (selected.length < 4) return;
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase(); const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
// Already found
if (foundWords.has(word)) { if (foundWords.has(word)) {
// Check if it's a prefix of an unfound word
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords)); const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word)); const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
if (isPrefixOfUnfound) return; if (isPrefixOfUnfound) return;
showMessage('Уже найдено!', '#feca57'); showMessage('Уже найдено!', '#feca57');
selected = []; selected = []; draw(); return;
draw();
return;
} }
// Valid solution word
if (word in allSolutions) { if (word in allSolutions) {
foundWords.add(word); foundWords.add(word);
foundPaths[word] = allSolutions[word];
flashPath(allSolutions[word], '#2ed573'); flashPath(allSolutions[word], '#2ed573');
renderFound(); renderFound(); updateStatus(); save(); return;
updateStatus();
return;
} }
// Valid extra word
if (word in extraWords) { if (word in extraWords) {
foundWords.add(word); foundWords.add(word);
foundPaths[word] = extraWords[word];
flashPath(extraWords[word], '#feca57'); flashPath(extraWords[word], '#feca57');
renderFound(); renderFound(); showMessage('Бонус!', '#feca57'); save(); return;
showMessage('Бонус!', '#feca57');
return;
} }
// Not a valid word, silently ignore (user still building)
} }
function validateWord() { function validateWord() {
if (selected.length === 0) return; if (selected.length === 0) return;
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase(); const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
// Already found
if (foundWords.has(word)) { if (foundWords.has(word)) {
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords)); const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word)); const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
if (isPrefixOfUnfound) return; if (isPrefixOfUnfound) return;
showMessage('Уже найдено!', '#feca57'); showMessage('Уже найдено!', '#feca57');
selected = []; selected = []; draw(); return;
draw();
return;
} }
if (selected.length < 4) { showMessage('Мало букв — нужно минимум 4', '#ff6b6b'); selected = []; draw(); return; }
// Too short if (!(word in allSolutions) && !(word in extraWords)) { showMessage('Слово не найдено!', '#ff6b6b'); selected = []; draw(); return; }
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'); 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) { function handleCellClick(mx, my) {
const col = Math.floor((mx - OFFSET_X) / CELL_PITCH); const col = Math.floor((mx - OFFSET_X) / CELL_PITCH);
const row = Math.floor((my - OFFSET_Y) / CELL_PITCH); const row = Math.floor((my - OFFSET_Y) / CELL_PITCH);
if (col < 0 || col >= GRID || row < 0 || row >= GRID) return false; 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); const idx = selected.findIndex(s => s[0] === col && s[1] === row);
if (idx >= 0) { if (idx >= 0) { selected = selected.slice(0, idx); draw(); return true; }
selected = selected.slice(0, idx);
draw();
return true;
}
// Check connection
if (selected.length > 0) { if (selected.length > 0) {
if (!isAdjacentAndConnected(selected[selected.length - 1], [col, row])) return false; if (!isAdjacentAndConnected(selected[selected.length - 1], [col, row])) return false;
} else {
if (!getActiveCells().has(cellKey(col, row))) return false;
} }
selected.push([col, row]); 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);
}
function resetTimer() {
if (timerInterval) clearInterval(timerInterval); timerInterval = null;
elapsed = 0;
lastSaveTime = Date.now();
}
// ===================== Load Puzzle =====================
async function loadPuzzle(number) {
statusEl.textContent = 'Генерация...';
gameCompleteEl.classList.remove('show');
numberSelectorEl.classList.remove('show');
resetTimer();
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; }
foundWords = new Set();
board = data.board;
allSolutions = data.solutions || {};
extraWords = data.extra || {};
wordDefs = data.word_defs || {};
selected = [];
initBoard(); draw(); renderFound(); updateStatus();
startTimer();
}
// ===================== 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) => { canvas.addEventListener('pointerdown', (e) => {
e.preventDefault(); e.preventDefault();
_pointerDown = true;
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
handleCellClick(e.clientX - r.left, e.clientY - r.top); handleCellClick(e.clientX - r.left, e.clientY - r.top);
}); });
canvas.addEventListener('pointerup', () => {});
canvas.addEventListener('pointerup', (e) => {
_pointerDown = false;
});
canvas.addEventListener('pointercancel', (e) => { canvas.addEventListener('pointercancel', (e) => {
e.preventDefault(); e.preventDefault();
_pointerDown = false; selected = []; draw();
selected = [];
draw();
}); });
async function loadPuzzle() { // ===================== Init =====================
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(); loadPuzzle();
})();
</script> </script>
</body> </body>
</html> </html>
+6
View File
@@ -8,3 +8,9 @@ class PuzzleOutputDTO(BaseModel):
solutions: dict[str, list[tuple[int, int]]] solutions: dict[str, list[tuple[int, int]]]
extra: dict[str, list[tuple[int, int]]] extra: dict[str, list[tuple[int, int]]]
word_defs: dict[str, str] 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.services.puzzles import PuzzleGeneratorDep
from zangramru.settings import settings
from .schema import PuzzleOutputDTO from .schema import DailyPuzzleDTO, PuzzleOutputDTO
router = APIRouter() router = APIRouter()
@@ -10,5 +17,55 @@ router = APIRouter()
@router.get("/random") @router.get("/random")
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO: def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
"""Generate a random puzzle.""" """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) 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,
)