Spaces:
Running
Running
| from __future__ import annotations | |
| from typing import Dict, Tuple | |
| from .models import Coord, Puzzle, GameState, Word | |
| def _chebyshev_distance(a: Coord, b: Coord) -> int: | |
| """8-neighborhood distance (max norm).""" | |
| return max(abs(a.x - b.x), abs(a.y - b.y)) | |
| def build_letter_map(puzzle: Puzzle, skip_validation: bool = True) -> Dict[Coord, str]: | |
| """Build a coordinate->letter map for the given puzzle. | |
| Spacer support (0–2): | |
| - spacer = 0: words may touch (no separation enforced). | |
| - spacer = 1: words must be separated by at least 1 blank tile | |
| (no two letters from different words at Chebyshev distance <= 1). | |
| - spacer = 2: at least 2 blank tiles between words | |
| (no two letters from different words at Chebyshev distance <= 2). | |
| Overlaps are not handled here (negative spacer not supported in this function). | |
| This function raises ValueError if the configured spacing is violated. | |
| Args: | |
| puzzle: The puzzle to build the letter map for | |
| skip_validation: If True, skip spacer validation (default: True). | |
| Validation is expensive and only needed during puzzle | |
| generation, not during gameplay. | |
| """ | |
| mapping: Dict[Coord, str] = {} | |
| spacer = getattr(puzzle, "spacer", 1) | |
| # Build mapping normally (no overlap merging beyond first-come-wins semantics) | |
| for w in puzzle.words: | |
| for idx, c in enumerate(w.cells): | |
| ch = w.text[idx] | |
| if c not in mapping: | |
| mapping[c] = ch | |
| else: | |
| # If an explicit overlap occurs, we don't support it here. | |
| # Keep the first-seen letter and continue. | |
| pass | |
| # Enforce spacer in the range 0–2 (only when validation is requested) | |
| if not skip_validation and spacer in (1, 2): | |
| # Prepare sets of cells per word | |
| word_cells = [set(w.cells) for w in puzzle.words] | |
| for i in range(len(word_cells)): | |
| for j in range(i + 1, len(word_cells)): | |
| cells_i = word_cells[i] | |
| cells_j = word_cells[j] | |
| # If any pair is too close, it's a violation | |
| for c1 in cells_i: | |
| # Early exit by scanning a small neighborhood around c1 | |
| # since Chebyshev distance <= spacer | |
| for c2 in cells_j: | |
| if _chebyshev_distance(c1, c2) <= spacer: | |
| raise ValueError( | |
| f"Words too close (spacer={spacer}): {c1} and {c2}" | |
| ) | |
| # spacer == 0 -> no checks; other values are ignored here intentionally | |
| return mapping | |
| def reveal_cell(state: GameState, letter_map: Dict[Coord, str], coord: Coord) -> None: | |
| if coord in state.revealed: | |
| state.last_action = "Already revealed." | |
| return | |
| state.revealed.add(coord) | |
| # Determine if this reveal uncovered a letter or an empty cell | |
| ch = letter_map.get(coord, "·") | |
| # Only allow guessing if a letter was revealed; preserve existing True (e.g., after a correct guess) | |
| state.can_guess = state.can_guess or (ch != "·") | |
| if ch == "·": | |
| state.last_action = f"Revealed empty at ({coord.x+1},{coord.y+1})." | |
| else: | |
| state.last_action = f"Revealed '{ch}' at ({coord.x+1},{coord.y+1})." | |
| def guess_word(state: GameState, guess_text: str) -> Tuple[bool, int]: | |
| if not state.can_guess: | |
| state.last_action = "You must reveal a cell before guessing." | |
| return False, 0 | |
| guess = (guess_text or "").strip().upper() | |
| if not (len(guess) in (4, 5, 6) and guess.isalpha()): | |
| state.last_action = "Guess must be A–Z and length 4, 5, or 6." | |
| state.can_guess = False | |
| return False, 0 | |
| if guess in state.guessed: | |
| state.last_action = f"Already guessed {guess}." | |
| state.can_guess = False | |
| return False, 0 | |
| # Find matching unguessed word | |
| target: Word | None = None | |
| for w in state.puzzle.words: | |
| if w.text == guess and w.text not in state.guessed: | |
| target = w | |
| break | |
| if target is None: | |
| state.last_action = f"Try Again! '{guess}' is not in the puzzle." | |
| state.can_guess = False | |
| return False, 0 | |
| # Scoring: base = length, bonus = unrevealed cells in that word | |
| unrevealed = sum(1 for c in target.cells if c not in state.revealed) | |
| points = target.length + unrevealed | |
| state.score += points | |
| state.points_by_word[target.text] = points | |
| # Reveal all cells of the word | |
| for c in target.cells: | |
| state.revealed.add(c) | |
| state.guessed.add(target.text) | |
| state.last_action = f"Correct! +{points} points for {target.text}." | |
| if state.game_mode == "classic": | |
| state.can_guess = True # <-- Allow another guess after a correct guess | |
| else: | |
| state.can_guess = False | |
| return True, points | |
| def is_game_over(state: GameState) -> bool: | |
| # Game ends if all words are guessed | |
| if len(state.guessed) == 6: | |
| return True | |
| # Game ends if all word cells are revealed | |
| # Use pre-computed _all_word_cells set from puzzle for performance | |
| if state.puzzle._all_word_cells.issubset(state.revealed): | |
| return True | |
| return False | |
| def compute_tier(score: int) -> str: | |
| if score >= 42: | |
| return "Fantastic" | |
| if 38 <= score <= 41: | |
| return "Great" | |
| if 34 <= score <= 37: | |
| return "Good" | |
| return "Keep practicing" | |
| def hidden_word_display(letters_display: int, character: str = "?") -> str: | |
| """Return a string of characters of length letters_display.""" | |
| return character * letters_display | |
| def auto_mark_completed_words(state: GameState) -> bool: | |
| """Automatically mark words as found when all their letters are revealed. | |
| Returns True if any word state changed (e.g., guessed/score/points). | |
| Scoring in this case is base length only (no unrevealed bonus). | |
| """ | |
| changed = False | |
| for w in state.puzzle.words: | |
| if w.text in state.guessed: | |
| continue | |
| if all(c in state.revealed for c in w.cells): | |
| # Award base points if not already assigned | |
| if w.text not in state.points_by_word: | |
| base_points = w.length | |
| state.points_by_word[w.text] = base_points | |
| state.score += base_points | |
| state.guessed.add(w.text) | |
| changed = True | |
| if changed: | |
| # Do not alter can_guess; just note the auto-complete | |
| state.last_action = (state.last_action or "") + "\nAuto-complete: revealed word(s) marked as found." | |
| return changed |