Surn commited on
Commit
1b1b6cc
·
1 Parent(s): 2de3d96

- change difficulty calculation
- add test_compare_difficulty_functions
- streamlit version update
- fix hugging face share link

.streamlit/config.toml CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  [theme]
2
  base="dark"
3
  primaryColor="#1d64c8"
 
1
+ [server]
2
+ enableStaticServing = true
3
+
4
  [theme]
5
  base="dark"
6
  primaryColor="#1d64c8"
Dockerfile CHANGED
@@ -39,9 +39,15 @@ RUN python -m pip install --upgrade pip setuptools wheel
39
  COPY requirements.txt ./
40
  RUN pip3 install -r requirements.txt
41
 
 
 
 
 
 
42
  # Copy application source
43
  COPY app.py ./app.py
44
  COPY battlewords ./battlewords
 
45
 
46
  # Hugging Face Spaces sets $PORT (default 7860). Expose it for clarity. using 8501 for local consistency with Streamlit defaults
47
 
 
39
  COPY requirements.txt ./
40
  RUN pip3 install -r requirements.txt
41
 
42
+ # Copy PWA injection files
43
+ COPY pwa-head-inject.html ./pwa-head-inject.html
44
+ COPY inject-pwa-head.sh ./inject-pwa-head.sh
45
+ RUN chmod +x ./inject-pwa-head.sh && ./inject-pwa-head.sh
46
+
47
  # Copy application source
48
  COPY app.py ./app.py
49
  COPY battlewords ./battlewords
50
+ COPY static ./static
51
 
52
  # Hugging Face Spaces sets $PORT (default 7860). Expose it for clarity. using 8501 for local consistency with Streamlit defaults
53
 
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🎲
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: streamlit
7
- sdk_version: 1.50.0
8
  python_version: 3.12.8
9
  app_port: 8501
10
  app_file: app.py
@@ -187,6 +187,11 @@ CRYPTO_PK= # Reserved for future signing
187
  - Personal high scores sidebar with filtering
188
  - Player statistics tracking (games played, averages, bests)
189
 
 
 
 
 
 
190
  -0.2.28
191
  - PWA INSTALL_GUIDE.md added
192
  - PWA implementation with service worker and manifest.json added
 
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: streamlit
7
+ sdk_version: 1.51.0
8
  python_version: 3.12.8
9
  app_port: 8501
10
  app_file: app.py
 
187
  - Personal high scores sidebar with filtering
188
  - Player statistics tracking (games played, averages, bests)
189
 
190
+ -0.2.29
191
+ - change difficulty calculation
192
+ - add test_compare_difficulty_functions
193
+ - streamlit version update to 1.51.0
194
+
195
  -0.2.28
196
  - PWA INSTALL_GUIDE.md added
197
  - PWA implementation with service worker and manifest.json added
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.28"
2
  __all__ = ["models", "generator", "logic", "ui", "game_storage"]
 
1
+ __version__ = "0.2.29"
2
  __all__ = ["models", "generator", "logic", "ui", "game_storage"]
battlewords/game_storage.py CHANGED
@@ -497,11 +497,12 @@ def get_shareable_url(sid: str, base_url: str = None) -> str:
497
  sep = '&' if '?' in base_url else '?'
498
  return f"{base_url}{sep}game_id={sid}"
499
 
500
- # 2) Check for local development (common Streamlit env vars)
501
- port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
502
- host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
503
- if host in ("localhost", "127.0.0.1") or os.environ.get("IS_LOCAL", "").lower() == "true":
504
- return f"http://{host}:{port}/?game_id={sid}"
 
505
 
506
  # 3) Otherwise, build HuggingFace Space URL from SPACE_NAME
507
  space = (SPACE_NAME or "surn/battlewords").lower().replace("/", "-")
 
497
  sep = '&' if '?' in base_url else '?'
498
  return f"{base_url}{sep}game_id={sid}"
499
 
500
+ if os.environ.get("IS_LOCAL", "true").lower() == "true":
501
+ # 2) Check for local development (common Streamlit env vars)
502
+ port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
503
+ host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
504
+ if host in ("localhost", "127.0.0.1") or os.environ.get("IS_LOCAL", "").lower() == "true":
505
+ return f"http://{host}:{port}/?game_id={sid}"
506
 
507
  # 3) Otherwise, build HuggingFace Space URL from SPACE_NAME
508
  space = (SPACE_NAME or "surn/battlewords").lower().replace("/", "-")
battlewords/ui.py CHANGED
@@ -33,25 +33,99 @@ st.set_page_config(initial_sidebar_state="collapsed")
33
 
34
  # PWA (Progressive Web App) Support
35
  # Enables installing BattleWords as a native-feeling mobile app
36
- pwa_meta_tags = """
37
- <link rel="manifest" href="/app/static/manifest.json">
38
- <meta name="theme-color" content="#165ba8">
39
- <meta name="apple-mobile-web-app-capable" content="yes">
40
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
41
- <meta name="apple-mobile-web-app-title" content="BattleWords">
42
- <link rel="apple-touch-icon" href="/app/static/icon-192.png">
43
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
44
- <meta name="mobile-web-app-capable" content="yes">
45
-
46
  <script>
47
  // Register service worker for offline functionality
 
48
  if ('serviceWorker' in navigator) {
49
  window.addEventListener('load', () => {
50
- navigator.serviceWorker.register('/app/static/service-worker.js')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  .then(registration => {
52
  console.log('[PWA] Service Worker registered successfully:', registration.scope);
53
 
54
- // Check for updates periodically
55
  registration.addEventListener('updatefound', () => {
56
  const newWorker = registration.installing;
57
  newWorker.addEventListener('statechange', () => {
@@ -83,7 +157,6 @@ window.addEventListener('appinstalled', () => {
83
  });
84
  </script>
85
  """
86
- st.markdown(pwa_meta_tags, unsafe_allow_html=True)
87
 
88
  CoordLike = Tuple[int, int]
89
 
@@ -397,7 +470,7 @@ def inject_styles() -> None:
397
  max-width:100%;
398
  }
399
  .stImage {max-width:300px;}
400
- #text_input_3,#text_input_1 {
401
  background-color:#fff;
402
  color:#000;
403
  caret-color:#333;}
@@ -1808,6 +1881,9 @@ def _render_game_over(state: GameState):
1808
  _mount_background_audio(False, None, 0.0)
1809
 
1810
  def run_app():
 
 
 
1811
  # Handle query params using new API
1812
  try:
1813
  params = st.query_params
 
33
 
34
  # PWA (Progressive Web App) Support
35
  # Enables installing BattleWords as a native-feeling mobile app
36
+ # Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
37
+ # This ensures proper PWA detection by browsers
38
+ pwa_service_worker = """
 
 
 
 
 
 
 
39
  <script>
40
  // Register service worker for offline functionality
41
+ // Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
42
  if ('serviceWorker' in navigator) {
43
  window.addEventListener('load', () => {
44
+ // Service worker code as string (inline to avoid MIME type issues)
45
+ const swCode = `
46
+ const CACHE_NAME = 'battlewords-v0.2.29';
47
+ const RUNTIME_CACHE = 'battlewords-runtime';
48
+
49
+ const PRECACHE_URLS = [
50
+ '/',
51
+ '/app/static/manifest.json',
52
+ '/app/static/icon-192.png',
53
+ '/app/static/icon-512.png'
54
+ ];
55
+
56
+ self.addEventListener('install', event => {
57
+ console.log('[ServiceWorker] Installing...');
58
+ event.waitUntil(
59
+ caches.open(CACHE_NAME)
60
+ .then(cache => {
61
+ console.log('[ServiceWorker] Precaching app shell');
62
+ return cache.addAll(PRECACHE_URLS);
63
+ })
64
+ .then(() => self.skipWaiting())
65
+ );
66
+ });
67
+
68
+ self.addEventListener('activate', event => {
69
+ console.log('[ServiceWorker] Activating...');
70
+ event.waitUntil(
71
+ caches.keys().then(cacheNames => {
72
+ return Promise.all(
73
+ cacheNames.map(cacheName => {
74
+ if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
75
+ console.log('[ServiceWorker] Deleting old cache:', cacheName);
76
+ return caches.delete(cacheName);
77
+ }
78
+ })
79
+ );
80
+ }).then(() => self.clients.claim())
81
+ );
82
+ });
83
+
84
+ self.addEventListener('fetch', event => {
85
+ if (event.request.method !== 'GET') return;
86
+ if (!event.request.url.startsWith('http')) return;
87
+
88
+ event.respondWith(
89
+ caches.open(RUNTIME_CACHE).then(cache => {
90
+ return fetch(event.request)
91
+ .then(response => {
92
+ if (response.status === 200) {
93
+ cache.put(event.request, response.clone());
94
+ }
95
+ return response;
96
+ })
97
+ .catch(() => {
98
+ return caches.match(event.request).then(cachedResponse => {
99
+ if (cachedResponse) {
100
+ console.log('[ServiceWorker] Serving from cache:', event.request.url);
101
+ return cachedResponse;
102
+ }
103
+ return new Response('Offline - Please check your connection', {
104
+ status: 503,
105
+ statusText: 'Service Unavailable',
106
+ headers: new Headers({'Content-Type': 'text/plain'})
107
+ });
108
+ });
109
+ });
110
+ })
111
+ );
112
+ });
113
+
114
+ self.addEventListener('message', event => {
115
+ if (event.data.action === 'skipWaiting') {
116
+ self.skipWaiting();
117
+ }
118
+ });
119
+ `;
120
+
121
+ // Create Blob URL for service worker
122
+ const blob = new Blob([swCode], { type: 'application/javascript' });
123
+ const swUrl = URL.createObjectURL(blob);
124
+
125
+ navigator.serviceWorker.register(swUrl)
126
  .then(registration => {
127
  console.log('[PWA] Service Worker registered successfully:', registration.scope);
128
 
 
129
  registration.addEventListener('updatefound', () => {
130
  const newWorker = registration.installing;
131
  newWorker.addEventListener('statechange', () => {
 
157
  });
158
  </script>
159
  """
 
160
 
161
  CoordLike = Tuple[int, int]
162
 
 
470
  max-width:100%;
471
  }
472
  .stImage {max-width:300px;}
473
+ [id^="text_input"] {
474
  background-color:#fff;
475
  color:#000;
476
  caret-color:#333;}
 
1881
  _mount_background_audio(False, None, 0.0)
1882
 
1883
  def run_app():
1884
+ # Render PWA service worker registration (meta tags in <head> via Docker)
1885
+ st.markdown(pwa_service_worker, unsafe_allow_html=True)
1886
+
1887
  # Handle query params using new API
1888
  try:
1889
  params = st.query_params
battlewords/word_loader.py CHANGED
@@ -133,7 +133,7 @@ def load_word_list(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
133
 
134
 
135
  # Ensure this function is at module scope (not indented) and import string at top
136
- def compute_word_difficulties(file_path, words_array=None):
137
  """
138
  1. Read and sanitize word list: uppercase A–Z only, skip comments/blank lines.
139
  2. Count occurrences of each letter across all words (A..Z only).
@@ -145,6 +145,8 @@ def compute_word_difficulties(file_path, words_array=None):
145
  8. Get count c_w of words with same first/last, uniqueness u_w = 1 / c_w.
146
  9. Difficulty d_w = [k * (26 - k)] / [(k + 1) * (a_w + u_w)] if denominator != 0, else 0.
147
  10. Return total difficulty (sum d_w) and dict of {word: d_w}.
 
 
148
  """
149
  try:
150
  with open(file_path, 'r', encoding='utf-8') as f:
@@ -210,5 +212,179 @@ def compute_word_difficulties(file_path, words_array=None):
210
  d_w = 0 if denominator == 0 else (k * (26 - k)) / denominator
211
  difficulties[w] = d_w
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  total_difficulty = sum(difficulties.values())
214
  return total_difficulty, difficulties
 
133
 
134
 
135
  # Ensure this function is at module scope (not indented) and import string at top
136
+ def compute_word_difficulties3(file_path, words_array=None):
137
  """
138
  1. Read and sanitize word list: uppercase A–Z only, skip comments/blank lines.
139
  2. Count occurrences of each letter across all words (A..Z only).
 
145
  8. Get count c_w of words with same first/last, uniqueness u_w = 1 / c_w.
146
  9. Difficulty d_w = [k * (26 - k)] / [(k + 1) * (a_w + u_w)] if denominator != 0, else 0.
147
  10. Return total difficulty (sum d_w) and dict of {word: d_w}.
148
+ Original Version: Battlewords v0.2.24 to 0.2.28
149
+ 2024-06: Updated to handle missing files gracefully and ensure A–Z filtering
150
  """
151
  try:
152
  with open(file_path, 'r', encoding='utf-8') as f:
 
212
  d_w = 0 if denominator == 0 else (k * (26 - k)) / denominator
213
  difficulties[w] = d_w
214
 
215
+ total_difficulty = sum(difficulties.values())
216
+ return total_difficulty, difficulties
217
+
218
+
219
+ def compute_word_difficulties2(file_path, words_array=None):
220
+ """
221
+ 1. Read and sanitize word list: uppercase A–Z only, skip comments/blank lines.
222
+ 2. Compute corpus token frequencies p_l for letters (A..Z) from total occurrences.
223
+ 3. Count words sharing same first/last letters for each pair (start_end_counts).
224
+ 4. If words_array provided, use it (uppercase, A–Z only); else use full list W.
225
+ 5. For each word w: q_l(w) = c_l(w)/len(w). Difficulty = Σ_l q_l(w) * p_l.
226
+ Optionally scale by (2 - u_w) where u_w = 1 / count(first,last).
227
+ 6. Return total difficulty and per-word difficulties.
228
+ # Version 2: uses letter occurrence frequencies instead of presence/absence.
229
+ """
230
+ try:
231
+ with open(file_path, 'r', encoding='utf-8') as f:
232
+ raw_lines = f.readlines()
233
+ except Exception:
234
+ return 0, {}
235
+
236
+ # Sanitize lines similarly to load_word_list()
237
+ cleaned_words = []
238
+ for raw in raw_lines:
239
+ line = raw.strip()
240
+ if not line or line.startswith("#"):
241
+ continue
242
+ if "#" in line:
243
+ line = line.split("#", 1)[0].strip()
244
+ word = line.upper()
245
+ if re.fullmatch(r"[A-Z]+", word):
246
+ cleaned_words.append(word)
247
+
248
+ W = cleaned_words
249
+ if not W:
250
+ return 0, {}
251
+
252
+ # Start/end pair counts (same as before)
253
+ start_end_counts: Dict[tuple[str, str], int] = {}
254
+ for w in W:
255
+ first, last = w[0], w[-1]
256
+ key = (first, last)
257
+ start_end_counts[key] = start_end_counts.get(key, 0) + 1
258
+
259
+ # Corpus token frequencies p_l (counts every occurrence, not just presence)
260
+ token_counts = {l: 0 for l in string.ascii_uppercase}
261
+ for w in W:
262
+ for l in w:
263
+ if l in token_counts:
264
+ token_counts[l] += 1
265
+ total_tokens = sum(token_counts.values()) or 1
266
+ p_l = {l: token_counts[l] / total_tokens for l in string.ascii_uppercase}
267
+
268
+ # Candidate set
269
+ if words_array is None:
270
+ words_array = W
271
+ else:
272
+ words_array = [
273
+ w.upper()
274
+ for w in words_array
275
+ if re.fullmatch(r"[A-Z]+", w.upper())
276
+ ]
277
+
278
+ difficulties: Dict[str, float] = {}
279
+ for w in words_array:
280
+ m = len(w)
281
+ if m == 0:
282
+ continue
283
+
284
+ # q_l(w) from counts within the word (accounts for repeats)
285
+ counts_in_w: Dict[str, int] = {}
286
+ for ch in w:
287
+ if ch in p_l:
288
+ counts_in_w[ch] = counts_in_w.get(ch, 0) + 1
289
+
290
+ # Base difficulty: alignment with common letters (q · p)
291
+ commonness = sum((cnt / m) * p_l.get(l, 0.0) for l, cnt in counts_in_w.items())
292
+
293
+ # Optional scaling for common start/end patterns
294
+ first, last = w[0], w[-1]
295
+ c_w = start_end_counts.get((first, last), 1)
296
+ u_w = 1.0 / c_w # uniqueness
297
+ d_w = commonness * (2.0 - u_w)
298
+
299
+ difficulties[w] = d_w
300
+
301
+ total_difficulty = sum(difficulties.values())
302
+ return total_difficulty, difficulties
303
+
304
+
305
+ def compute_word_difficulties(file_path, words_array=None):
306
+ """
307
+ 1. Read and sanitize word list: uppercase A–Z only, skip comments/blank lines.
308
+ 2. Count occurrences of each letter across all words (A..Z only).
309
+ 3. Compute frequency f_l = count / n, rarity r_l = 1 - f_l for each letter.
310
+ 4. Count words sharing same first/last letters for each pair.
311
+ 5. If words_array provided, use it (uppercase); else use full list.
312
+ 6. For each word: get unique letters L_w, k = |L_w|.
313
+ 7. Compute weighted average rarity a_w = sum(r_l * count_in_word) / total_letters_in_word.
314
+ 8. Get count c_w of words with same first/last, uniqueness u_w = 1 / c_w.
315
+ 9. Difficulty d_w = [k * (26 - k)] / [(k + 1) * (a_w + u_w)] if denominator != 0, else 0.
316
+ 10. Return total difficulty (sum d_w) and dict of {word: d_w}.
317
+ VERION 3.0
318
+ """
319
+ try:
320
+ with open(file_path, 'r', encoding='utf-8') as f:
321
+ raw_lines = f.readlines()
322
+ except Exception:
323
+ return 0, {}
324
+
325
+ # Sanitize lines similarly to load_word_list()
326
+ cleaned_words = []
327
+ for raw in raw_lines:
328
+ line = raw.strip()
329
+ if not line or line.startswith("#"):
330
+ continue
331
+ if "#" in line:
332
+ line = line.split("#", 1)[0].strip()
333
+ word = line.upper()
334
+ # keep only A–Z words
335
+ if re.fullmatch(r"[A-Z]+", word):
336
+ cleaned_words.append(word)
337
+
338
+ W = cleaned_words
339
+ n = len(W)
340
+ if n == 0:
341
+ return 0, {}
342
+
343
+ letter_counts = {l: 0 for l in string.ascii_uppercase}
344
+ start_end_counts = {}
345
+
346
+ for w in W:
347
+ letters = set(w)
348
+ # Only count A..Z to avoid KeyError
349
+ for l in letters:
350
+ if l in letter_counts:
351
+ letter_counts[l] += 1
352
+ first, last = w[0], w[-1]
353
+ key = (first, last)
354
+ start_end_counts[key] = start_end_counts.get(key, 0) + 1
355
+
356
+ f_l = {l: count / n for l, count in letter_counts.items()}
357
+ r_l = {l: 1 - f for l, f in f_l.items()}
358
+
359
+ if words_array is None:
360
+ words_array = W
361
+ else:
362
+ # Ensure A–Z and uppercase for the selection as well
363
+ words_array = [
364
+ w.upper()
365
+ for w in words_array
366
+ if re.fullmatch(r"[A-Z]+", w.upper())
367
+ ]
368
+
369
+ difficulties = {}
370
+ for w in words_array:
371
+ # Count occurrences of each letter in the word
372
+ letter_freq = {l: w.count(l) for l in set(w)}
373
+
374
+ # Compute weighted average rarity
375
+ total_letters = len(w)
376
+ a_w = sum(r_l.get(l, 0) * freq for l, freq in letter_freq.items()) / total_letters
377
+
378
+ L_w = set(w)
379
+ k = len(L_w)
380
+ if k == 0:
381
+ continue
382
+ first, last = w[0], w[-1]
383
+ c_w = start_end_counts.get((first, last), 1)
384
+ u_w = c_w / 18 # magic number to scale uniqueness based on word lengths
385
+ denominator = (k + 1) * (a_w + u_w)
386
+ d_w = 0 if denominator == 0 else (k * (26 - k)) / denominator
387
+ difficulties[w] = d_w
388
+
389
  total_difficulty = sum(difficulties.values())
390
  return total_difficulty, difficulties
claude.md CHANGED
@@ -47,7 +47,7 @@ BattleWords is a vocabulary learning game inspired by Battleship mechanics, buil
47
  ## Technical Architecture
48
 
49
  ### Technology Stack
50
- - **Framework:** Streamlit 1.50.0
51
  - **Language:** Python 3.12.8
52
  - **Visualization:** Matplotlib, NumPy
53
  - **Data Processing:** Pandas, Altair
 
47
  ## Technical Architecture
48
 
49
  ### Technology Stack
50
+ - **Framework:** Streamlit 1.51.0
51
  - **Language:** Python 3.12.8
52
  - **Visualization:** Matplotlib, NumPy
53
  - **Data Processing:** Pandas, Altair
inject-pwa-head.sh ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Inject PWA meta tags into Streamlit's index.html head section
3
+ # This script modifies the Streamlit index.html during Docker build
4
+
5
+ set -e
6
+
7
+ echo "[PWA] Injecting PWA meta tags into Streamlit's index.html..."
8
+
9
+ # Find Streamlit's index.html
10
+ STREAMLIT_INDEX=$(python3 -c "import streamlit; import os; print(os.path.join(os.path.dirname(streamlit.__file__), 'static', 'index.html'))")
11
+
12
+ if [ ! -f "$STREAMLIT_INDEX" ]; then
13
+ echo "[PWA] ERROR: Streamlit index.html not found at: $STREAMLIT_INDEX"
14
+ exit 1
15
+ fi
16
+
17
+ echo "[PWA] Found Streamlit index.html at: $STREAMLIT_INDEX"
18
+
19
+ # Check if already injected (to make script idempotent)
20
+ if grep -q "PWA (Progressive Web App) Meta Tags" "$STREAMLIT_INDEX"; then
21
+ echo "[PWA] PWA tags already injected, skipping..."
22
+ exit 0
23
+ fi
24
+
25
+ # Read the injection content
26
+ INJECT_FILE="/app/pwa-head-inject.html"
27
+ if [ ! -f "$INJECT_FILE" ]; then
28
+ echo "[PWA] ERROR: Injection file not found at: $INJECT_FILE"
29
+ exit 1
30
+ fi
31
+
32
+ # Create backup
33
+ cp "$STREAMLIT_INDEX" "${STREAMLIT_INDEX}.backup"
34
+
35
+ # Use awk to inject after <head> tag
36
+ awk -v inject_file="$INJECT_FILE" '
37
+ /<head>/ {
38
+ print
39
+ while ((getline line < inject_file) > 0) {
40
+ print line
41
+ }
42
+ close(inject_file)
43
+ next
44
+ }
45
+ { print }
46
+ ' "${STREAMLIT_INDEX}.backup" > "$STREAMLIT_INDEX"
47
+
48
+ echo "[PWA] PWA meta tags successfully injected!"
49
+ echo "[PWA] Backup saved as: ${STREAMLIT_INDEX}.backup"
pwa-head-inject.html ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <!-- PWA (Progressive Web App) Meta Tags -->
2
+ <link rel="manifest" href="/app/static/manifest.json">
3
+ <meta name="theme-color" content="#165ba8">
4
+ <meta name="apple-mobile-web-app-capable" content="yes">
5
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
6
+ <meta name="apple-mobile-web-app-title" content="BattleWords">
7
+ <link rel="apple-touch-icon" href="/app/static/icon-192.png">
8
+ <meta name="mobile-web-app-capable" content="yes">
pyproject.toml CHANGED
@@ -1,11 +1,11 @@
1
  [project]
2
  name = "battlewords"
3
- version = "0.2.20"
4
  description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
8
- "streamlit>=1.50.0",
9
  "matplotlib>=3.8",
10
  "requests>=2.31.0",
11
  ]
 
1
  [project]
2
  name = "battlewords"
3
+ version = "0.2.29"
4
  description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
8
+ "streamlit>=1.51.0",
9
  "matplotlib>=3.8",
10
  "requests>=2.31.0",
11
  ]
{battlewords/static → static}/icon-192.png RENAMED
File without changes
{battlewords/static → static}/icon-512.png RENAMED
File without changes
{battlewords/static → static}/manifest.json RENAMED
@@ -1,27 +1,27 @@
1
- {
2
- "name": "BattleWords",
3
- "short_name": "BattleWords",
4
- "description": "Vocabulary learning game inspired by Battleship mechanics. Discover hidden words on a 12x12 grid and earn points for strategic guessing.",
5
- "start_url": "/",
6
- "scope": "/",
7
- "display": "standalone",
8
- "orientation": "portrait",
9
- "background_color": "#0b2a4a",
10
- "theme_color": "#165ba8",
11
- "icons": [
12
- {
13
- "src": "/app/static/icon-192.png",
14
- "sizes": "192x192",
15
- "type": "image/png",
16
- "purpose": "any maskable"
17
- },
18
- {
19
- "src": "/app/static/icon-512.png",
20
- "sizes": "512x512",
21
- "type": "image/png",
22
- "purpose": "any maskable"
23
- }
24
- ],
25
- "categories": ["games", "education"],
26
- "screenshots": []
27
- }
 
1
+ {
2
+ "name": "BattleWords",
3
+ "short_name": "BattleWords",
4
+ "description": "Vocabulary learning game inspired by Battleship mechanics. Discover hidden words on a 12x12 grid and earn points for strategic guessing.",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "orientation": "portrait",
9
+ "background_color": "#0b2a4a",
10
+ "theme_color": "#165ba8",
11
+ "icons": [
12
+ {
13
+ "src": "/app/static/icon-192.png",
14
+ "sizes": "192x192",
15
+ "type": "image/png",
16
+ "purpose": "any maskable"
17
+ },
18
+ {
19
+ "src": "/app/static/icon-512.png",
20
+ "sizes": "512x512",
21
+ "type": "image/png",
22
+ "purpose": "any maskable"
23
+ }
24
+ ],
25
+ "categories": ["games", "education"],
26
+ "screenshots": []
27
+ }
{battlewords/static → static}/service-worker.js RENAMED
@@ -1,99 +1,99 @@
1
- /**
2
- * BattleWords Service Worker
3
- * Enables PWA functionality: offline caching, install prompt, etc.
4
- *
5
- * Security Note: This file contains no secrets or sensitive data.
6
- * It only caches public assets for offline access.
7
- */
8
-
9
- const CACHE_NAME = 'battlewords-v0.2.27';
10
- const RUNTIME_CACHE = 'battlewords-runtime';
11
-
12
- // Assets to cache on install (minimal for faster install)
13
- const PRECACHE_URLS = [
14
- '/',
15
- '/app/static/manifest.json',
16
- '/app/static/icon-192.png',
17
- '/app/static/icon-512.png'
18
- ];
19
-
20
- // Install event - cache essential files
21
- self.addEventListener('install', event => {
22
- console.log('[ServiceWorker] Installing...');
23
- event.waitUntil(
24
- caches.open(CACHE_NAME)
25
- .then(cache => {
26
- console.log('[ServiceWorker] Precaching app shell');
27
- return cache.addAll(PRECACHE_URLS);
28
- })
29
- .then(() => self.skipWaiting()) // Activate immediately
30
- );
31
- });
32
-
33
- // Activate event - clean up old caches
34
- self.addEventListener('activate', event => {
35
- console.log('[ServiceWorker] Activating...');
36
- event.waitUntil(
37
- caches.keys().then(cacheNames => {
38
- return Promise.all(
39
- cacheNames.map(cacheName => {
40
- if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
41
- console.log('[ServiceWorker] Deleting old cache:', cacheName);
42
- return caches.delete(cacheName);
43
- }
44
- })
45
- );
46
- }).then(() => self.clients.claim()) // Take control immediately
47
- );
48
- });
49
-
50
- // Fetch event - network first, fall back to cache
51
- self.addEventListener('fetch', event => {
52
- // Skip non-GET requests
53
- if (event.request.method !== 'GET') {
54
- return;
55
- }
56
-
57
- // Skip chrome-extension and other non-http requests
58
- if (!event.request.url.startsWith('http')) {
59
- return;
60
- }
61
-
62
- event.respondWith(
63
- caches.open(RUNTIME_CACHE).then(cache => {
64
- return fetch(event.request)
65
- .then(response => {
66
- // Cache successful responses for future offline access
67
- if (response.status === 200) {
68
- cache.put(event.request, response.clone());
69
- }
70
- return response;
71
- })
72
- .catch(() => {
73
- // Network failed, try cache
74
- return caches.match(event.request).then(cachedResponse => {
75
- if (cachedResponse) {
76
- console.log('[ServiceWorker] Serving from cache:', event.request.url);
77
- return cachedResponse;
78
- }
79
-
80
- // No cache available, return offline page or error
81
- return new Response('Offline - Please check your connection', {
82
- status: 503,
83
- statusText: 'Service Unavailable',
84
- headers: new Headers({
85
- 'Content-Type': 'text/plain'
86
- })
87
- });
88
- });
89
- });
90
- })
91
- );
92
- });
93
-
94
- // Message event - handle commands from the app
95
- self.addEventListener('message', event => {
96
- if (event.data.action === 'skipWaiting') {
97
- self.skipWaiting();
98
- }
99
- });
 
1
+ /**
2
+ * BattleWords Service Worker
3
+ * Enables PWA functionality: offline caching, install prompt, etc.
4
+ *
5
+ * Security Note: This file contains no secrets or sensitive data.
6
+ * It only caches public assets for offline access.
7
+ */
8
+
9
+ const CACHE_NAME = 'battlewords-v0.2.29';
10
+ const RUNTIME_CACHE = 'battlewords-runtime';
11
+
12
+ // Assets to cache on install (minimal for faster install)
13
+ const PRECACHE_URLS = [
14
+ '/',
15
+ '/app/static/manifest.json',
16
+ '/app/static/icon-192.png',
17
+ '/app/static/icon-512.png'
18
+ ];
19
+
20
+ // Install event - cache essential files
21
+ self.addEventListener('install', event => {
22
+ console.log('[ServiceWorker] Installing...');
23
+ event.waitUntil(
24
+ caches.open(CACHE_NAME)
25
+ .then(cache => {
26
+ console.log('[ServiceWorker] Precaching app shell');
27
+ return cache.addAll(PRECACHE_URLS);
28
+ })
29
+ .then(() => self.skipWaiting()) // Activate immediately
30
+ );
31
+ });
32
+
33
+ // Activate event - clean up old caches
34
+ self.addEventListener('activate', event => {
35
+ console.log('[ServiceWorker] Activating...');
36
+ event.waitUntil(
37
+ caches.keys().then(cacheNames => {
38
+ return Promise.all(
39
+ cacheNames.map(cacheName => {
40
+ if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
41
+ console.log('[ServiceWorker] Deleting old cache:', cacheName);
42
+ return caches.delete(cacheName);
43
+ }
44
+ })
45
+ );
46
+ }).then(() => self.clients.claim()) // Take control immediately
47
+ );
48
+ });
49
+
50
+ // Fetch event - network first, fall back to cache
51
+ self.addEventListener('fetch', event => {
52
+ // Skip non-GET requests
53
+ if (event.request.method !== 'GET') {
54
+ return;
55
+ }
56
+
57
+ // Skip chrome-extension and other non-http requests
58
+ if (!event.request.url.startsWith('http')) {
59
+ return;
60
+ }
61
+
62
+ event.respondWith(
63
+ caches.open(RUNTIME_CACHE).then(cache => {
64
+ return fetch(event.request)
65
+ .then(response => {
66
+ // Cache successful responses for future offline access
67
+ if (response.status === 200) {
68
+ cache.put(event.request, response.clone());
69
+ }
70
+ return response;
71
+ })
72
+ .catch(() => {
73
+ // Network failed, try cache
74
+ return caches.match(event.request).then(cachedResponse => {
75
+ if (cachedResponse) {
76
+ console.log('[ServiceWorker] Serving from cache:', event.request.url);
77
+ return cachedResponse;
78
+ }
79
+
80
+ // No cache available, return offline page or error
81
+ return new Response('Offline - Please check your connection', {
82
+ status: 503,
83
+ statusText: 'Service Unavailable',
84
+ headers: new Headers({
85
+ 'Content-Type': 'text/plain'
86
+ })
87
+ });
88
+ });
89
+ });
90
+ })
91
+ );
92
+ });
93
+
94
+ // Message event - handle commands from the app
95
+ self.addEventListener('message', event => {
96
+ if (event.data.action === 'skipWaiting') {
97
+ self.skipWaiting();
98
+ }
99
+ });
tests/test_compare_difficulty_functions.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # file: tests/test_compare_difficulty_functions.py
2
+ import os
3
+ import sys
4
+ import pytest
5
+
6
+ # Ensure the modules path is available
7
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
+
9
+ from battlewords.modules.constants import HF_API_TOKEN
10
+ from battlewords.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
11
+ from battlewords.word_loader import compute_word_difficulties, compute_word_difficulties2, compute_word_difficulties3
12
+
13
+ # Ensure the token is set for Hugging Face Hub
14
+ if HF_API_TOKEN:
15
+ os.environ["HF_API_TOKEN"] = HF_API_TOKEN
16
+
17
+ # Define sample_words as a global variable
18
+ sample_words = []
19
+
20
+ def test_compare_difficulty_functions_for_challenge(capsys):
21
+ """
22
+ Compare compute_word_difficulties, compute_word_difficulties2, and compute_word_difficulties3
23
+ for all users in a challenge identified by short_id.
24
+ """
25
+ global sample_words # Ensure we modify the global variable
26
+
27
+ # Use a fixed short id for testing
28
+ short_id = "hDjsB_dl"
29
+
30
+ # Step 1: Resolve short ID to full URL
31
+ status, full_url = gen_full_url(
32
+ short_url=short_id,
33
+ repo_id=HF_REPO_ID,
34
+ json_file=SHORTENER_JSON_FILE
35
+ )
36
+
37
+ if status != "success_retrieved_full" or not full_url:
38
+ print(
39
+ f"Could not resolve short id '{short_id}'. "
40
+ f"Status: {status}. "
41
+ f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
42
+ )
43
+ captured = capsys.readouterr()
44
+ assert "Could not resolve short id" in captured.out
45
+ assert not full_url, "full_url should be empty/None on failure"
46
+ print("settings.json was not found or could not be resolved.")
47
+ return
48
+
49
+ print(f"✓ Resolved short id '{short_id}' to full URL: {full_url}")
50
+
51
+ # Step 2: Extract file path from full URL
52
+ url_parts = full_url.split("/resolve/main/")
53
+ assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
54
+ file_path = url_parts[1]
55
+
56
+ # Step 3: Download and parse settings.json
57
+ settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
58
+ assert settings, "Failed to download or parse settings.json"
59
+ print(f"✓ Downloaded settings.json")
60
+
61
+ # Validate settings structure
62
+ assert "challenge_id" in settings
63
+ assert "wordlist_source" in settings
64
+ assert "users" in settings
65
+
66
+ wordlist_source = settings.get("wordlist_source", "wordlist.txt")
67
+ users = settings.get("users", [])
68
+
69
+ print(f"\nChallenge ID: {settings['challenge_id']}")
70
+ print(f"Wordlist Source: {wordlist_source}")
71
+ print(f"Number of Users: {len(users)}")
72
+
73
+ # Step 4: Determine wordlist file path
74
+ # Assuming the wordlist is in battlewords/words/ directory
75
+ words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
76
+ wordlist_path = os.path.join(words_dir, wordlist_source)
77
+
78
+ # If wordlist doesn't exist, try classic.txt as fallback
79
+ if not os.path.exists(wordlist_path):
80
+ print(f"⚠ Wordlist '{wordlist_source}' not found, using 'classic.txt' as fallback")
81
+ wordlist_path = os.path.join(words_dir, "classic.txt")
82
+
83
+ assert os.path.exists(wordlist_path), f"Wordlist file not found: {wordlist_path}"
84
+ print(f"✓ Using wordlist: {wordlist_path}")
85
+
86
+ # Step 5: Compare difficulty functions for each user
87
+ print("\n" + "="*80)
88
+ print("DIFFICULTY COMPARISON BY USER")
89
+ print("="*80)
90
+
91
+ all_results = []
92
+
93
+ for user_idx, user in enumerate(users, 1):
94
+ user_name = user.get("name", f"User {user_idx}")
95
+ word_list = user.get("word_list", [])
96
+ sample_words += word_list # Update the global variable with the latest word list
97
+
98
+ if not word_list:
99
+ print(f"\n[{user_idx}] {user_name}: No words assigned, skipping")
100
+ continue
101
+
102
+ print(f"\n[{user_idx}] {user_name}")
103
+ print(f" Words: {len(word_list)} words")
104
+ print(f" Sample: {', '.join(word_list[:5])}{'...' if len(word_list) > 5 else ''}")
105
+
106
+ # Compute difficulties using all three functions
107
+ total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, word_list)
108
+ total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, word_list)
109
+ total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, word_list)
110
+
111
+ print(f"\n Function 1 (compute_word_difficulties):")
112
+ print(f" Total Difficulty: {total_diff1:.4f}")
113
+ print(f" Words Processed: {len(difficulties1)}")
114
+
115
+ print(f"\n Function 2 (compute_word_difficulties2):")
116
+ print(f" Total Difficulty: {total_diff2:.4f}")
117
+ print(f" Words Processed: {len(difficulties2)}")
118
+
119
+ print(f"\n Function 3 (compute_word_difficulties3):")
120
+ print(f" Total Difficulty: {total_diff3:.4f}")
121
+ print(f" Words Processed: {len(difficulties3)}")
122
+
123
+ # Calculate statistics
124
+ if difficulties1 and difficulties2 and difficulties3:
125
+ avg_diff1 = total_diff1 / len(difficulties1)
126
+ avg_diff2 = total_diff2 / len(difficulties2)
127
+ avg_diff3 = total_diff3 / len(difficulties3)
128
+
129
+ print(f"\n Comparison:")
130
+ print(f" Average Difficulty (Func1): {avg_diff1:.4f}")
131
+ print(f" Average Difficulty (Func2): {avg_diff2:.4f}")
132
+ print(f" Average Difficulty (Func3): {avg_diff3:.4f}")
133
+ print(f" Difference (Func1 vs Func2): {abs(avg_diff1 - avg_diff2):.4f}")
134
+ print(f" Difference (Func1 vs Func3): {abs(avg_diff1 - avg_diff3):.4f}")
135
+ print(f" Difference (Func2 vs Func3): {abs(avg_diff2 - avg_diff3):.4f}")
136
+
137
+ # Store results for final summary
138
+ all_results.append({
139
+ "user_name": user_name,
140
+ "word_count": len(word_list),
141
+ "total_diff1": total_diff1,
142
+ "total_diff2": total_diff2,
143
+ "total_diff3": total_diff3,
144
+ "difficulties1": difficulties1,
145
+ "difficulties2": difficulties2,
146
+ "difficulties3": difficulties3,
147
+ })
148
+
149
+ # Step 6: Print summary comparison
150
+ print("\n" + "="*80)
151
+ print("OVERALL SUMMARY")
152
+ print("="*80)
153
+
154
+ if all_results:
155
+ total1_sum = sum(r["total_diff1"] for r in all_results)
156
+ total2_sum = sum(r["total_diff2"] for r in all_results)
157
+ total3_sum = sum(r["total_diff3"] for r in all_results)
158
+ total_words = sum(r["word_count"] for r in all_results)
159
+
160
+ print(f"\nTotal Users Analyzed: {len(all_results)}")
161
+ print(f"Total Words Across All Users: {total_words}")
162
+ print(f"\nAggregate Total Difficulty:")
163
+ print(f" Function 1: {total1_sum:.4f}")
164
+ print(f" Function 2: {total2_sum:.4f}")
165
+ print(f" Function 3: {total3_sum:.4f}")
166
+ print(f" Difference (Func1 vs Func2): {abs(total1_sum - total2_sum):.4f}")
167
+ print(f" Difference (Func1 vs Func3): {abs(total1_sum - total3_sum):.4f}")
168
+ print(f" Difference (Func2 vs Func3): {abs(total2_sum - total3_sum):.4f}")
169
+
170
+ # Validate that all functions returned results for all users
171
+ assert all(r["difficulties1"] for r in all_results), "Function 1 failed for some users"
172
+ assert all(r["difficulties2"] for r in all_results), "Function 2 failed for some users"
173
+ assert all(r["difficulties3"] for r in all_results), "Function 3 failed for some users"
174
+
175
+ print("\n✓ All tests passed!")
176
+ else:
177
+ print("\n⚠ No users with words found in this challenge")
178
+
179
+
180
+ def test_compare_difficulty_functions_with_classic_wordlist():
181
+ """
182
+ Test all three difficulty functions using the classic.txt wordlist
183
+ with a sample set of words.
184
+ """
185
+ global sample_words # Use the global variable
186
+
187
+ words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
188
+ wordlist_path = os.path.join(words_dir, "classic.txt")
189
+
190
+ if not os.path.exists(wordlist_path):
191
+ pytest.skip(f"classic.txt not found at {wordlist_path}")
192
+
193
+ # Use the global sample_words if already populated, otherwise set a default
194
+ if not sample_words:
195
+ sample_words = ["ABLE", "ACID", "AREA", "ARMY", "BEAR", "BOWL", "CAVE", "COIN", "ECHO", "GOLD"]
196
+
197
+ print("\n" + "="*80)
198
+ print("TESTING WITH CLASSIC.TXT WORDLIST")
199
+ print("="*80)
200
+ print(f"Sample Words: {', '.join(sample_words)}")
201
+
202
+ # Compute difficulties
203
+ total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, sample_words)
204
+ total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, sample_words)
205
+ total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, sample_words)
206
+
207
+ print(f"\nFunction compute_word_difficulties Results:")
208
+ print(f" Total Difficulty: {total_diff1:.4f}")
209
+ for word in sample_words:
210
+ if word in difficulties1:
211
+ print(f" {word}: {difficulties1[word]:.4f}")
212
+
213
+ print(f"\nFunction compute_word_difficulties2 Results:")
214
+ print(f" Total Difficulty: {total_diff2:.4f}")
215
+ for word in sample_words:
216
+ if word in difficulties2:
217
+ print(f" {word}: {difficulties2[word]:.4f}")
218
+
219
+ print(f"\nFunction compute_word_difficulties3 Results:")
220
+ print(f" Total Difficulty: {total_diff3:.4f}")
221
+ for word in sample_words:
222
+ if word in difficulties3:
223
+ print(f" {word}: {difficulties3[word]:.4f}")
224
+
225
+ # Assertions
226
+ assert len(difficulties1) == len(set(sample_words)), "Function 1 didn't process all words"
227
+ assert len(difficulties2) == len(set(sample_words)), "Function 2 didn't process all words"
228
+ assert len(difficulties3) == len(set(sample_words)), "Function 3 didn't process all words"
229
+ assert total_diff1 > 0, "Function 1 total difficulty should be positive"
230
+ assert total_diff2 > 0, "Function 2 total difficulty should be positive"
231
+ assert total_diff3 > 0, "Function 3 total difficulty should be positive"
232
+
233
+ print("\n✓ Classic wordlist test passed!")
234
+
235
+
236
+ if __name__ == "__main__":
237
+ pytest.main(["-s", "-v", __file__])
tests/test_generator.py CHANGED
@@ -6,7 +6,13 @@ from battlewords.models import Coord
6
 
7
  class TestGenerator(unittest.TestCase):
8
  def test_generate_valid_puzzle(self):
9
- p = generate_puzzle(grid_size=12, seed=1234)
 
 
 
 
 
 
10
  validate_puzzle(p, grid_size=12)
11
  # Ensure 6 words and 6 radar pulses
12
  self.assertEqual(len(p.words), 6)
@@ -19,6 +25,5 @@ class TestGenerator(unittest.TestCase):
19
  seen.add(c)
20
  self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
21
 
22
-
23
  if __name__ == "__main__":
24
  unittest.main()
 
6
 
7
  class TestGenerator(unittest.TestCase):
8
  def test_generate_valid_puzzle(self):
9
+ # Provide a minimal word pool for deterministic testing
10
+ words_by_len = {
11
+ 4: ["TREE", "BOAT"],
12
+ 5: ["APPLE", "RIVER"],
13
+ 6: ["ORANGE", "PYTHON"],
14
+ }
15
+ p = generate_puzzle(grid_size=12, words_by_len=words_by_len, seed=1234)
16
  validate_puzzle(p, grid_size=12)
17
  # Ensure 6 words and 6 radar pulses
18
  self.assertEqual(len(p.words), 6)
 
25
  seen.add(c)
26
  self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
27
 
 
28
  if __name__ == "__main__":
29
  unittest.main()