diff --git a/docker-compose.yml b/docker-compose.yml index 917b0f2..1859f97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,8 @@ services: ZANGRAMRU_DB_USER: zangramru ZANGRAMRU_DB_PASS: zangramru ZANGRAMRU_DB_BASE: zangramru - volumes: - - .:/app/src/ + # volumes: + # - .:/app/src/ db: image: postgres:18.3-trixie diff --git a/zangramru/services/dict.csv b/zangramru/services/dict.csv index f5e3caf..f5ea6af 100644 --- a/zangramru/services/dict.csv +++ b/zangramru/services/dict.csv @@ -2055,6 +2055,20 @@ word|def потоп|сильное наводнение, разлив воды жакет|"1: короткая женская верхняя одежда, укороченный вариант пиджака 2: короткий однобортный сюртук" +гопак|"1: этногр., муз. украинская народная пляска, основными движениями которой являются присядка, пробежки, широкие, высокие прыжки со взмахами ногами +2: этногр., муз. музыка к такой пляске +3: этногр., муз. музыкальное произведение в ритме такой пляски" +колпак|"1: конусообразный или овальный головной убор, служащий для различных целей +2: конусообразная или округлая покрышка к разным предметам +3: микол. род грибов из семейства кортинариевых +4: прост., устар. глупый, недалёкий человек, простак" +сено|"1: неисч. скошенная и высушенная трава, идущая на корм и подстилку скоту +2: неисч. о траве, предназначенной для заготовки такого корма +3: крим. жарг. табак; махорка" +навес|"1: кровля на столбах или иных опорах для защиты от солнца или непогоды +2: спорт. высокий пас по воздуху, как правило — с фланга или по диагонали, вперёд" +воск|"1: пластичная масса жёлтого или белого цвета, производимая пчёлами для постройки сот +2: хим. сложный эфир высших жирных кислот и одноатомных высших спиртов" функция|"1: перен., книжн. обязанность, круг деятельности чего-либо, подлежащая исполнению работа 2: книжн. значение, назначение, роль 3: матем. переменная величина, меняющаяся в зависимости от изменения другой величины, а также закон, определяющий свойства такого изменения @@ -9093,9 +9107,6 @@ word|def 2: совокупность произведений массового искусства, которые могут быть исполнены данным исполнителем 3: библиотечный указатель, ретроспективно охватывающий весь свод национальной печатной продукции определенного периода" рюкзак|заплечный мешок с лямками для ношения за спиной -сено|"1: неисч. скошенная и высушенная трава, идущая на корм и подстилку скоту -2: неисч. о траве, предназначенной для заготовки такого корма -3: крим. жарг. табак; махорка" согласование|"1: действие или состояние по значению гл. согласовать, согласоваться 2: лингв. вид подчинительной связи, при котором зависимое слово уподобляется в выражении грамматических значений господствующему слову" фундамент|"1: основание из камня, бетона и т. п., служащее опорой конструкции какого-либо здания или сооружения @@ -13739,10 +13750,6 @@ word|def 3: спец. знаковый или числовой показатель, указывающий на изменение той или иной гуманитарной, социальной, экономической величины" камешек|уменьш.-ласк. к камень; маленький камень каска|сделанный из твёрдого материала защитный головной убор военнослужащих или других специалистов, действующих в опасных условиях -колпак|"1: конусообразный или овальный головной убор, служащий для различных целей -2: конусообразная или округлая покрышка к разным предметам -3: микол. род грибов из семейства кортинариевых -4: прост., устар. глупый, недалёкий человек, простак" коммуна|"1: коллектив лиц, объединившихся для совместной жизни на началах общности имущества и труда 2: низшая административно-территориальная единица в некоторых странах" мгла|"1: мрак, тьма diff --git a/zangramru/services/puzzles.py b/zangramru/services/puzzles.py index 01fc1fd..5e40cd7 100644 --- a/zangramru/services/puzzles.py +++ b/zangramru/services/puzzles.py @@ -113,7 +113,7 @@ class PuzzleGenerator: self.words[word] = row["def"] self.trie.add(word) - def find_solutions( # noqa: C901 + def _find_solutions( # noqa: C901 self, board: list[list[str]] ) -> dict[str, list[tuple[int, int]]]: """ @@ -258,7 +258,7 @@ class PuzzleGenerator: return cover, extras - def get_valid_points( + def _get_valid_points( self, solutions: dict[str, list[tuple[int, int]]], ) -> set[tuple[int, int]]: @@ -269,6 +269,32 @@ class PuzzleGenerator: return used_cells + def _verify_solution( + self, board: list[list[str]], target: int + ) -> tuple[set[tuple[int, int]], Puzzle | None]: + solutions = self._find_solutions(board) + cover, extra = self._find_minimal_cover(solutions, target) + valid_points = self._get_valid_points(cover) + + # We found a valid solution | board combination + if len(valid_points) != 16 or len(cover) < target: + return valid_points, None + + word_defs = {word: self.words[word] for word in cover | extra} + + return valid_points, Puzzle( + board=board, + solutions=cover, + extra=extra, + word_defs=word_defs, + ) + + def check_board(self, compressed_board: str, target: int = 10) -> Puzzle | None: + """Check if given board is valid.""" + board = [list(compressed_board[i * 4 : i * 4 + 4]) for i in range(4)] + _, puzzle = self._verify_solution(board, target) + return puzzle + def generate(self, target: int, timeout: int = 10) -> Puzzle: """ Generate a valid puzzle that has solutions. @@ -289,27 +315,19 @@ class PuzzleGenerator: ) if single_board_attempt > 50: valid_points = set() + single_board_attempt = 0 for x in range(4): for y in range(4): if (x, y) not in valid_points: board[x][y] = secrets.choice(WEIGHTED_ALPHABET) - solutions = self.find_solutions(board) - cover, extra = self._find_minimal_cover(solutions, target) - valid_points = self.get_valid_points(cover) - word_defs = {word: self.words[word] for word in cover | extra} - # We found a valid solution | board combination - if len(valid_points) != 16 or len(cover) < target: + valid_points, puzzle = self._verify_solution(board, target) + if not puzzle: single_board_attempt += 1 continue - return Puzzle( - board=board, - solutions=cover, - extra=extra, - word_defs=word_defs, - ) + return puzzle def setup_generator(app: FastAPI) -> None: diff --git a/zangramru/static/index.html b/zangramru/static/index.html index 34dcab3..3bcbe9f 100644 --- a/zangramru/static/index.html +++ b/zangramru/static/index.html @@ -501,8 +501,10 @@ // ===================== Persistence ===================== function save() { if (puzzleNumber === null) return; - elapsed += Date.now() - lastSaveTime; - lastSaveTime = Date.now(); + if (!allWordsFound()) { + 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)); } @@ -951,7 +953,9 @@ } function finishGame(withConfetti) { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } - gameTimeEl.textContent = formatTime(displayElapsed()); + elapsed = displayElapsed(); + if (puzzleNumber !== null) save(); + gameTimeEl.textContent = formatTime(elapsed); gameCompleteEl.classList.add('show'); if (withConfetti) celebrate(); } @@ -1005,14 +1009,20 @@ startTimer(); } - async function loadRandomPuzzle() { + async function loadRandomPuzzle(compressed) { gameCompleteEl.classList.remove('show'); statLeft.textContent = '…'; resetTimer(); puzzleNumber = null; statNumber.textContent = '?'; - const res = await fetch('/api/puzzles/random'); - const data = await res.json(); + let data; + if (compressed) { + const res = await fetch('/api/puzzles/random?board=' + encodeURIComponent(compressed)); + data = await res.json(); + } else { + const res = await fetch('/api/puzzles/random'); + data = await res.json(); + } if (data.error) { statNumber.textContent = 'ERR'; return; } foundWords = new Set(); board = data.board; @@ -1020,6 +1030,8 @@ extraWords = data.extra || {}; wordDefs = data.word_defs || {}; selected = []; + const cb = board.flat().join(''); + history.replaceState(null, '', '#' + cb); startBoard(); renderFound(); updateStatus(); startTimer(); } @@ -1047,9 +1059,16 @@ // ===================== Copy result ===================== function copyResult() { - const text = 'Занграммы #' + puzzleNumber + - '\n🎉 Мне потребовалось всего лишь ' + formatTime(displayElapsed()) + - '\n\n' + window.location.href; + let text; + if (puzzleNumber === null) { + const compressedBoard = board.flat().join(''); + const url = window.location.origin + window.location.pathname + '#' + compressedBoard; + text = 'Занграммы!\n🎉 Мне потребовалось всего лишь ' + formatTime(displayElapsed()) + '\n\n' + url; + } else { + text = 'Занграммы #' + puzzleNumber + + '\n🎉 Мне потребовалось всего лишь ' + formatTime(displayElapsed()) + + '\n\n' + window.location.href; + } navigator.clipboard.writeText(text).then( () => showMessage('Результат скопирован!', PAL().found), () => showMessage('Не удалось скопировать', PAL().selTile), @@ -1069,19 +1088,24 @@ wordDisplay.addEventListener('click', submitSelection); $('copyResultBtn').addEventListener('click', copyResult); $('prevPuzzlesBtn').addEventListener('click', showNumberSelector); - $('randomPuzzleBtn').addEventListener('click', loadRandomPuzzle); + $('randomPuzzleBtn').addEventListener('click', () => loadRandomPuzzle()); $('closeNumberBtn').addEventListener('click', () => numberSelectorEl.classList.remove('show')); themeBtn.addEventListener('click', () => applyTheme(theme === 'dark' ? 'light' : 'dark')); document.addEventListener('keydown', onKeyDown); window.addEventListener('resize', sizeConfetti); - window.addEventListener('beforeunload', save); - document.addEventListener('visibilitychange', () => { if (document.hidden) save(); }); + + // ===================== Init ===================== applyTheme(theme); sizeConfetti(); - loadPuzzle(); + const hash = decodeURIComponent(window.location.hash.slice(1) || ''); + if (hash && hash.length === 16) { + loadRandomPuzzle(hash); + } else { + loadPuzzle(); + } })(); diff --git a/zangramru/web/api/puzzles/views.py b/zangramru/web/api/puzzles/views.py index cbb82b6..4056722 100644 --- a/zangramru/web/api/puzzles/views.py +++ b/zangramru/web/api/puzzles/views.py @@ -16,9 +16,20 @@ router = APIRouter() @router.get("/random") -def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO: +def random_puzzle( + generator: PuzzleGeneratorDep, + board: str | None = None, +) -> PuzzleOutputDTO: """Generate a random puzzle.""" - puzzle = generator.generate(target=10) + if board: + puzzle = generator.check_board(board, target=10) + if puzzle is None: + raise HTTPException( + status_code=status.HTTP_421_MISDIRECTED_REQUEST, + detail="Given board has no solution", + ) + else: + puzzle = generator.generate(10) return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)