added support for daily puzzles.
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
+17242
-17469
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
@@ -263,20 +268,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 +310,6 @@ class PuzzleGenerator:
|
||||
word_defs=word_defs,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def setup_generator(app: FastAPI) -> None:
|
||||
"""Create puzzle-generator."""
|
||||
@@ -319,7 +326,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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
+310
-353
@@ -48,9 +48,7 @@
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
#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 +63,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 +74,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 +87,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 +169,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 +202,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 +226,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 +345,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 +403,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);
|
||||
}
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
_pointerDown = true;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
handleCellClick(e.clientX - r.left, e.clientY - r.top);
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', (e) => {
|
||||
_pointerDown = false;
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', () => {});
|
||||
canvas.addEventListener('pointercancel', (e) => {
|
||||
e.preventDefault();
|
||||
_pointerDown = false;
|
||||
selected = [];
|
||||
draw();
|
||||
selected = []; draw();
|
||||
});
|
||||
|
||||
async function loadPuzzle() {
|
||||
statusEl.textContent = 'Генерация...';
|
||||
gameCompleteEl.classList.remove('show');
|
||||
resetTimer();
|
||||
try {
|
||||
const res = await fetch('/api/puzzles/random');
|
||||
const data = await res.json();
|
||||
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
|
||||
board = data.board;
|
||||
allSolutions = data.solutions;
|
||||
extraWords = data.extra || {};
|
||||
word_defs = data.word_defs || {};
|
||||
selected = [];
|
||||
foundWords = new Set();
|
||||
foundPaths = {};
|
||||
initBoard();
|
||||
draw();
|
||||
renderFound();
|
||||
updateStatus();
|
||||
startTimer();
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Не удалось загрузить головоломку';
|
||||
}
|
||||
}
|
||||
|
||||
wordDisplay.addEventListener('click', validateWord);
|
||||
|
||||
// ===================== Init =====================
|
||||
loadPuzzle();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user