File size: 5,142 Bytes
d747bc4
 
 
 
 
 
 
49f93dd
 
 
 
 
d747bc4
49f93dd
 
 
 
 
 
 
 
 
 
 
 
d747bc4
49f93dd
 
 
 
d747bc4
 
49f93dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d747bc4
 
 
 
 
 
 
 
85779ec
581c2fc
85779ec
 
581c2fc
d747bc4
 
 
 
 
 
 
 
 
 
 
581c2fc
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226477a
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10dda8b
99bf0ab
 
 
d747bc4
 
 
 
feb1c4e
 
 
 
 
 
 
 
 
 
d747bc4
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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) -> 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.
    """
    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
    if 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
    all_word_cells = set()
    for w in state.puzzle.words:
        all_word_cells.update(w.cells)
    if 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"