Surn commited on
Commit
850b1df
·
1 Parent(s): c9759eb

Initail AI update

Browse files
CLAUDE.md CHANGED
@@ -7,14 +7,17 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords, with these k
7
  - **No scope/radar visualization**
8
  - **2 free letter guesses at game start** (all instances of chosen letters are revealed)
9
 
10
- **Current Version:** 0.0.4
11
  **Repository:** https://github.com/Oncorporation/Wrdler.git
12
  **Live Demo:** [DEPLOYMENT_URL_HERE]
13
 
14
  ## Recent Changes
15
 
16
- **v0.0.4 (Current):**
17
- - ✅ Version updated in `__init__.py` to 0.0.4
 
 
 
18
  - ✅ Documentation synchronized across all files
19
  - ✅ Project structure validated and consistent
20
  - ✅ All Phase 1 requirements complete (7 sprints)
@@ -64,6 +67,7 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords, with these k
64
  - **HTTP Requests:** requests (>=2.31.0)
65
  - **Remote Storage:** huggingface_hub (>=0.20.0)
66
  - **Environment:** python-dotenv (>=1.0.0)
 
67
  - **Testing:** Pytest
68
  - **Package Manager:** UV or pip
69
 
@@ -72,7 +76,7 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords, with these k
72
  wrdler/
73
  ├── app.py # Streamlit entry point
74
  ├── wrdler/ # Main package
75
- │ ├── __init__.py # Version: 0.0.4
76
  │ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
77
  │ ├── generator.py # Puzzle generation with deterministic seeding
78
  │ ├── logic.py # Game mechanics (reveal, guess, scoring)
@@ -111,7 +115,7 @@ wrdler/
111
  ├── README.md # User-facing documentation
112
  ├── CLAUDE.md # This file - project context for Claude
113
  ├── GAMEPLAY_GUIDE.md # User guide with tips and strategies
114
- └── RELEASE_NOTES_v0.0.4.md # Complete release documentation
115
  ```
116
 
117
  ## Key Features
@@ -153,6 +157,13 @@ wrdler/
153
  - No gameplay logic changes required
154
  - Works offline for basic functionality
155
 
 
 
 
 
 
 
 
156
  ### PLANNED: Local Player Storage (v0.3.0)
157
  - **Local Storage:**
158
  - Location: `~/.wrdler/data/`
@@ -179,7 +190,7 @@ wrdler/
179
  - **Free Letter Selection:** Circular green gradient buttons (2 at game start)
180
  - **Score Panel:** Real-time scoring with client-side JavaScript timer
181
  - **Settings Sidebar:**
182
- - Word list picker (classic, fourth_grade, wordlist)
183
  - Game mode selector (Classic, Too Easy)
184
  - Audio volume controls (music and effects separate)
185
  - Toggle for incorrect guess history display
@@ -199,9 +210,10 @@ wrdler/
199
 
200
  ### Development Status
201
 
202
- **Current Version:** v0.0.4 (Complete)
203
  - ✅ All 7 sprints complete
204
  - ✅ 100% test coverage (25/25 tests)
 
205
  - ✅ Ready for production deployment
206
  - ✅ PWA support implemented
207
  - ✅ Challenge Mode fully functional
@@ -335,6 +347,12 @@ The dataset repository will contain:
335
 
336
  ## Post-v0.0.2 Enhancements
337
 
 
 
 
 
 
 
338
  ### v0.2.20-0.2.29 (Challenge Mode & PWA)
339
  - Remote storage and game sharing via HF datasets
340
  - Multi-user leaderboards
@@ -364,7 +382,7 @@ The dataset repository will contain:
364
  - **Local:** Development and testing
365
  - **PWA:** Installable on desktop and mobile devices
366
 
367
- ### Privacy & Data (v0.0.4)
368
  - **Challenge Mode:** Optional remote storage via Hugging Face datasets
369
  - Player names optional (defaults to "Anonymous")
370
  - Only stores: word lists, scores, times, game modes
@@ -391,6 +409,8 @@ The dataset repository will contain:
391
  - ✅ Radar/scope visualization removed entirely
392
  - ✅ Free letter selection UI implemented with circular buttons
393
  - ✅ PWA injection via Docker build script (`inject-pwa-head.sh`)
 
 
394
 
395
  ### Key Implementation Details
396
  - **No radar field in Puzzle dataclass** - removed in Sprint 3
@@ -401,6 +421,8 @@ The dataset repository will contain:
401
  - **Free letters tracked** - `free_letters` set and `free_letters_used` counter
402
  - **Auto-completion** - words auto-marked when all letters revealed
403
  - **Incorrect guess limit** - maximum 10 per game
 
 
404
 
405
  ### WSL Environment Python Versions
406
  The development environment is WSL (Windows Subsystem for Linux) with access to both native Linux and Windows Python installations:
@@ -466,13 +488,16 @@ From `requirements.txt`:
466
  - requests>=2.31.0 (HTTP requests)
467
  - huggingface_hub>=0.20.0 (remote storage)
468
  - python-dotenv>=1.0.0 (environment variables)
 
 
469
 
470
  From `pyproject.toml`:
471
  - Python >=3.12, <3.13 (strict version requirement)
472
 
473
  ## Version History Summary
474
 
475
- - **v0.0.4** (Current) - Documentation sync, version update
 
476
  - **v0.0.2-0.0.3** - All 7 sprints complete, core Wrdler features
477
  - **v0.2.20-0.2.29** - Challenge Mode, PWA, remote storage (inherited from BattleWords)
478
  - **v0.1.x** - Initial BattleWords releases before Wrdler fork
@@ -482,7 +507,7 @@ See README.md for complete changelog.
482
  ---
483
 
484
  **Last Updated:** 2025-01-31
485
- **Current Version:** 0.0.4
486
  **Status:** Production Ready - All Features Complete ✅
487
 
488
  ## Test File Location
 
7
  - **No scope/radar visualization**
8
  - **2 free letter guesses at game start** (all instances of chosen letters are revealed)
9
 
10
+ **Current Version:** 0.1.0
11
  **Repository:** https://github.com/Oncorporation/Wrdler.git
12
  **Live Demo:** [DEPLOYMENT_URL_HERE]
13
 
14
  ## Recent Changes
15
 
16
+ **v0.1.0 (Current):**
17
+ - ✅ Version updated to 0.1.0 across all files
18
+ - ✅ AI word generation functionality added
19
+ - ✅ Word list management enhanced with AI support
20
+ - ✅ Utility modules integrated
21
  - ✅ Documentation synchronized across all files
22
  - ✅ Project structure validated and consistent
23
  - ✅ All Phase 1 requirements complete (7 sprints)
 
67
  - **HTTP Requests:** requests (>=2.31.0)
68
  - **Remote Storage:** huggingface_hub (>=0.20.0)
69
  - **Environment:** python-dotenv (>=1.0.0)
70
+ - **AI Generation:** transformers, gradio_client
71
  - **Testing:** Pytest
72
  - **Package Manager:** UV or pip
73
 
 
76
  wrdler/
77
  ├── app.py # Streamlit entry point
78
  ├── wrdler/ # Main package
79
+ │ ├── __init__.py # Version: 0.1.0
80
  │ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
81
  │ ├── generator.py # Puzzle generation with deterministic seeding
82
  │ ├── logic.py # Game mechanics (reveal, guess, scoring)
 
115
  ├── README.md # User-facing documentation
116
  ├── CLAUDE.md # This file - project context for Claude
117
  ├── GAMEPLAY_GUIDE.md # User guide with tips and strategies
118
+ └── RELEASE_NOTES_v0.1.0.md # Complete release documentation
119
  ```
120
 
121
  ## Key Features
 
157
  - No gameplay logic changes required
158
  - Works offline for basic functionality
159
 
160
+ ### ✅ AI Word Generation (v0.1.0)
161
+ - **AI-Powered Word Lists:** Generate custom word lists using Hugging Face Spaces or local transformers
162
+ - **Topic-Based Generation:** Create words related to specific themes (e.g., "Ocean Life", "Space")
163
+ - **Automatic Expansion:** New AI-generated words are saved to local files for future use
164
+ - **Fallback Support:** Gracefully falls back to dictionary words if AI is unavailable
165
+ - **Word Distribution:** Ensures exactly 25 words each of lengths 4, 5, and 6 per topic
166
+
167
  ### PLANNED: Local Player Storage (v0.3.0)
168
  - **Local Storage:**
169
  - Location: `~/.wrdler/data/`
 
190
  - **Free Letter Selection:** Circular green gradient buttons (2 at game start)
191
  - **Score Panel:** Real-time scoring with client-side JavaScript timer
192
  - **Settings Sidebar:**
193
+ - Word list picker (classic, fourth_grade, wordlist, AI Generated)
194
  - Game mode selector (Classic, Too Easy)
195
  - Audio volume controls (music and effects separate)
196
  - Toggle for incorrect guess history display
 
210
 
211
  ### Development Status
212
 
213
+ **Current Version:** 0.1.0 (Complete)
214
  - ✅ All 7 sprints complete
215
  - ✅ 100% test coverage (25/25 tests)
216
+ - ✅ AI word generation implemented
217
  - ✅ Ready for production deployment
218
  - ✅ PWA support implemented
219
  - ✅ Challenge Mode fully functional
 
347
 
348
  ## Post-v0.0.2 Enhancements
349
 
350
+ ### v0.1.0 (AI Word Generation)
351
+ - AI-powered word list generation using Hugging Face Spaces
352
+ - Topic-based word creation with automatic saving
353
+ - Enhanced word list management with AI fallback
354
+ - Utility modules integration for storage and file handling
355
+
356
  ### v0.2.20-0.2.29 (Challenge Mode & PWA)
357
  - Remote storage and game sharing via HF datasets
358
  - Multi-user leaderboards
 
382
  - **Local:** Development and testing
383
  - **PWA:** Installable on desktop and mobile devices
384
 
385
+ ### Privacy & Data (v0.1.0)
386
  - **Challenge Mode:** Optional remote storage via Hugging Face datasets
387
  - Player names optional (defaults to "Anonymous")
388
  - Only stores: word lists, scores, times, game modes
 
409
  - ✅ Radar/scope visualization removed entirely
410
  - ✅ Free letter selection UI implemented with circular buttons
411
  - ✅ PWA injection via Docker build script (`inject-pwa-head.sh`)
412
+ - ✅ AI word generation via `word_loader_ai.py` with Hugging Face integration
413
+ - ✅ Utility modules provide reusable functions for storage and file ops
414
 
415
  ### Key Implementation Details
416
  - **No radar field in Puzzle dataclass** - removed in Sprint 3
 
421
  - **Free letters tracked** - `free_letters` set and `free_letters_used` counter
422
  - **Auto-completion** - words auto-marked when all letters revealed
423
  - **Incorrect guess limit** - maximum 10 per game
424
+ - **AI word generation** - generates 75 words per topic, saves to local files
425
+ - **Utility modules** - shared functions from OpenBadge project
426
 
427
  ### WSL Environment Python Versions
428
  The development environment is WSL (Windows Subsystem for Linux) with access to both native Linux and Windows Python installations:
 
488
  - requests>=2.31.0 (HTTP requests)
489
  - huggingface_hub>=0.20.0 (remote storage)
490
  - python-dotenv>=1.0.0 (environment variables)
491
+ - transformers (AI word generation)
492
+ - gradio_client (HF Space API)
493
 
494
  From `pyproject.toml`:
495
  - Python >=3.12, <3.13 (strict version requirement)
496
 
497
  ## Version History Summary
498
 
499
+ - **v0.1.0** (Current) - AI word generation, utility modules, version bump
500
+ - **v0.0.4** (Previous) - Documentation sync, version update
501
  - **v0.0.2-0.0.3** - All 7 sprints complete, core Wrdler features
502
  - **v0.2.20-0.2.29** - Challenge Mode, PWA, remote storage (inherited from BattleWords)
503
  - **v0.1.x** - Initial BattleWords releases before Wrdler fork
 
507
  ---
508
 
509
  **Last Updated:** 2025-01-31
510
+ **Current Version:** 0.1.0
511
  **Status:** Production Ready - All Features Complete ✅
512
 
513
  ## Test File Location
env.template ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face API Token (required)
2
+ # Get your token from https://huggingface.co/settings/tokens
3
+ HF_TOKEN=your_huggingface_token_here
4
+
5
+ # Repository ID for storing badges (optional, defaults to your "hf_username/Storage")
6
+ HF_REPO_ID=hf_username/Storage
7
+
8
+ # Name of space hosting this program
9
+ SPACE_NAME=hf_username/Wrdler
10
+
11
+ # Temporary directory for file operations (optional)
12
+ TMPDIR=/tmp
13
+ # TEMP=/tmp
14
+ # XDG_CACHE_HOME=/tmp
15
+
16
+ # Flash attention setting (optional)
17
+ # USE_FLASH_ATTENTION=1
18
+
19
+ CRYPTO_PK=btc_public_key_here
20
+ IS_LOCAL=true
21
+ USE_HF_WORDS=false
22
+ HF_WORD_LIST_REPO_ID=hf_username/word-lists
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "wrdler"
3
- version = "0.0.2"
4
  description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
@@ -10,6 +10,8 @@ dependencies = [
10
  "requests>=2.31.0",
11
  "huggingface_hub>=0.20.0",
12
  "python-dotenv>=1.0.0",
 
 
13
  ]
14
 
15
  [build-system]
 
1
  [project]
2
  name = "wrdler"
3
+ version = "0.1.0"
4
  description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
 
10
  "requests>=2.31.0",
11
  "huggingface_hub>=0.20.0",
12
  "python-dotenv>=1.0.0",
13
+ "transformers",
14
+ "gradio_client",
15
  ]
16
 
17
  [build-system]
specs/requirements.md CHANGED
@@ -1,5 +1,5 @@
1
  # Wrdler: Implementation Requirements
2
- **Version:** 0.0.2
3
  **Status:** All Features Complete - Ready for Deployment
4
  **Last Updated:** 2025-01-31
5
 
@@ -59,7 +59,7 @@ This document breaks down the implementation tasks for Wrdler using the game rul
59
  ## Folder Structure (Implemented)
60
  - `app.py` – Streamlit entry point ✅
61
  - `wrdler/` – Python package ✅
62
- - `__init__.py` (version 0.0.2)
63
  - `models.py` – data models and types (rectangular grid support)
64
  - `word_loader.py` – load/validate/cached word lists
65
  - `generator.py` – word placement (8x6, horizontal only, one per row)
@@ -88,10 +88,13 @@ This document breaks down the implementation tasks for Wrdler using the game rul
88
 
89
  **Acceptance:** ✅ Types implemented and fully integrated (13/13 tests passing)
90
 
91
- ### 2) Word List ✅ (Sprint 1)
92
  - ✅ English word list filtered to alphabetic uppercase, lengths in {4,5,6}
93
  - ✅ Loader centralized in `word_loader.py` with caching
94
  - ✅ Three word lists: classic, fourth_grade, wordlist
 
 
 
95
 
96
  **Acceptance:** ✅ Loading function returns lists by length with >= 25 words per length
97
 
@@ -149,7 +152,15 @@ This document breaks down the implementation tasks for Wrdler using the game rul
149
 
150
  **Acceptance:** ✅ URL with `game_id` loads correctly; share button works; leaderboard displays properly
151
 
152
- ### 8) Comprehensive Tests ✅ (Sprint 6)
 
 
 
 
 
 
 
 
153
  - ✅ Placement validity (bounds, no overlaps, correct counts)
154
  - ✅ Scoring logic and bonuses
155
  - ✅ Free letter reveal behavior (2-letter limit)
@@ -203,7 +214,7 @@ This document breaks down the implementation tasks for Wrdler using the game rul
203
  ---
204
 
205
  **Last Updated:** 2025-01-31
206
- **Version:** 0.0.2
207
  **Status:** All Features Complete - Ready for Deployment 🚀
208
 
209
  ## Test File Location
 
1
  # Wrdler: Implementation Requirements
2
+ **Version:** 0.1.0
3
  **Status:** All Features Complete - Ready for Deployment
4
  **Last Updated:** 2025-01-31
5
 
 
59
  ## Folder Structure (Implemented)
60
  - `app.py` – Streamlit entry point ✅
61
  - `wrdler/` – Python package ✅
62
+ - `__init__.py` (version 0.1.0)
63
  - `models.py` – data models and types (rectangular grid support)
64
  - `word_loader.py` – load/validate/cached word lists
65
  - `generator.py` – word placement (8x6, horizontal only, one per row)
 
88
 
89
  **Acceptance:** ✅ Types implemented and fully integrated (13/13 tests passing)
90
 
91
+ ### 2) Word List Management ✅ (Sprint 1)
92
  - ✅ English word list filtered to alphabetic uppercase, lengths in {4,5,6}
93
  - ✅ Loader centralized in `word_loader.py` with caching
94
  - ✅ Three word lists: classic, fourth_grade, wordlist
95
+ - ✅ AI word generation support via `word_loader_ai.py` (generates 75 words per topic)
96
+ - ✅ Unified loader (`load_word_list_or_ai`) routes between file-based and AI-generated words
97
+ - ✅ Saves new AI-generated words to local files for expansion
98
 
99
  **Acceptance:** ✅ Loading function returns lists by length with >= 25 words per length
100
 
 
152
 
153
  **Acceptance:** ✅ URL with `game_id` loads correctly; share button works; leaderboard displays properly
154
 
155
+ ### 8) Utility Modules ✅ (Sprint 1)
156
+ - ✅ Shared utility modules from OpenBadge project
157
+ - ✅ `modules/__init__.py` exports storage, constants, file_utils
158
+ - ✅ HuggingFace storage & URL shortener
159
+ - ✅ File handling and utility functions
160
+
161
+ **Acceptance:** ✅ Modules integrated and functional for Challenge Mode storage
162
+
163
+ ### 9) Comprehensive Tests ✅ (Sprint 6)
164
  - ✅ Placement validity (bounds, no overlaps, correct counts)
165
  - ✅ Scoring logic and bonuses
166
  - ✅ Free letter reveal behavior (2-letter limit)
 
214
  ---
215
 
216
  **Last Updated:** 2025-01-31
217
+ **Version:** 0.1.0
218
  **Status:** All Features Complete - Ready for Deployment 🚀
219
 
220
  ## Test File Location
specs/specs.md CHANGED
@@ -1,5 +1,5 @@
1
  # Wrdler Game Specifications (specs.md)
2
- **Version:** 0.0.2
3
  **Status:** All Features Complete - Ready for Deployment
4
  **Last Updated:** 2025-01-31
5
 
 
1
  # Wrdler Game Specifications (specs.md)
2
+ **Version:** 0.1.0
3
  **Status:** All Features Complete - Ready for Deployment
4
  **Last Updated:** 2025-01-31
5
 
static/icon-192.png CHANGED
static/icon-512.png CHANGED
wrdler/__init__.py CHANGED
@@ -8,5 +8,5 @@ Key differences from BattleWords:
8
  - 2 free letter guesses at game start
9
  """
10
 
11
- __version__ = "0.0.8"
12
  __all__ = ["models", "generator", "logic", "ui", "word_loader"]
 
8
  - 2 free letter guesses at game start
9
  """
10
 
11
+ __version__ = "0.1.0"
12
  __all__ = ["models", "generator", "logic", "ui", "word_loader"]
wrdler/modules/constants.py CHANGED
@@ -19,8 +19,20 @@ CRYPTO_PK = os.getenv("CRYPTO_PK", None)
19
 
20
  # Repository Configuration
21
  HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
22
- SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
23
  SHORTENER_JSON_FILE = "shortener.json"
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  # Temporary Directory Configuration
26
  try:
 
19
 
20
  # Repository Configuration
21
  HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
22
+ SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/Wrdler')
23
  SHORTENER_JSON_FILE = "shortener.json"
24
+ USE_HF_WORDS = os.getenv("USE_HF_WORDS", "false").lower() == "true"
25
+ HF_WORD_LIST_REPO_ID= os.getenv("HF_WORD_LIST_REPO_ID", "ysharma/Chat_with_Meta_llama3_1_8b")
26
+
27
+ # List of smaller, faster fallback models if the primary one fails
28
+ AI_MODELS = [
29
+ "microsoft/Phi-3-mini-4k-instruct",
30
+ "meta-llama/Llama-3.1-8B-Instruct",
31
+ "google/gemma-2b-it",
32
+ "distilbert/distilgpt2",
33
+ "mistralai/Mistral-7B-Instruct-v0.3",
34
+ "NousResearch/Hermes-2-Pro-Llama-3-8B"
35
+ ]
36
 
37
  # Temporary Directory Configuration
38
  try:
wrdler/ui.py CHANGED
@@ -17,7 +17,7 @@ from datetime import datetime
17
  from .generator import generate_puzzle, sort_word_file
18
  from .logic import build_letter_map, reveal_cell, reveal_free_letter, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
- from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
  from .version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
@@ -356,7 +356,7 @@ def inject_styles() -> None:
356
  .bw-cell {
357
  width: 100%;
358
  gap: 0.1rem;
359
- aspect-ratio: 1 / 1;
360
  line-height: 1.6;
361
  display: flex;
362
  align-items: center;
@@ -367,7 +367,7 @@ def inject_styles() -> None:
367
  user-select: none;
368
  padding: 0.5rem 0.75rem;
369
  font-size: 1.4rem;
370
- min-height: 2.5rem;
371
  min-width: 1.25em;
372
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
373
  background: #1d64c8; /* Base cell color */
@@ -480,7 +480,7 @@ border-radius: 50% !important;
480
  margin: 0 auto;
481
  text-align: center;
482
  }
483
- div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 2.5rem;}
484
  .st-key-new_game_btn, .st-key-sort_wordlist_btn { margin: 0 auto; aspect-ratio: unset; }
485
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
486
 
@@ -490,7 +490,7 @@ border-radius: 50% !important;
490
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
491
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
492
  .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
493
- .st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 5px; }
494
  .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
495
  .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
496
  .st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
@@ -501,10 +501,10 @@ border-radius: 50% !important;
501
 
502
  /* grid adjustments */
503
  @media (min-width: 560px){
504
- div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}
505
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
506
- .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 1 / 1; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
507
- /*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
508
  .st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
509
  .st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
510
  aspect-ratio: auto !important;
@@ -519,7 +519,7 @@ border-radius: 50% !important;
519
 
520
  /* Mobile styles */
521
  @media (max-width: 640px) {
522
- .bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
523
  #bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
524
  #bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { width: 100% !important; min-width: 100% !important; max-width: 100% !important; flex: 1 1 100% !important; }
525
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
@@ -631,7 +631,12 @@ def _init_session() -> None:
631
  # Each player has their own uid and word_list in the users array
632
  else:
633
  # Normal game generation (Wrdler: 8 columns × 6 rows)
634
- words = load_word_list(st.session_state.get("selected_wordlist"))
 
 
 
 
 
635
  puzzle = generate_puzzle(grid_rows=6, grid_cols=8, words_by_len=words)
636
 
637
  st.session_state.puzzle = puzzle
@@ -640,7 +645,11 @@ def _init_session() -> None:
640
  st.session_state.revealed = set()
641
  st.session_state.guessed = set()
642
  st.session_state.score = 0
643
- st.session_state.last_action = "Welcome to Wrdler! Choose 2 free letters to start."
 
 
 
 
644
  st.session_state.can_guess = False
645
  st.session_state.points_by_word = {}
646
  st.session_state.letter_map = build_letter_map(puzzle)
@@ -668,43 +677,68 @@ def _init_session() -> None:
668
  st.session_state.free_letters_used = 0
669
 
670
  def _new_game() -> None:
671
- selected = st.session_state.get("selected_wordlist")
672
- mode = st.session_state.get("game_mode")
673
- show_grid_ticks = st.session_state.get("show_grid_ticks", False)
674
- spacer = st.session_state.get("spacer",1)
675
- show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
676
- # --- Preserve music and effects settings ---
677
- music_enabled = st.session_state.get("music_enabled", False)
678
- music_track_path = st.session_state.get("music_track_path")
679
- music_volume = st.session_state.get("music_volume",15)
680
- effects_volume = st.session_state.get("effects_volume",25)
681
- enable_sound_effects = st.session_state.get("enable_sound_effects", True)
682
- # NEW: Preserve Show Challenge Share Links
683
- show_challenge_share_links = st.session_state.get("show_challenge_share_links", True)
684
-
685
- st.session_state.clear()
686
- if selected:
687
- st.session_state.selected_wordlist = selected
688
- if mode:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  st.session_state.game_mode = mode
690
- st.session_state.show_grid_ticks = show_grid_ticks
691
- st.session_state.spacer = spacer
692
- st.session_state.show_incorrect_guesses = show_incorrect_guesses
693
- # --- Restore music/effects settings ---
694
- st.session_state.music_enabled = music_enabled
695
- if music_track_path:
696
- st.session_state.music_track_path = music_track_path
697
- st.session_state.music_volume = music_volume
698
- st.session_state.effects_volume = effects_volume
699
- st.session_state.enable_sound_effects = enable_sound_effects
700
- # NEW: Restore Show Challenge Share Links
701
- st.session_state.show_challenge_share_links = show_challenge_share_links
702
-
703
- st.session_state.start_time = datetime.now() # Reset timer on new game
704
- st.session_state.end_time = None
705
- st.session_state.incorrect_guesses = [] # Clear incorrect guesses for new game
706
- _init_session()
707
 
 
708
 
709
  def _to_state() -> GameState:
710
  return GameState(
@@ -739,11 +773,15 @@ def _sync_back(state: GameState) -> None:
739
  def _render_header():
740
  st.title(f"Wrdler v{version}")
741
 
742
- st.subheader("Choose 2 free letters, then reveal cells and guess words!")
 
 
 
 
743
 
744
  # Only show Challenge Mode expander if in challenge mode and game_id is present
745
  params = st.query_params if hasattr(st, "query_params") else {}
746
- is_challenge_mode = "shared_game_settings" in st.session_state and "game_id" in params
747
 
748
  if is_challenge_mode:
749
  with st.expander("🎯 Challenge Mode (click to expand/collapse)", expanded=True):
@@ -844,7 +882,7 @@ def _render_sidebar():
844
  st.header("SETTINGS")
845
 
846
  st.header("Game Mode")
847
- game_modes = ["classic", "easy", "too easy"] # <-- added "easy"
848
  default_mode = "classic"
849
  if "game_mode" not in st.session_state:
850
  st.session_state.game_mode = default_mode
@@ -858,28 +896,71 @@ def _render_sidebar():
858
  )
859
 
860
  st.header("Wordlist Controls")
 
 
 
 
 
 
 
861
  wordlist_files = get_wordlist_files()
862
-
 
 
 
863
  if wordlist_files:
864
- # Ensure current selection is valid
865
- if st.session_state.get("selected_wordlist") not in wordlist_files:
 
 
 
 
 
866
  st.session_state.selected_wordlist = wordlist_files[0]
 
 
867
 
868
- # Use filenames as options, show without extension
869
- current_index = wordlist_files.index(st.session_state.selected_wordlist)
870
- st.selectbox(
871
  "Select list",
872
- options=wordlist_files,
873
  index=current_index,
874
- format_func=lambda f: f.rsplit(".", 1)[0],
875
- key="selected_wordlist",
876
- on_change=_on_game_option_change, # was _new_game
877
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
 
879
- if st.button("Sort Wordlist", width=125, key="sort_wordlist_btn"):
880
- _sort_wordlist(st.session_state.selected_wordlist)
 
 
881
  else:
882
- st.info("No word lists found in words/ directory. Using built-in fallback.")
 
 
 
 
 
 
 
 
 
 
883
 
884
  # Add Show Grid ticks option
885
  if "show_grid_ticks" not in st.session_state:
@@ -907,6 +988,11 @@ def _render_sidebar():
907
  if "show_challenge_share_links" not in st.session_state:
908
  st.session_state.show_challenge_share_links = False
909
  st.checkbox("Show Challenge Share Links", value=st.session_state.show_challenge_share_links, key="show_challenge_share_links")
 
 
 
 
 
910
 
911
  # Audio settings
912
  st.header("Audio")
@@ -986,6 +1072,23 @@ def _render_sidebar():
986
  # Wrdler uses simplified 8x6 grid with no scope visualization
987
 
988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
989
  def _render_free_letters(state: GameState):
990
  """Render the free letter selection interface."""
991
  if state.free_letters_used >= 2:
@@ -1015,8 +1118,7 @@ def _render_free_letters(state: GameState):
1015
 
1016
  # Use a container to wrap the columns with the custom class
1017
  with st.container(key="free_letter-grid"):
1018
- # st.markdown('<div class="bw-free-letter-grid">', unsafe_allow_html=True)
1019
- cols = st.columns(min(5, len(available_letters)),gap="small")
1020
  for i, letter in enumerate(available_letters):
1021
  col_idx = i % 5
1022
  with cols[col_idx]:
@@ -1024,22 +1126,11 @@ def _render_free_letters(state: GameState):
1024
  letter,
1025
  key=f"free_letter_{letter}",
1026
  use_container_width=True,
1027
- type="primary"
 
 
1028
  ):
1029
- # Reveal this free letter
1030
- count = reveal_free_letter(state, st.session_state.letter_map, letter)
1031
- _sync_back(state)
1032
-
1033
- # Enable guessing after free letter reveal
1034
- st.session_state.can_guess = True
1035
-
1036
- # Play sound effect
1037
- if count > 0:
1038
- play_sound_effect("hit", volume=(st.session_state.get("effects_volume", 50) / 100))
1039
- else:
1040
- play_sound_effect("miss", volume=(st.session_state.get("effects_volume", 50) / 100))
1041
-
1042
- st.rerun()
1043
  st.markdown('</div>', unsafe_allow_html=True)
1044
  def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1045
  # Wrdler: Use rectangular grid dimensions (6 rows × 8 columns)
@@ -1058,8 +1149,8 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1058
  padding: 0 !important;
1059
  }
1060
  button[data-testid="stButton"] {
1061
- width: 40px !important;
1062
- height: 40px !important;
1063
  min-width: 40px !important;
1064
  min-height: 40px !important;
1065
  padding: 0 !important;
@@ -1254,6 +1345,13 @@ def _render_guess_form(state: GameState):
1254
  action = (state.last_action or "").strip()
1255
  if action.startswith("Correct!") or action.startswith("Revealed '"):
1256
  st.session_state.can_guess = True
 
 
 
 
 
 
 
1257
 
1258
  # Prepare tooltip text for native browser tooltip (stack vertically)
1259
  recent_incorrect = st.session_state.incorrect_guesses[-10:]
@@ -1378,7 +1476,10 @@ def _render_score_panel(state: GameState):
1378
  )
1379
  rows_html.append(header_html)
1380
 
1381
- for w in state.puzzle.words:
 
 
 
1382
  pts = state.points_by_word.get(w.text, 0)
1383
  letters_display = len(w.text)
1384
  if pts > 0 or state.game_mode == "too easy":
@@ -1493,8 +1594,6 @@ def _render_score_panel(state: GameState):
1493
  height = 40 + (num_rows * 36)
1494
  components.html(html_doc, height=height, scrolling=False)
1495
 
1496
- # -------------------- Game Over Dialog --------------------
1497
-
1498
  def _game_over_content(state: GameState) -> None:
1499
  # Play congratulations music (not sound effect) as background if enabled
1500
  music_dir = _get_music_dir()
@@ -1752,9 +1851,9 @@ def _game_over_content(state: GameState) -> None:
1752
  st.rerun()
1753
  else:
1754
  st.error("Failed to generate short URL")
1755
-
1756
  except Exception as e:
1757
- st.error(f"Failed to save game: {e}")
1758
  else:
1759
  # Conditionally display the generated share URL
1760
  if st.session_state.get("show_challenge_share_links", False):
@@ -1920,8 +2019,8 @@ def run_app():
1920
  #st.divider()
1921
  _render_score_panel(state)
1922
  with left:
1923
- # Show free letter selection if not complete
1924
- if state.free_letters_used < 2:
1925
  _render_free_letters(state)
1926
 
1927
  _render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
@@ -1968,24 +2067,23 @@ def _on_game_option_change() -> None:
1968
  st.session_state.pop("share_sid", None)
1969
 
1970
  # Start a fresh game with updated options
1971
- # Replace only the shown section in wrdler/ui.py
1972
-
1973
- def _render_guess_form(state: GameState):
1974
- # Initialize incorrect guesses list in session state (safety check)
1975
- if "incorrect_guesses" not in st.session_state:
1976
- st.session_state.incorrect_guesses = []
1977
-
1978
- # Enable guessing after correct guess or reveal per mode
1979
- action = (state.last_action or "").strip()
1980
- if action.startswith("Correct!"):
1981
- st.session_state.can_guess = True
1982
- else:
1983
- if state.game_mode in ("easy", "too easy"):
1984
- if action.startswith("Revealed '") or action.startswith("Revealed empty"):
1985
- st.session_state.can_guess = True
1986
- else:
1987
- if action.startswith("Revealed '"):
1988
- st.session_state.can_guess = True
1989
 
1990
- # ... rest of function unchanged ...
1991
- _new_game()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  from .generator import generate_puzzle, sort_word_file
18
  from .logic import build_letter_map, reveal_cell, reveal_free_letter, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
+ from .word_loader import get_wordlist_files, load_word_list, load_word_list_or_ai, compute_word_difficulties
21
  from .version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
 
356
  .bw-cell {
357
  width: 100%;
358
  gap: 0.1rem;
359
+ aspect-ratio: 16 / 11;
360
  line-height: 1.6;
361
  display: flex;
362
  align-items: center;
 
367
  user-select: none;
368
  padding: 0.5rem 0.75rem;
369
  font-size: 1.4rem;
370
+ min-height: 1.75rem;
371
  min-width: 1.25em;
372
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
373
  background: #1d64c8; /* Base cell color */
 
480
  margin: 0 auto;
481
  text-align: center;
482
  }
483
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 1.75rem;}
484
  .st-key-new_game_btn, .st-key-sort_wordlist_btn { margin: 0 auto; aspect-ratio: unset; }
485
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
486
 
 
490
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
491
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
492
  .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
493
+ .st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 10px; }
494
  .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
495
  .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
496
  .st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
 
501
 
502
  /* grid adjustments */
503
  @media (min-width: 560px){
504
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: calc(100% + 20px) !important;}
505
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
506
+ .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 16 / 11; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
507
+ /*.st-emotion-cache-1n6tfoc { aspect-ratio: 16 / 11; min-height: calc(100% + 20px) !important;}*/
508
  .st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
509
  .st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
510
  aspect-ratio: auto !important;
 
519
 
520
  /* Mobile styles */
521
  @media (max-width: 640px) {
522
+ .bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:60px;}
523
  #bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
524
  #bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { width: 100% !important; min-width: 100% !important; max-width: 100% !important; flex: 1 1 100% !important; }
525
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
 
631
  # Each player has their own uid and word_list in the users array
632
  else:
633
  # Normal game generation (Wrdler: 8 columns × 6 rows)
634
+ # Use unified loader that handles both AI and file modes
635
+ words = load_word_list_or_ai(
636
+ use_ai=st.session_state.get("use_ai_wordlist", False),
637
+ topic=st.session_state.get("ai_topic", "English"),
638
+ selected_file=st.session_state.get("selected_wordlist")
639
+ )
640
  puzzle = generate_puzzle(grid_rows=6, grid_cols=8, words_by_len=words)
641
 
642
  st.session_state.puzzle = puzzle
 
645
  st.session_state.revealed = set()
646
  st.session_state.guessed = set()
647
  st.session_state.score = 0
648
+ # Update welcome message based on free letters setting
649
+ if st.session_state.get("enable_free_letters", False):
650
+ st.session_state.last_action = "Welcome to Wrdler! Choose 2 free letters to start. Guess the words on each line!"
651
+ else:
652
+ st.session_state.last_action = "Welcome to Wrdler! Reveal cells and guess the words on each line!"
653
  st.session_state.can_guess = False
654
  st.session_state.points_by_word = {}
655
  st.session_state.letter_map = build_letter_map(puzzle)
 
677
  st.session_state.free_letters_used = 0
678
 
679
  def _new_game() -> None:
680
+ """
681
+ Create a fresh puzzle using CURRENT settings.
682
+ - Does NOT wipe user preferences.
683
+ - Generates new words if AI mode is active.
684
+ """
685
+ # Read current preference/state values (do not mutate yet)
686
+ use_ai = st.session_state.get("use_ai_wordlist", False)
687
+ selected_file = st.session_state.get("selected_wordlist")
688
+ ai_topic = st.session_state.get("ai_topic", "English")
689
+ spacer = st.session_state.get("spacer", 1)
690
+ mode = st.session_state.get("game_mode", "classic")
691
+ show_grid_ticks = st.session_state.get("show_grid_ticks", False)
692
+ show_incorrect = st.session_state.get("show_incorrect_guesses", True)
693
+ show_challenge_share_links = st.session_state.get("show_challenge_share_links", False) # preserve
694
+
695
+ shared_settings = st.session_state.get("shared_game_settings")
696
+
697
+ # Generate a brand new word source
698
+ if shared_settings:
699
+ # Challenge mode: keep same source, but still get a fresh random selection
700
+ wordlist_source = shared_settings.get("wordlist_source", selected_file or "classic.txt")
701
+ spacer = shared_settings.get("puzzle_options", {}).get("spacer", spacer)
702
+ words = load_word_list(wordlist_source)
703
+ st.session_state.selected_wordlist = wordlist_source
704
+ st.session_state.spacer = spacer
705
+ else:
706
+ words = load_word_list_or_ai(
707
+ use_ai=use_ai,
708
+ topic=ai_topic,
709
+ selected_file=selected_file,
710
+ )
711
+
712
+ puzzle = generate_puzzle(grid_rows=6, grid_cols=8, words_by_len=words, spacer=spacer)
713
+
714
+ # Reset ONLY game progress keys
715
+ st.session_state.puzzle = puzzle
716
+ st.session_state.grid_rows = 6
717
+ st.session_state.grid_cols = 8
718
+ st.session_state.revealed = set()
719
+ st.session_state.guessed = set()
720
+ st.session_state.score = 0
721
+ # Update welcome message based on free letters setting
722
+ if st.session_state.get("enable_free_letters", False):
723
+ st.session_state.last_action = "Welcome to Wrdler! Choose 2 free letters to start."
724
+ else:
725
+ st.session_state.last_action = "Welcome to Wrdler! Reveal cells and guess words!"
726
+ st.session_state.can_guess = False
727
+ st.session_state.points_by_word = {}
728
+ st.session_state.letter_map = build_letter_map(puzzle)
729
+ st.session_state.start_time = datetime.now()
730
+ st.session_state.end_time = None
731
+ st.session_state.incorrect_guesses = []
732
+ st.session_state.free_letters = set()
733
+ st.session_state.free_letters_used = 0
734
+
735
+ # Preserve preferences
736
  st.session_state.game_mode = mode
737
+ st.session_state.show_grid_ticks = show_grid_ticks
738
+ st.session_state.show_incorrect_guesses = show_incorrect
739
+ st.session_state.initialized = True # Prevent _init_session from overwriting
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
 
741
+ # No st.rerun() needed - Streamlit automatically reruns after callback
742
 
743
  def _to_state() -> GameState:
744
  return GameState(
 
773
  def _render_header():
774
  st.title(f"Wrdler v{version}")
775
 
776
+ # Update subtitle based on free letters setting
777
+ if st.session_state.get("enable_free_letters", False):
778
+ st.subheader("Choose 2 free letters, then reveal cells and guess words on each line!")
779
+ else:
780
+ st.subheader("Reveal cells and guess words on each line!")
781
 
782
  # Only show Challenge Mode expander if in challenge mode and game_id is present
783
  params = st.query_params if hasattr(st, "query_params") else {}
784
+ is_challenge_mode = ( "shared_game_settings" in st.session_state and "game_id" in params ) or st.session_state.get("share_sid")
785
 
786
  if is_challenge_mode:
787
  with st.expander("🎯 Challenge Mode (click to expand/collapse)", expanded=True):
 
882
  st.header("SETTINGS")
883
 
884
  st.header("Game Mode")
885
+ game_modes = ["classic", "easy", "too easy"]
886
  default_mode = "classic"
887
  if "game_mode" not in st.session_state:
888
  st.session_state.game_mode = default_mode
 
896
  )
897
 
898
  st.header("Wordlist Controls")
899
+
900
+ # Initialize AI mode settings
901
+ if "use_ai_wordlist" not in st.session_state:
902
+ st.session_state.use_ai_wordlist = False
903
+ if "ai_topic" not in st.session_state:
904
+ st.session_state.ai_topic = "English"
905
+
906
  wordlist_files = get_wordlist_files()
907
+
908
+ # Add AI Generated option to file list
909
+ wordlist_options = ["AI Generated"] + wordlist_files if wordlist_files else ["AI Generated"]
910
+
911
  if wordlist_files:
912
+ # Determine current selection index
913
+ if st.session_state.get("use_ai_wordlist", False):
914
+ current_index = 0 # AI Generated
915
+ elif st.session_state.get("selected_wordlist") in wordlist_files:
916
+ current_index = wordlist_options.index(st.session_state.selected_wordlist)
917
+ else:
918
+ # Default to first file
919
  st.session_state.selected_wordlist = wordlist_files[0]
920
+ st.session_state.use_ai_wordlist = False
921
+ current_index = 1
922
 
923
+ # Wordlist selector
924
+ selected = st.selectbox(
 
925
  "Select list",
926
+ options=wordlist_options,
927
  index=current_index,
928
+ format_func=lambda f: f if f == "AI Generated" else f.rsplit(".", 1)[0],
929
+ key="wordlist_selector",
930
+ on_change=_on_wordlist_change,
931
  )
932
+
933
+ # Show topic input if AI mode selected
934
+ if selected == "AI Generated":
935
+ st.session_state.use_ai_wordlist = True
936
+ st.text_input(
937
+ "Topic",
938
+ value=st.session_state.ai_topic,
939
+ key="ai_topic",
940
+ placeholder="e.g., Ocean Life, Space, History",
941
+ help="Enter a topic for AI-generated words",
942
+ on_change=_on_game_option_change,
943
+ )
944
+ else:
945
+ st.session_state.use_ai_wordlist = False
946
+ st.session_state.selected_wordlist = selected
947
 
948
+ # Only show Sort button for file-based wordlists
949
+ if not st.session_state.use_ai_wordlist:
950
+ if st.button("Sort Wordlist", width=125, key="sort_wordlist_btn"):
951
+ _sort_wordlist(st.session_state.selected_wordlist)
952
  else:
953
+ st.info("No word lists found in words/ directory. Using AI or built-in fallback.")
954
+ # Force AI mode if no files available
955
+ st.session_state.use_ai_wordlist = True
956
+ st.text_input(
957
+ "Topic",
958
+ value=st.session_state.ai_topic,
959
+ key="ai_topic",
960
+ placeholder="e.g., Ocean Life, Space, History",
961
+ help="Enter a topic for AI-generated words",
962
+ on_change=_on_game_option_change,
963
+ )
964
 
965
  # Add Show Grid ticks option
966
  if "show_grid_ticks" not in st.session_state:
 
988
  if "show_challenge_share_links" not in st.session_state:
989
  st.session_state.show_challenge_share_links = False
990
  st.checkbox("Show Challenge Share Links", value=st.session_state.show_challenge_share_links, key="show_challenge_share_links")
991
+
992
+ # NEW: Initialize Enable Free Letters (default OFF)
993
+ if "enable_free_letters" not in st.session_state:
994
+ st.session_state.enable_free_letters = False
995
+ st.checkbox("Enable Free Letters (2 per game)", value=st.session_state.enable_free_letters, key="enable_free_letters")
996
 
997
  # Audio settings
998
  st.header("Audio")
 
1072
  # Wrdler uses simplified 8x6 grid with no scope visualization
1073
 
1074
 
1075
+ def _on_free_letter_click(letter: str, state: GameState) -> None:
1076
+ """Callback for free letter button clicks."""
1077
+ # Reveal this free letter
1078
+ count = reveal_free_letter(state, st.session_state.letter_map, letter)
1079
+ _sync_back(state)
1080
+
1081
+ # Enable guessing after free letter reveal
1082
+ st.session_state.can_guess = True
1083
+
1084
+ # Play sound effect
1085
+ if count > 0:
1086
+ play_sound_effect("hit", volume=(st.session_state.get("effects_volume", 50) / 100))
1087
+ else:
1088
+ play_sound_effect("miss", volume=(st.session_state.get("effects_volume", 50) / 100))
1089
+
1090
+ # No st.rerun() needed - Streamlit automatically reruns after callback
1091
+
1092
  def _render_free_letters(state: GameState):
1093
  """Render the free letter selection interface."""
1094
  if state.free_letters_used >= 2:
 
1118
 
1119
  # Use a container to wrap the columns with the custom class
1120
  with st.container(key="free_letter-grid"):
1121
+ cols = st.columns(min(5, len(available_letters)), gap="small")
 
1122
  for i, letter in enumerate(available_letters):
1123
  col_idx = i % 5
1124
  with cols[col_idx]:
 
1126
  letter,
1127
  key=f"free_letter_{letter}",
1128
  use_container_width=True,
1129
+ type="primary",
1130
+ on_click=_on_free_letter_click,
1131
+ args=(letter, state),
1132
  ):
1133
+ pass # Callback handles everything
 
 
 
 
 
 
 
 
 
 
 
 
 
1134
  st.markdown('</div>', unsafe_allow_html=True)
1135
  def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1136
  # Wrdler: Use rectangular grid dimensions (6 rows × 8 columns)
 
1149
  padding: 0 !important;
1150
  }
1151
  button[data-testid="stButton"] {
1152
+ width: 60px !important;
1153
+ height: 60px !important;
1154
  min-width: 40px !important;
1155
  min-height: 40px !important;
1156
  padding: 0 !important;
 
1345
  action = (state.last_action or "").strip()
1346
  if action.startswith("Correct!") or action.startswith("Revealed '"):
1347
  st.session_state.can_guess = True
1348
+ else:
1349
+ if state.game_mode in ("easy", "too easy"):
1350
+ if action.startswith("Revealed '") or action.startswith("Revealed empty"):
1351
+ st.session_state.can_guess = True
1352
+ else:
1353
+ if action.startswith("Revealed '"):
1354
+ st.session_state.can_guess = True
1355
 
1356
  # Prepare tooltip text for native browser tooltip (stack vertically)
1357
  recent_incorrect = st.session_state.incorrect_guesses[-10:]
 
1476
  )
1477
  rows_html.append(header_html)
1478
 
1479
+ # Sort words by length (ascending order)
1480
+ sorted_words = sorted(state.puzzle.words, key=lambda w: len(w.text))
1481
+
1482
+ for w in sorted_words: # Changed from state.puzzle.words
1483
  pts = state.points_by_word.get(w.text, 0)
1484
  letters_display = len(w.text)
1485
  if pts > 0 or state.game_mode == "too easy":
 
1594
  height = 40 + (num_rows * 36)
1595
  components.html(html_doc, height=height, scrolling=False)
1596
 
 
 
1597
  def _game_over_content(state: GameState) -> None:
1598
  # Play congratulations music (not sound effect) as background if enabled
1599
  music_dir = _get_music_dir()
 
1851
  st.rerun()
1852
  else:
1853
  st.error("Failed to generate short URL")
1854
+
1855
  except Exception as e:
1856
+ st.error(f"Failed to save game: {e}")
1857
  else:
1858
  # Conditionally display the generated share URL
1859
  if st.session_state.get("show_challenge_share_links", False):
 
2019
  #st.divider()
2020
  _render_score_panel(state)
2021
  with left:
2022
+ # Show free letter selection if enabled and not complete
2023
+ if st.session_state.get("enable_free_letters", False) and state.free_letters_used < 2:
2024
  _render_free_letters(state)
2025
 
2026
  _render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
 
2067
  st.session_state.pop("share_sid", None)
2068
 
2069
  # Start a fresh game with updated options
2070
+ _new_game()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2071
 
2072
+ def _on_wordlist_change() -> None:
2073
+ """
2074
+ Callback when wordlist selection changes.
2075
+ Updates session state flags for AI vs file mode.
2076
+ """
2077
+ selected = st.session_state.get("wordlist_selector")
2078
+
2079
+ if selected == "AI Generated":
2080
+ st.session_state.use_ai_wordlist = True
2081
+ # Preserve current AI topic or use default
2082
+ if "ai_topic" not in st.session_state:
2083
+ st.session_state.ai_topic = "English"
2084
+ else:
2085
+ st.session_state.use_ai_wordlist = False
2086
+ st.session_state.selected_wordlist = selected
2087
+
2088
+ # Trigger new game with updated wordlist
2089
+ _on_game_option_change()
wrdler/word_loader.py CHANGED
@@ -12,14 +12,13 @@ from importlib import resources
12
  # Minimal built-ins used if the external file is missing or too small
13
  FALLBACK_WORDS: Dict[int, List[str]] = {
14
  4: [
15
- "TREE", "BOAT", "WIND", "FROG", "LION", "MOON", "FORK", "GLOW", "GAME", "CODE",
16
- "DATA", "BLUE", "GOLD", "ROAD", "STAR",
17
  ],
18
  5: [
19
- "APPLE", "RIVER", "STONE", "PLANT", "MOUSE", "BOARD", "CHAIR", "SCALE", "SMILE", "CLOUD",
20
  ],
21
  6: [
22
- "ORANGE", "PYTHON", "STREAM", "MARKET", "FOREST", "THRIVE", "LOGGER", "BREATH", "DOMAIN", "GALAXY",
23
  ],
24
  }
25
 
@@ -387,4 +386,65 @@ def compute_word_difficulties(file_path, words_array=None):
387
  difficulties[w] = d_w
388
 
389
  total_difficulty = sum(difficulties.values())
390
- return total_difficulty, difficulties
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  # Minimal built-ins used if the external file is missing or too small
13
  FALLBACK_WORDS: Dict[int, List[str]] = {
14
  4: [
15
+ "TREE", "BOAT", "WIND", "FROG", "LION", "MOON", "FORK", "GLOW", "GAME", "CODE", "DATA", "BLUE", "GOLD", "ROAD", "STAR",
 
16
  ],
17
  5: [
18
+ "APPLE", "RIVER", "STONE", "PLANT", "MOUSE", "BOARD", "CHAIR", "SCALE", "SMILE", "CLOUD", "DRONE", "LEVEL", "ZEBRA", "BRAVE", "CROWN"
19
  ],
20
  6: [
21
+ "ORANGE", "PYTHON", "STREAM", "MARKET", "FOREST", "THRIVE", "LOGGER", "BREATH", "DOMAIN", "GALAXY", "BUTTON", "JUNGLE", "PLANET", "PUZZLE", "QUARTZ"
22
  ],
23
  }
24
 
 
386
  difficulties[w] = d_w
387
 
388
  total_difficulty = sum(difficulties.values())
389
+ return total_difficulty, difficulties
390
+
391
+
392
+ @st.cache_data(show_spinner=False)
393
+ def load_word_list_or_ai(
394
+ use_ai: bool = False,
395
+ topic: str = "English",
396
+ selected_file: Optional[str] = None
397
+ ) -> Dict[int, List[str]]:
398
+ """
399
+ Unified word list loader that routes to either file-based or AI-generated words.
400
+
401
+ Parameters:
402
+ use_ai: If True, generate words using AI; otherwise load from file
403
+ topic: Topic for AI generation (only used if use_ai=True)
404
+ selected_file: File to load (only used if use_ai=False)
405
+
406
+ Returns:
407
+ Dictionary mapping word lengths {4, 5, 6} to lists of words
408
+ """
409
+ if use_ai:
410
+ # Import AI module only when needed
411
+ try:
412
+ from .word_loader_ai import generate_ai_words
413
+
414
+ # Generate AI words
415
+ words, difficulties, metadata = generate_ai_words(
416
+ topic=topic,
417
+ use_dictionary_filter=True,
418
+ selected_file=selected_file # Use for dictionary validation
419
+ )
420
+
421
+ # Convert flat list to length-bucketed dictionary
422
+ # AI generates exactly 2 words each of lengths 4, 5, 6
423
+ words_by_len: Dict[int, List[str]] = {4: [], 5: [], 6: []}
424
+ for word in words:
425
+ length = len(word)
426
+ if length in words_by_len:
427
+ words_by_len[length].append(word)
428
+
429
+ # Update session state with AI metadata
430
+ try:
431
+ st.session_state.wordlist_source = f"AI:{topic}"
432
+ st.session_state.wordlist_selected = "AI Generated"
433
+ st.session_state.word_counts = {k: len(v) for k, v in words_by_len.items()}
434
+ st.session_state.ai_metadata = metadata
435
+ except Exception:
436
+ pass
437
+
438
+ return words_by_len
439
+
440
+ except ImportError:
441
+ # If AI module unavailable, fall back to file-based loading
442
+ st.warning("⚠️ AI word generation not available. Falling back to file-based wordlist.")
443
+ return load_word_list(selected_file=selected_file)
444
+ except Exception as e:
445
+ # On any error, fall back gracefully
446
+ st.warning(f"⚠️ AI generation failed: {e}. Using file-based wordlist.")
447
+ return load_word_list(selected_file=selected_file)
448
+ else:
449
+ # Standard file-based loading
450
+ return load_word_list(selected_file=selected_file)
wrdler/word_loader_ai.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import random
5
+ import re
6
+ import string
7
+ import logging
8
+ from datetime import datetime
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ # Configure logging
12
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Streamlit is optional; degrade gracefully if absent
16
+ try:
17
+ import streamlit as st
18
+ except Exception: # pragma: no cover
19
+ class _Stub:
20
+ def cache_resource(self, **_): # type: ignore
21
+ def deco(fn):
22
+ return fn
23
+ return deco
24
+ def cache_data(self, **_): # type: ignore
25
+ def deco(fn):
26
+ return fn
27
+ return deco
28
+ st = _Stub() # type: ignore
29
+
30
+ # Local imports
31
+ from .word_loader import (
32
+ load_word_list,
33
+ FALLBACK_WORDS,
34
+ compute_word_difficulties, # Use current v3 difficulty metric
35
+ )
36
+ from .modules.constants import AI_MODELS, TMPDIR, USE_HF_WORDS, HF_WORD_LIST_REPO_ID
37
+
38
+ # Attempt to import transformers; if unavailable we will fallback to dictionary words only
39
+ try:
40
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
41
+ _TRANSFORMERS_AVAILABLE = True
42
+ except Exception:
43
+ _TRANSFORMERS_AVAILABLE = False
44
+
45
+ # Attempt to import gradio_client for HF Space API calls
46
+ try:
47
+ from gradio_client import Client
48
+ _GRADIO_CLIENT_AVAILABLE = True
49
+ except Exception:
50
+ _GRADIO_CLIENT_AVAILABLE = False
51
+ logger.debug("⚠️ gradio_client not available; HF Space generation disabled.")
52
+
53
+ # Track which model actually loaded
54
+ _USED_MODEL_NAME: Optional[str] = None
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Configuration
58
+ # ---------------------------------------------------------------------------
59
+
60
+ DEFAULT_MODEL_NAME = os.environ.get(
61
+ "WRDLER_AI_MODEL",
62
+ AI_MODELS[0] if AI_MODELS else "meta-llama/Meta-Llama-3-8B-Instruct"
63
+ )
64
+
65
+ # Safety: limit max new tokens to keep latency reasonable (increased to accommodate 75+ words)
66
+ MAX_NEW_TOKENS = int(os.environ.get("WRDLER_AI_MAX_NEW_TOKENS", "512"))
67
+
68
+ # Prompt template: request 75+ words (25+ each of lengths 4,5,6)
69
+ BASE_PROMPT_TEMPLATE = (
70
+ "You are an assistant generating words for a word deduction game.\n"
71
+ "Return AT LEAST 75 UNIQUE WORDS related to the topic: '{topic}'.\n"
72
+ "FORMAT RULES:\n"
73
+ "- Output ONLY a single comma-separated list (no numbering, no extra text)\n"
74
+ "- Include at least: 25 words of length 4 letters, 25 words of length 5 letters, 25 words of length 6 letters\n"
75
+ "- Use ONLY uppercase A-Z letters (no diacritics, hyphens, or spaces)\n"
76
+ "- No duplicates. No explanations.\n"
77
+ "List:"
78
+ )
79
+
80
+ VALID_LENGTHS = (4, 5, 6)
81
+ RE_WORD = re.compile(r"^[A-Z]+$")
82
+ WORDS_PER_LENGTH = 25 # Target 25 words for each length
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # HF Space API Generation
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def _generate_via_hf_space(topic: str) -> Tuple[str, str]:
90
+ """
91
+ Generate words using Hugging Face Space API.
92
+
93
+ Args:
94
+ topic: The topic/theme for word generation
95
+
96
+ Returns:
97
+ Tuple of (raw_output, space_name)
98
+
99
+ Raises:
100
+ Exception: If gradio_client is not available or API call fails
101
+ """
102
+ if not _GRADIO_CLIENT_AVAILABLE:
103
+ raise Exception("gradio_client not installed; install with: pip install gradio_client")
104
+
105
+ prompt = BASE_PROMPT_TEMPLATE.format(topic=topic.upper())
106
+
107
+ try:
108
+ logger.info(f"🌐 Calling HF Space API: {HF_WORD_LIST_REPO_ID}")
109
+ client = Client(HF_WORD_LIST_REPO_ID)
110
+
111
+ result = client.predict(
112
+ message=prompt,
113
+ temperature=0.95,
114
+ max_new_tokens=MAX_NEW_TOKENS,
115
+ api_name="/chat"
116
+ )
117
+
118
+ logger.info(f"✅ HF Space API call successful")
119
+ return result, HF_WORD_LIST_REPO_ID
120
+
121
+ except Exception as e:
122
+ logger.error(f"❌ HF Space API call failed: {e}")
123
+ raise
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Model Loading
128
+ # ---------------------------------------------------------------------------
129
+
130
+ @st.cache_resource(show_spinner=False)
131
+ def _load_model(model_name: str = DEFAULT_MODEL_NAME):
132
+ """
133
+ Try to load the requested model first, then fall back through AI_MODELS in order.
134
+ """
135
+ if not _TRANSFORMERS_AVAILABLE:
136
+ logger.warning("⚠️ Transformers not available; falling back to dictionary words.")
137
+ return None
138
+
139
+ # Build priority list (requested first, then remaining AI_MODELS without duplicates)
140
+ models_to_try: List[str] = []
141
+ if model_name:
142
+ models_to_try.append(model_name)
143
+ models_to_try.extend([m for m in AI_MODELS if m not in models_to_try])
144
+
145
+ for idx, current in enumerate(models_to_try, 1):
146
+ try:
147
+ logger.info(f"🤖 Loading AI model ({idx}/{len(models_to_try)}): {current}")
148
+ import torch
149
+ device = 0 if torch.cuda.is_available() else -1
150
+
151
+ tokenizer = AutoTokenizer.from_pretrained(current)
152
+ model = AutoModelForCausalLM.from_pretrained(
153
+ current,
154
+ torch_dtype="auto",
155
+ device_map="auto" if device == 0 else None,
156
+ )
157
+ gen = pipeline(
158
+ "text-generation",
159
+ model=model,
160
+ tokenizer=tokenizer,
161
+ device=device,
162
+ )
163
+ global _USED_MODEL_NAME
164
+ _USED_MODEL_NAME = current
165
+ logger.info(f"✅ Model loaded successfully: {current}")
166
+ return gen
167
+ except Exception as e:
168
+ logger.error(f"❌ Failed to load model {current}: {e}")
169
+ # try next one
170
+
171
+ logger.error("❌ All AI models failed to load; using dictionary words only.")
172
+ return None
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Parsing / Validation Helpers
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def _extract_words_from_output(prompt: str, raw_output: str) -> List[str]:
180
+ """
181
+ Given full model output, extract comma-separated tail after prompt.
182
+ Fallback to scanning entire output if direct split fails.
183
+ """
184
+ tail = raw_output.split(prompt)[-1]
185
+ # Split by commas, strip, uppercase, filter valid
186
+ candidates = [
187
+ w.strip().upper()
188
+ for w in tail.split(",")
189
+ if w.strip()
190
+ ]
191
+ cleaned = [
192
+ w for w in candidates
193
+ if RE_WORD.fullmatch(w)
194
+ ]
195
+ return cleaned
196
+
197
+
198
+ def _enforce_distribution(words: List[str], wordlist_map: Dict[int, List[str]]) -> List[str]:
199
+ """
200
+ Ensure we have exactly 25 of each required length (4,5,6). Truncate extras.
201
+ Missing slots are filled from dictionary words (wordlist_map), then FALLBACK_WORDS if needed.
202
+
203
+ Args:
204
+ words: List of AI-generated words
205
+ wordlist_map: Dictionary of canonical words by length from load_word_list
206
+
207
+ Returns:
208
+ List of exactly 75 words (25 each of lengths 4, 5, 6)
209
+ """
210
+ by_len: Dict[int, List[str]] = {4: [], 5: [], 6: []}
211
+ for w in words:
212
+ L = len(w)
213
+ if L in by_len and w not in by_len[L]:
214
+ by_len[L].append(w)
215
+
216
+ # Trim to at most 25 each
217
+ for L in VALID_LENGTHS:
218
+ by_len[L] = by_len[L][:WORDS_PER_LENGTH]
219
+
220
+ # Fill missing using dictionary words, then fallback words if still needed
221
+ for L in VALID_LENGTHS:
222
+ if len(by_len[L]) < WORDS_PER_LENGTH:
223
+ needed = WORDS_PER_LENGTH - len(by_len[L])
224
+
225
+ # First try to fill from dictionary (wordlist_map)
226
+ dict_pool = [w for w in wordlist_map[L] if w not in by_len[L]]
227
+ random.shuffle(dict_pool)
228
+ from_dict = dict_pool[:needed]
229
+ by_len[L].extend(from_dict)
230
+
231
+ # If still not enough, use FALLBACK_WORDS
232
+ if len(by_len[L]) < WORDS_PER_LENGTH:
233
+ still_needed = WORDS_PER_LENGTH - len(by_len[L])
234
+ pool = [fw for fw in FALLBACK_WORDS[L] if fw not in by_len[L]]
235
+ random.shuffle(pool)
236
+ by_len[L].extend(pool[:still_needed])
237
+
238
+ if still_needed > 0:
239
+ logger.debug(f"⚠️ Filled {len(from_dict)} words from dictionary and {min(still_needed, len(pool))} from fallback for {L}-letter words.")
240
+
241
+ merged = by_len[4] + by_len[5] + by_len[6]
242
+ # Shuffle final order for game randomness
243
+ random.shuffle(merged)
244
+ return merged
245
+
246
+
247
+ def _filter_and_dedupe(words: List[str]) -> List[str]:
248
+ seen = set()
249
+ result = []
250
+ for w in words:
251
+ if w in seen:
252
+ continue
253
+ if not RE_WORD.fullmatch(w):
254
+ logger.debug(f"⚠️ Filtered invalid word: {w} (contains non-alphabetic characters)")
255
+ continue
256
+ if len(w) not in VALID_LENGTHS:
257
+ logger.debug(f"⚠️ Filtered word: {w} (invalid length {len(w)}, expected 4-6)")
258
+ continue
259
+ seen.add(w)
260
+ result.append(w)
261
+ return result
262
+
263
+
264
+ def _score_words(full_wordlist_path: Optional[str], words: List[str]) -> Dict[str, float]:
265
+ """
266
+ Use existing difficulty metric for the subset derived.
267
+ If file path unavailable, return empty score set.
268
+ """
269
+ if full_wordlist_path is None:
270
+ return {}
271
+ try:
272
+ _, diff_map = compute_word_difficulties(full_wordlist_path, words_array=words)
273
+ return diff_map
274
+ except Exception as e:
275
+ logger.warning(f"⚠️ Could not compute word difficulties: {e}")
276
+ return {}
277
+
278
+
279
+ def _save_ai_words_to_file(topic: str, words: List[str]) -> str:
280
+ """
281
+ Save AI-generated words to a file in the words folder.
282
+ If the file exists, append new words without duplicates and sort.
283
+ Does not add words if the file already has 1000+ words.
284
+
285
+ Args:
286
+ topic: The topic used for generation
287
+ words: List of words to save
288
+
289
+ Returns:
290
+ The filename of the saved file
291
+ """
292
+ from .generator import sort_word_file
293
+
294
+ # Create safe filename from topic
295
+ safe_topic = re.sub(r'[^\w\s-]', '', topic.lower()).strip()
296
+ safe_topic = re.sub(r'[-\s]+', '_', safe_topic)
297
+ filename = f"{safe_topic}.txt"
298
+
299
+ wordlist_dir = os.path.join(os.path.dirname(__file__), "words")
300
+ os.makedirs(wordlist_dir, exist_ok=True)
301
+ filepath = os.path.join(wordlist_dir, filename)
302
+
303
+ # Load existing words if file exists
304
+ existing_words = set()
305
+ if os.path.isfile(filepath):
306
+ try:
307
+ with open(filepath, "r", encoding="utf-8") as f:
308
+ for line in f:
309
+ line = line.strip()
310
+ if line and not line.startswith("#"):
311
+ existing_words.add(line.upper())
312
+ logger.info(f"📂 Found existing file {filename} with {len(existing_words)} words")
313
+
314
+ # Check if file already has 1000+ words
315
+ if len(existing_words) >= 1000:
316
+ logger.info(f"ℹ️ File {filename} already has {len(existing_words)} words (≥1000). Not adding new words.")
317
+ return filename
318
+
319
+ except Exception as e:
320
+ logger.warning(f"⚠️ Error reading existing file {filename}: {e}")
321
+
322
+ # Combine new and existing words (deduplicate)
323
+ all_words = existing_words.union(set(words))
324
+ new_words_count = len(all_words) - len(existing_words)
325
+
326
+ if new_words_count > 0:
327
+ logger.info(f"💾 Adding {new_words_count} new words to {filename}")
328
+
329
+ # Write to file
330
+ try:
331
+ with open(filepath, "w", encoding="utf-8") as f:
332
+ # Write header
333
+ f.write("# AI-generated word list\n")
334
+ f.write(f"# Topic: {topic}\n")
335
+ f.write(f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
336
+ f.write("# Format: one word per line, uppercase A-Z only\n")
337
+ f.write("#\n")
338
+
339
+ # Write all words (unsorted first)
340
+ for word in sorted(all_words):
341
+ f.write(f"{word}\n")
342
+
343
+ # Now sort the file using existing sort_word_file function
344
+ sorted_words = sort_word_file(filepath)
345
+
346
+ # Rewrite with sorted words
347
+ with open(filepath, "w", encoding="utf-8") as f:
348
+ # Write header again
349
+ f.write("# AI-generated word list\n")
350
+ f.write(f"# Topic: {topic}\n")
351
+ f.write(f"# Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
352
+ f.write(f"# Total words: {len(sorted_words)}\n")
353
+ f.write("# Format: one word per line, sorted by length then alphabetically\n")
354
+ f.write("#\n")
355
+
356
+ # Write sorted words
357
+ for word in sorted_words:
358
+ f.write(f"{word}\n")
359
+
360
+ logger.info(f"✅ Successfully saved and sorted {len(all_words)} words to {filename}")
361
+ return filename
362
+
363
+ except Exception as e:
364
+ logger.error(f"❌ Error saving words to {filename}: {e}")
365
+ return ""
366
+ else:
367
+ logger.info(f"ℹ️ No new words to add to {filename}")
368
+ return filename
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Public Generation Function
373
+ # ---------------------------------------------------------------------------
374
+
375
+ def generate_ai_words(
376
+ topic: str = "English",
377
+ model_name: Optional[str] = None,
378
+ seed: Optional[int] = None,
379
+ use_dictionary_filter: bool = True,
380
+ selected_file: Optional[str] = None,
381
+ ) -> Tuple[List[str], Dict[str, float], Dict[str, str]]:
382
+ """
383
+ Generate 75 AI-selected words (25 each of lengths 4,5,6) related to a topic and
384
+ validate them against game constraints.
385
+
386
+ Returns:
387
+ words: List[str] - Final 75 words (uppercase A–Z).
388
+ difficulties: Dict[str,float] - Difficulty scores using compute_word_difficulties().
389
+ metadata: Dict[str,str] - Source / diagnostic info.
390
+
391
+ Parameters:
392
+ topic: Semantic theme for generation.
393
+ model_name: Override default model.
394
+ seed: Optional RNG seed for reproducibility.
395
+ use_dictionary_filter: If True, intersect with loaded dictionary for extra validation.
396
+ selected_file: Word list file name to pass to load_word_list for dictionary context.
397
+ """
398
+ if seed is not None:
399
+ random.seed(seed)
400
+
401
+ logger.info(f"🎮 Generating AI words for topic: {topic}")
402
+
403
+ # Load dictionary (game canonical word list)
404
+ wordlist_map = load_word_list(selected_file=selected_file)
405
+ canonical_set = set(wordlist_map[4] + wordlist_map[5] + wordlist_map[6])
406
+
407
+ raw_generated_text = ""
408
+ ai_words: List[str] = []
409
+ generation_source = "none"
410
+
411
+ # Check if USE_HF_WORDS is enabled
412
+ if USE_HF_WORDS:
413
+ # Try HF Space API first
414
+ try:
415
+ raw_generated_text, generation_source = _generate_via_hf_space(topic)
416
+ prompt = BASE_PROMPT_TEMPLATE.format(topic=topic.upper())
417
+ parsed = _extract_words_from_output(prompt, raw_generated_text)
418
+ logger.debug(f"Parsed {len(parsed)} words from HF Space output")
419
+ ai_words = _filter_and_dedupe(parsed)
420
+ if len(ai_words) > 0:
421
+ logger.info(f"✅ HF Space generated {len(ai_words)} valid words")
422
+ else:
423
+ logger.warning(f"⚠️ HF Space generated no valid words; falling back to local model.")
424
+ # Fall through to local model
425
+ except Exception as e:
426
+ logger.error(f"❌ HF Space generation failed: {e}")
427
+ logger.info("🔄 Falling back to local model generation.")
428
+ # Fall through to local model
429
+
430
+ # If HF Space not used or failed, try local model
431
+ if not ai_words:
432
+ generator = _load_model(model_name or DEFAULT_MODEL_NAME)
433
+
434
+ if generator is not None:
435
+ prompt = BASE_PROMPT_TEMPLATE.format(topic=topic.upper())
436
+ try:
437
+ logger.info(f"📝 Generating words from local AI model...")
438
+ outputs = generator(
439
+ prompt,
440
+ max_new_tokens=MAX_NEW_TOKENS,
441
+ num_return_sequences=1,
442
+ temperature=0.7,
443
+ do_sample=True,
444
+ )
445
+ raw_generated_text = outputs[0]["generated_text"]
446
+ logger.debug(f"Raw AI output: {raw_generated_text[:200]}")
447
+ parsed = _extract_words_from_output(prompt, raw_generated_text)
448
+ logger.debug(f"Parsed {len(parsed)} words from AI output")
449
+ ai_words = _filter_and_dedupe(parsed)
450
+ generation_source = _USED_MODEL_NAME or "local_model"
451
+ if len(ai_words) > 0:
452
+ logger.info(f"✅ AI generated {len(ai_words)} valid words")
453
+ else:
454
+ logger.warning(f"⚠️ AI generated no valid words; using fallback dictionary.")
455
+ except Exception as e:
456
+ logger.error(f"❌ Error during AI word generation: {e}")
457
+ logger.info("🔄 Falling back to dictionary-based word selection.")
458
+ ai_words = []
459
+ else:
460
+ # Transformers not available; we will fallback entirely
461
+ logger.info("🔄 Transformers unavailable; using fallback dictionary words.")
462
+ ai_words = []
463
+
464
+ # CORRECT ORDER:
465
+ # 1. FIRST identify and save new words (before any filtering)
466
+ new_words_to_save: List[str] = []
467
+ if ai_words:
468
+ existing_words = [w for w in ai_words if w in canonical_set]
469
+ new_words_to_save = [w for w in ai_words if w not in canonical_set]
470
+
471
+ logger.info(f"📊 Word analysis: {len(ai_words)} total = {len(existing_words)} existing + {len(new_words_to_save)} NEW")
472
+
473
+ # Save the NEW words to expand the dictionary
474
+ if new_words_to_save:
475
+ saved_filename = _save_ai_words_to_file(topic, new_words_to_save)
476
+ if saved_filename:
477
+ logger.info(f"💾 Saved {len(new_words_to_save)} NEW words to {saved_filename}")
478
+
479
+ # 2. THEN apply dictionary filter if requested (for game word selection)
480
+ if use_dictionary_filter and ai_words:
481
+ before_filter = len(ai_words)
482
+ filtered_out_words = [w for w in ai_words if w not in canonical_set]
483
+ ai_words = [w for w in ai_words if w in canonical_set]
484
+ filtered_out_count = len(filtered_out_words)
485
+
486
+ logger.info(f"📊 Dictionary filter: {before_filter} → {len(ai_words)} words (removed {filtered_out_count})")
487
+ if filtered_out_words:
488
+ # Group by length for better readability
489
+ by_len = {4: [], 5: [], 6: [], 'other': []}
490
+ for w in filtered_out_words:
491
+ length = len(w)
492
+ if length in by_len:
493
+ by_len[length].append(w)
494
+ else:
495
+ by_len['other'].append(w)
496
+
497
+ logger.info(f"🚫 Filtered out words NOT in dictionary:")
498
+ for length in [4, 5, 6, 'other']:
499
+ if by_len[length]:
500
+ logger.info(f" {length}-letter: {', '.join(sorted(by_len[length]))}")
501
+
502
+ # Use ALL AI-generated words for the game (no filtering)
503
+ # The use_dictionary_filter parameter is kept for backward compatibility but ignored
504
+ if use_dictionary_filter:
505
+ logger.debug(f"ℹ️ Dictionary filter parameter ignored - using all {len(ai_words)} AI-generated words")
506
+
507
+ # Enforce distribution and fill gaps with dictionary or fallback words
508
+ final_words = _enforce_distribution(ai_words, wordlist_map)
509
+
510
+ if len(final_words) < 75:
511
+ logger.error(f"❌ GENERATION FAILED: Could not generate 75 words (got {len(final_words)})")
512
+ logger.error(f" Topic: {topic}")
513
+ logger.error(f" AI Words Before Distribution: {ai_words}")
514
+ logger.error(f" Final Words After Distribution: {len(final_words)} words")
515
+ else:
516
+ logger.info(f"✅ Successfully generated 75 words: {sum(1 for w in final_words if len(w)==4)} x 4-letter, {sum(1 for w in final_words if len(w)==5)} x 5-letter, {sum(1 for w in final_words if len(w)==6)} x 6-letter")
517
+
518
+ # Difficulty scoring (requires path to underlying corpus file if available)
519
+ # We reuse the selected_file path if present and resolvable.
520
+ wordlist_dir = os.path.join(os.path.dirname(__file__), "words")
521
+ file_path = None
522
+ if selected_file:
523
+ candidate_path = os.path.join(wordlist_dir, selected_file)
524
+ if os.path.isfile(candidate_path):
525
+ file_path = candidate_path
526
+ else:
527
+ default_path = os.path.join(wordlist_dir, "wordlist.txt")
528
+ if os.path.isfile(default_path):
529
+ file_path = default_path
530
+
531
+ difficulties = _score_words(file_path, final_words)
532
+
533
+ metadata = {
534
+ "model_used": generation_source,
535
+ "transformers_available": str(_TRANSFORMERS_AVAILABLE),
536
+ "gradio_client_available": str(_GRADIO_CLIENT_AVAILABLE),
537
+ "use_hf_words": str(USE_HF_WORDS),
538
+ "raw_output_length": str(len(raw_generated_text)),
539
+ "raw_output_snippet": raw_generated_text[:180].replace("\n", " "),
540
+ "ai_initial_count": str(len(ai_words)),
541
+ "topic": topic,
542
+ "dictionary_filter": str(use_dictionary_filter),
543
+ "new_words_saved": str(len(new_words_to_save)),
544
+ }
545
+
546
+ return final_words, difficulties, metadata
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # Convenience: simple wrapper for external callers
551
+ # ---------------------------------------------------------------------------
552
+
553
+ def get_ai_words(topic: str, selected_file: Optional[str] = None) -> List[str]:
554
+ """
555
+ Lightweight convenience returning only the final 75 words.
556
+ """
557
+ words, _, _ = generate_ai_words(topic=topic, selected_file=selected_file)
558
+ return words
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # Manual test (can be invoked when running this module directly)
563
+ # ---------------------------------------------------------------------------
564
+
565
+ if __name__ == "__main__": # pragma: no cover
566
+ logger.info("🧪 Running manual AI word generation test...")
567
+ w, diff, meta = generate_ai_words(topic="FOREST ECOLOGY")
568
+ logger.info(f"Generated Words ({len(w)}): {w}")
569
+ logger.info(f"Difficulties: {diff}")
570
+ logger.info(f"Metadata: {meta}")
571
+
572
+ hf_root = os.path.join(TMPDIR, "hf-cache")
573
+ os.makedirs(hf_root, exist_ok=True)
574
+
575
+ os.environ.setdefault("HF_HOME", hf_root) # preferred (models end up in $HF_HOME/hub)
576
+ os.environ.setdefault("HUGGINGFACE_HUB_CACHE", os.path.join(hf_root, "hub"))
577
+ os.environ.setdefault("TRANSFORMERS_CACHE", os.path.join(hf_root, "transformers"))
wrdler/words/english.txt ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI-generated word list
2
+ # Topic: EnglisH
3
+ # Last updated: 2025-11-24 09:23:12
4
+ # Total words: 336
5
+ # Format: one word per line, sorted by length then alphabetically
6
+ #
7
+ ALMS
8
+ BAIL
9
+ BAIT
10
+ BALE
11
+ BANE
12
+ BANK
13
+ BARN
14
+ BASE
15
+ BEAN
16
+ BELL
17
+ BEND
18
+ BORE
19
+ BORN
20
+ BUNK
21
+ BURN
22
+ CAKE
23
+ CARE
24
+ CLIP
25
+ CODE
26
+ DARE
27
+ DEAR
28
+ DENT
29
+ DINE
30
+ DUEL
31
+ DUES
32
+ EARS
33
+ EASE
34
+ EATS
35
+ ECHO
36
+ EMIT
37
+ FELL
38
+ FILE
39
+ FINE
40
+ FIRE
41
+ FISH
42
+ FLAP
43
+ FLAT
44
+ GAIN
45
+ GAME
46
+ GEMS
47
+ GENT
48
+ GIRD
49
+ GIVE
50
+ GLOB
51
+ HALE
52
+ HONE
53
+ HOSE
54
+ IRON
55
+ LAKE
56
+ LAND
57
+ LANE
58
+ LASH
59
+ LATE
60
+ LEAD
61
+ LEAN
62
+ LEAP
63
+ LEND
64
+ LIFE
65
+ LINE
66
+ LION
67
+ LIST
68
+ LODE
69
+ MINE
70
+ NAIL
71
+ OILY
72
+ PACE
73
+ PAIN
74
+ POET
75
+ PORE
76
+ PORT
77
+ POUR
78
+ RAKE
79
+ READ
80
+ REAL
81
+ RENT
82
+ RIDE
83
+ RISE
84
+ ROAM
85
+ ROPS
86
+ SEAL
87
+ SEAM
88
+ SINE
89
+ SIRE
90
+ SLAB
91
+ SLIT
92
+ SLUG
93
+ SORE
94
+ STEM
95
+ TAGS
96
+ TAIL
97
+ TALE
98
+ TIME
99
+ TYPE
100
+ WALK
101
+ WINE
102
+ WIRE
103
+ WISH
104
+ WORD
105
+ YELL
106
+ ZONE
107
+ ALICE
108
+ ALIKE
109
+ BANKS
110
+ BEAST
111
+ BLANK
112
+ BLIND
113
+ BOAST
114
+ BRAID
115
+ BRAKE
116
+ BRASH
117
+ BRAVE
118
+ BRICK
119
+ BRINE
120
+ BRING
121
+ BRINK
122
+ BROAD
123
+ BROWN
124
+ BRUSH
125
+ BRUTE
126
+ BULGE
127
+ BUNCH
128
+ BURNS
129
+ BURST
130
+ CABLE
131
+ CAUSE
132
+ CHEER
133
+ CLAIM
134
+ CLANG
135
+ CLASP
136
+ CLOUD
137
+ CODES
138
+ COLOR
139
+ CRASH
140
+ CROSS
141
+ DANCE
142
+ DARES
143
+ DEALT
144
+ DROPS
145
+ EARLY
146
+ EASES
147
+ FANCY
148
+ FENCE
149
+ FIELD
150
+ FLAIR
151
+ FLAKE
152
+ FLAPY
153
+ FLARE
154
+ FLICK
155
+ FLOOD
156
+ FLOOR
157
+ FLOWE
158
+ FLOWS
159
+ FLUNG
160
+ FLUSH
161
+ FLUTE
162
+ FOLKS
163
+ FRAYS
164
+ FRESH
165
+ FRIED
166
+ FRILL
167
+ FROGS
168
+ FROWS
169
+ FROZE
170
+ FRUIT
171
+ GAINS
172
+ GAMEY
173
+ GLARE
174
+ GLEAM
175
+ GLOBE
176
+ GLOOM
177
+ GLOVE
178
+ GRADE
179
+ GRANT
180
+ GRAZE
181
+ GROVE
182
+ GUILT
183
+ GUNGE
184
+ IDEAL
185
+ JABBY
186
+ JELLY
187
+ JOTTY
188
+ JUDGE
189
+ KNELL
190
+ KNITS
191
+ KNURL
192
+ LAIRS
193
+ LAMPS
194
+ LATCH
195
+ LAUGH
196
+ LAYER
197
+ LEVER
198
+ LINED
199
+ LISTO
200
+ LIVED
201
+ LIVES
202
+ LOOKS
203
+ LOOMS
204
+ LOREY
205
+ LOUDS
206
+ LOYAL
207
+ LUNCH
208
+ LURID
209
+ LURKY
210
+ MAIZE
211
+ MANTO
212
+ MAPLE
213
+ MARKS
214
+ MASTO
215
+ MOULD
216
+ MURAL
217
+ NAMED
218
+ NERVE
219
+ NEWSY
220
+ NICER
221
+ NINES
222
+ NODAL
223
+ NODES
224
+ NURSE
225
+ OMITS
226
+ OUTGO
227
+ PEERS
228
+ PINKO
229
+ PINTS
230
+ PLAYS
231
+ PLOOM
232
+ PLUTO
233
+ POLLS
234
+ POOPS
235
+ PORED
236
+ PORES
237
+ PORTS
238
+ PRAIR
239
+ PRATE
240
+ PRICK
241
+ PRISM
242
+ PRIVE
243
+ PRIZE
244
+ PROPS
245
+ PULPS
246
+ PUNGY
247
+ PURTY
248
+ RAINS
249
+ RAMPS
250
+ RANCH
251
+ RANTS
252
+ REACH
253
+ READS
254
+ REALM
255
+ REAPS
256
+ REEDS
257
+ REELD
258
+ REGAL
259
+ RENTS
260
+ REPLA
261
+ REPOS
262
+ REPOT
263
+ RESEW
264
+ RESIN
265
+ RESTO
266
+ RETRO
267
+ REXED
268
+ REXER
269
+ REXES
270
+ REXIS
271
+ REYED
272
+ RHIME
273
+ RINGS
274
+ RISES
275
+ RIVER
276
+ ROAMS
277
+ ROAST
278
+ ROBES
279
+ ROBSO
280
+ ROCKS
281
+ RODER
282
+ RODIN
283
+ ROLES
284
+ ROMAN
285
+ ROODY
286
+ ROOMS
287
+ ROSEY
288
+ ROUPS
289
+ ROWED
290
+ ROWLS
291
+ RUDER
292
+ RULER
293
+ RUMPS
294
+ RUNES
295
+ RUNGS
296
+ RUPES
297
+ RUPTS
298
+ RUSES
299
+ RUSHY
300
+ RUTTY
301
+ RUYSY
302
+ SAINT
303
+ SANDS
304
+ SAPOR
305
+ SAREE
306
+ SAVAN
307
+ SAVOR
308
+ SAXES
309
+ SCALE
310
+ SCENT
311
+ SCRAM
312
+ SCREE
313
+ SCULL
314
+ SHARE
315
+ SHINE
316
+ SPACE
317
+ SPOON
318
+ STAGE
319
+ STAIR
320
+ STALK
321
+ STICK
322
+ SUNNY
323
+ VOICE
324
+ WAIST
325
+ WASTE
326
+ AUTHOR
327
+ BEACON
328
+ BRIDGE
329
+ BRIGHT
330
+ CASTLE
331
+ DINNER
332
+ EMPIRE
333
+ FLOWER
334
+ FOREST
335
+ GENTLE
336
+ HAMMER
337
+ ISLAND
338
+ LENGTH
339
+ PRAISE
340
+ REASON
341
+ REFORM
342
+ REYLES