File size: 8,394 Bytes
d747bc4
 
 
49f93dd
 
78346c3
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f93dd
 
 
 
 
 
 
 
 
 
d747bc4
 
 
49f93dd
d747bc4
49f93dd
 
 
507acad
 
d747bc4
 
 
 
78346c3
49f93dd
 
 
 
 
 
 
 
 
 
507acad
 
49f93dd
 
 
 
 
d747bc4
49f93dd
 
 
 
 
 
 
 
 
 
 
507acad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d747bc4
 
78346c3
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78346c3
 
 
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
78346c3
 
 
 
 
d747bc4
 
 
 
 
 
 
 
 
 
49f93dd
 
 
 
 
 
 
 
507acad
49f93dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d747bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50f9808
 
49f93dd
 
 
 
 
 
 
 
 
 
50f9808
 
 
 
 
 
 
 
 
 
 
 
 
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
from __future__ import annotations

import random
import hashlib  # NEW
from typing import Dict, List, Optional, Union  # UPDATED
from .word_loader import load_word_list
from .models import Coord, Word, Puzzle


def _fits_and_free(cells: List[Coord], used: set[Coord], size: int) -> bool:
    for c in cells:
        if not c.in_bounds(size) or c in used:
            return False
    return True


def _build_cells(start: Coord, length: int, direction: str) -> List[Coord]:
    if direction == "H":
        return [Coord(start.x, start.y + i) for i in range(length)]
    else:
        return [Coord(start.x + i, start.y) for i in range(length)]


def _chebyshev_distance(a: Coord, b: Coord) -> int:
    return max(abs(a.x - b.x), abs(a.y - b.y))


def _seed_from_id(puzzle_id: str) -> int:
    """Derive a deterministic 64-bit seed from a string id."""
    h = hashlib.sha256(puzzle_id.encode("utf-8")).digest()
    return int.from_bytes(h[:8], "big", signed=False)


def generate_puzzle(
    grid_size: int = 12,
    words_by_len: Optional[Dict[int, List[str]]] = None,
    seed: Optional[Union[int, str]] = None,
    max_attempts: int = 5000,
    spacer: int = 1,
    puzzle_id: Optional[str] = None,  # NEW
    _retry: int = 0,  # NEW internal for deterministic retries
    target_words: Optional[List[str]] = None,  # NEW: for loading shared games
    may_overlap: bool = False,  # NEW: for future crossword-style gameplay
) -> Puzzle:
    """
    Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
    no cell overlaps. Radar pulses are last-letter cells.
    Ensures the same word text is not selected more than once.

    Parameters
    - grid_size: grid dimension (default 12)
    - words_by_len: preloaded word pools by length
    - seed: optional RNG seed
    - max_attempts: cap for placement attempts before restarting
    - spacer: separation constraint between different words (0–2 supported)
      - 0: words may touch
      - 1: at least 1 blank tile between words (default)
      - 2: at least 2 blank tiles between words
    - target_words: optional list of exactly 6 words to use (for shared games)
    - may_overlap: whether words can overlap (default False, for future use)

    Determinism:
    - If puzzle_id is provided, it's used to derive the RNG seed. Retries use f"{puzzle_id}:{_retry}".
    - Else if seed is provided (int or str), it's used (retries offset deterministically).
    - Else RNG is non-deterministic as before.
    """
    # Compute deterministic seed if requested
    if puzzle_id is not None:
        seed_val = _seed_from_id(f"{puzzle_id}:{_retry}")
    elif isinstance(seed, str):
        seed_val = _seed_from_id(f"{seed}:{_retry}")
    elif isinstance(seed, int):
        seed_val = seed + _retry
    else:
        seed_val = None

    rng = random.Random(seed_val) if seed_val is not None else random.Random()

    # If target_words is provided, use those specific words
    if target_words:
        if len(target_words) != 6:
            raise ValueError(f"target_words must contain exactly 6 words, got {len(target_words)}")
        # Group target words by length
        pools: Dict[int, List[str]] = {}
        for word in target_words:
            L = len(word)
            if L not in pools:
                pools[L] = []
            pools[L].append(word.upper())
        target_lengths = sorted([len(w) for w in target_words])
    else:
        # Normal random word selection
        words_by_len = words_by_len or load_word_list()
        target_lengths = [4, 4, 5, 5, 6, 6]
        # Pre-shuffle the word pools for variety but deterministic with seed.
        pools: Dict[int, List[str]] = {}
        for L in (4, 5, 6):
            unique_words = list(dict.fromkeys(words_by_len.get(L, [])))
            rng.shuffle(unique_words)
            pools[L] = unique_words

    used: set[Coord] = set()
    used_texts: set[str] = set()
    placed: List[Word] = []

    attempts = 0
    for L in target_lengths:
        placed_ok = False
        pool = pools[L]
        if not pool:
            raise RuntimeError(f"No words available for length {L}")

        word_try_order = pool[:]  # copy
        rng.shuffle(word_try_order)

        for cand_text in word_try_order:
            if attempts >= max_attempts:
                break
            attempts += 1

            if cand_text in used_texts:
                continue

            for _ in range(50):
                direction = rng.choice(["H", "V"])
                if direction == "H":
                    row = rng.randrange(0, grid_size)
                    col = rng.randrange(0, grid_size - L + 1)
                else:
                    row = rng.randrange(0, grid_size - L + 1)
                    col = rng.randrange(0, grid_size)

                cells = _build_cells(Coord(row, col), L, direction)
                if _fits_and_free(cells, used, grid_size):
                    w = Word(cand_text, Coord(row, col), direction)
                    placed.append(w)
                    used.update(cells)
                    used_texts.add(cand_text)
                    try:
                        pool.remove(cand_text)
                    except ValueError:
                        pass
                    placed_ok = True
                    break

            if placed_ok:
                break

        if not placed_ok:
            # Hard reset and retry whole generation if we hit a wall
            if attempts >= max_attempts:
                raise RuntimeError("Puzzle generation failed: max attempts reached")
            return generate_puzzle(
                grid_size=grid_size,
                words_by_len=words_by_len,
                seed=rng.randrange(1 << 30),
                max_attempts=max_attempts,
                spacer=spacer,
            )

    puzzle = Puzzle(words=placed, spacer=spacer, may_overlap=may_overlap)
    try:
        validate_puzzle(puzzle, grid_size=grid_size)
    except AssertionError:
        # Deterministic retry on validation failure

        # Regenerate on validation failure (e.g., spacer rule violation)
        return generate_puzzle(
            grid_size=grid_size,
            words_by_len=words_by_len,
            seed=seed,
            max_attempts=max_attempts,
            spacer=spacer,
            puzzle_id=puzzle_id,
            _retry=_retry + 1,
        )
    return puzzle


def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
    # Bounds and overlap checks
    seen: set[Coord] = set()
    counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
    for w in puzzle.words:
        if len(w.text) not in (4, 5, 6):
            raise AssertionError("Word length invalid")
        counts[len(w.text)] += 1
        for c in w.cells:
            if not c.in_bounds(grid_size):
                raise AssertionError("Cell out of bounds")
            if c in seen:
                raise AssertionError("Overlapping words detected")
            seen.add(c)
        # Last cell must match radar pulse for that word
        if w.last_cell not in puzzle.radar:
            raise AssertionError("Radar pulse missing for last cell")

    if counts[4] != 2 or counts[5] != 2 or counts[6] != 2:
        raise AssertionError("Incorrect counts of word lengths")

    # Enforce spacer rule (supports 0–2). Default spacer is 1 (from models.Puzzle).
    spacer_val = getattr(puzzle, "spacer", 1)
    if spacer_val in (1, 2):
        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)):
                for c1 in word_cells[i]:
                    for c2 in word_cells[j]:
                        if _chebyshev_distance(c1, c2) <= spacer_val:
                            raise AssertionError(f"Spacing violation (spacer={spacer_val}): {c1} vs {c2}")

def sort_word_file(filepath: str) -> List[str]:
    """
    Reads a word list file, skips header/comment lines, and returns words sorted
    by length (ascending), then alphabetically within each length group.
    """
    with open(filepath, "r", encoding="utf-8") as f:
        lines = f.readlines()
    # Skip header/comment lines
    words = [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
    # Sort by length, then alphabetically
    sorted_words = sorted(words, key=lambda w: (len(w), w))
    return sorted_words