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