Added random board sharing.
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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: мрак, тьма
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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:
|
||||
|
||||
@@ -501,8 +501,10 @@
|
||||
// ===================== Persistence =====================
|
||||
function save() {
|
||||
if (puzzleNumber === null) return;
|
||||
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 = '?';
|
||||
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');
|
||||
const data = await res.json();
|
||||
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 +
|
||||
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();
|
||||
const hash = decodeURIComponent(window.location.hash.slice(1) || '');
|
||||
if (hash && hash.length === 16) {
|
||||
loadRandomPuzzle(hash);
|
||||
} else {
|
||||
loadPuzzle();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user