Surn's picture
0.2.28
2de3d96
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