from __future__ import annotations from dataclasses import dataclass, field from typing import Literal, List, Set, Dict, Optional from datetime import datetime import uuid Direction = Literal["H", "V"] @dataclass(frozen=True, order=True) class Coord: x: int # row, 0-based y: int # col, 0-based def in_bounds(self, size: int) -> bool: return 0 <= self.x < size and 0 <= self.y < size @dataclass class Word: text: str start: Coord direction: Direction cells: List[Coord] = field(default_factory=list) def __post_init__(self): self.text = self.text.upper() if self.direction not in ("H", "V"): raise ValueError("direction must be 'H' or 'V'") if not self.text.isalpha(): raise ValueError("word must be alphabetic A–Z only") # compute cells based on start and direction length = len(self.text) cells: List[Coord] = [] for i in range(length): if self.direction == "H": cells.append(Coord(self.start.x, self.start.y + i)) else: cells.append(Coord(self.start.x + i, self.start.y)) object.__setattr__(self, "cells", cells) @property def length(self) -> int: return len(self.text) @property def last_cell(self) -> Coord: return self.cells[-1] @dataclass class Puzzle: """Puzzle configuration and metadata. Fields - words: The list of placed words. - radar: Points used to render the UI radar (defaults to each word's last cell). - may_overlap: If True, words may overlap on shared letters (e.g., a crossword-style junction). - spacer: (2 to -3) Controls proximity and overlap rules between distinct words: * spacer = 0 -> words may be directly adjacent (touching next to each other). * spacer = 1 -> at least 1 blank cell must separate words (no immediate adjacency). * spacer > 1 -> enforce that many blank cells of separation. * spacer < 0 -> allow overlaps on a common letter; abs(spacer) is the maximum number of trailing letters each word may extend beyond the shared letter (e.g., -3 allows up to 3 letters past the overlap). Note: These are configuration hints for the generator/logic. Enforcement is not implemented here. """ words: List[Word] radar: List[Coord] = field(default_factory=list) may_overlap: bool = False spacer: int = 1 # Unique identifier for this puzzle instance (used for deterministic regen and per-session assets) uid: str = field(default_factory=lambda: uuid.uuid4().hex) # Cached set of all word cells (computed once, used by is_game_over check) _all_word_cells: Set[Coord] = field(default_factory=set, repr=False, init=False) def __post_init__(self): pulses = [w.last_cell for w in self.words] self.radar = pulses # Pre-compute all word cells once for faster is_game_over() checks all_cells: Set[Coord] = set() for w in self.words: all_cells.update(w.cells) object.__setattr__(self, '_all_word_cells', all_cells) @dataclass class GameState: grid_size: int puzzle: Puzzle revealed: Set[Coord] guessed: Set[str] score: int last_action: str can_guess: bool game_mode: Literal["classic", "too easy"] = "classic" points_by_word: Dict[str, int] = field(default_factory=dict) start_time: Optional[datetime] = None end_time: Optional[datetime] = None