File size: 6,259 Bytes
d747bc4
 
 
 
 
 
 
49f93dd
 
 
 
 
d747bc4
49f93dd
 
 
 
 
 
 
 
 
 
 
 
d747bc4
49f93dd
 
 
 
d747bc4
 
49f93dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d747bc4
 
 
 
 
 
 
 
85779ec
581c2fc
85779ec
 
581c2fc
d747bc4
 
 
 
 
 
 
 
 
 
 
581c2fc
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226477a
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10dda8b
99bf0ab
 
 
d747bc4
 
 
 
feb1c4e
 
 
 
 
 
 
 
 
 
d747bc4
 
 
 
 
 
 
 
 
8c718b2
 
7896205
 
 
8c718b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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"

def hidden_word_display(letters_display: int) -> str:
    """Return a string of '?' of length letters_display."""
    return "?" * 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