3 Commits

Author SHA1 Message Date
s3rius cf98f5ccbd Dict update.
/ docker_build (push) Successful in 5m56s
/ helm_deploy (push) Successful in 48s
Testing zangramru / lint (push) Successful in 2m53s
Testing zangramru / pytest (push) Successful in 3m22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-03 01:07:08 +02:00
s3rius fcd7f6aaf9 Fixed time copying.
/ docker_build (push) Successful in 1m9s
/ helm_deploy (push) Successful in 23s
Testing zangramru / lint (push) Successful in 30s
Testing zangramru / pytest (push) Successful in 18s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 19:00:02 +02:00
s3rius 042e8b8581 Added random board sharing.
/ docker_build (push) Successful in 1m12s
/ helm_deploy (push) Successful in 23s
Testing zangramru / lint (push) Successful in 29s
Testing zangramru / pytest (push) Successful in 18s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 18:33:37 +02:00
5 changed files with 9779 additions and 399 deletions
+2 -2
View File
@@ -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
+9696 -368
View File
File diff suppressed because it is too large Load Diff
+32 -14
View File
@@ -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:
+36 -13
View File
@@ -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;
@@ -1047,9 +1057,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 + '#' + encodeURIComponent(compressedBoard);
text = 'Занграммы!\n🎉 Мне потребовалось всего лишь ' + formatTime(elapsed) + '\n\n' + url;
} else {
text = 'Занграммы #' + puzzleNumber +
'\n🎉 Мне потребовалось всего лишь ' + formatTime(elapsed) +
'\n\n' + window.location.href;
}
navigator.clipboard.writeText(text).then(
() => showMessage('Результат скопирован!', PAL().found),
() => showMessage('Не удалось скопировать', PAL().selTile),
@@ -1069,19 +1086,25 @@
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) {
history.replaceState(null, '', window.location.pathname);
loadRandomPuzzle(hash);
} else {
loadPuzzle();
}
})();
</script>
</body>
+13 -2
View File
@@ -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)