Spaces:
Running
Running
Add EASY MODE and fix wordlist selections
Browse files- battlewords/logic.py +4 -1
- battlewords/models.py +1 -0
- battlewords/ui.py +26 -2
- battlewords/word_loader.py +38 -10
battlewords/logic.py
CHANGED
|
@@ -64,7 +64,10 @@ def guess_word(state: GameState, guess_text: str) -> Tuple[bool, int]:
|
|
| 64 |
state.guessed.add(target.text)
|
| 65 |
|
| 66 |
state.last_action = f"Correct! +{points} points for {target.text}."
|
| 67 |
-
state.
|
|
|
|
|
|
|
|
|
|
| 68 |
return True, points
|
| 69 |
|
| 70 |
|
|
|
|
| 64 |
state.guessed.add(target.text)
|
| 65 |
|
| 66 |
state.last_action = f"Correct! +{points} points for {target.text}."
|
| 67 |
+
if state.game_mode == "standard":
|
| 68 |
+
state.can_guess = True # <-- Allow another guess after a correct guess
|
| 69 |
+
else:
|
| 70 |
+
state.can_guess = False
|
| 71 |
return True, points
|
| 72 |
|
| 73 |
|
battlewords/models.py
CHANGED
|
@@ -67,4 +67,5 @@ class GameState:
|
|
| 67 |
score: int
|
| 68 |
last_action: str
|
| 69 |
can_guess: bool
|
|
|
|
| 70 |
points_by_word: Dict[str, int] = field(default_factory=dict)
|
|
|
|
| 67 |
score: int
|
| 68 |
last_action: str
|
| 69 |
can_guess: bool
|
| 70 |
+
game_mode: Literal["standard", "easy"] = "standard"
|
| 71 |
points_by_word: Dict[str, int] = field(default_factory=dict)
|
battlewords/ui.py
CHANGED
|
@@ -221,6 +221,8 @@ def _init_session() -> None:
|
|
| 221 |
files = get_wordlist_files()
|
| 222 |
if "selected_wordlist" not in st.session_state and files:
|
| 223 |
st.session_state.selected_wordlist = "wordlist.txt"
|
|
|
|
|
|
|
| 224 |
|
| 225 |
words = load_word_list(st.session_state.get("selected_wordlist"))
|
| 226 |
puzzle = generate_puzzle(grid_size=12, words_by_len=words)
|
|
@@ -236,12 +238,19 @@ def _init_session() -> None:
|
|
| 236 |
st.session_state.letter_map = build_letter_map(puzzle)
|
| 237 |
st.session_state.initialized = True
|
| 238 |
st.session_state.radar_gif_path = None # Add this line
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
def _new_game() -> None:
|
| 241 |
selected = st.session_state.get("selected_wordlist")
|
|
|
|
| 242 |
st.session_state.clear()
|
| 243 |
if selected:
|
| 244 |
st.session_state.selected_wordlist = selected
|
|
|
|
|
|
|
| 245 |
st.session_state.radar_gif_path = None # Reset radar GIF path
|
| 246 |
_init_session()
|
| 247 |
|
|
@@ -255,6 +264,7 @@ def _to_state() -> GameState:
|
|
| 255 |
score=st.session_state.score,
|
| 256 |
last_action=st.session_state.last_action,
|
| 257 |
can_guess=st.session_state.can_guess,
|
|
|
|
| 258 |
points_by_word=st.session_state.points_by_word,
|
| 259 |
)
|
| 260 |
|
|
@@ -288,7 +298,21 @@ def _render_sidebar():
|
|
| 288 |
"- Radar pulses show the last letter position of each hidden word.\n"
|
| 289 |
"- After each reveal, you may submit one word guess below.\n"
|
| 290 |
"- Scoring: length + unrevealed letters of that word at guess time.")
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
st.header("Wordlist Controls")
|
| 293 |
wordlist_files = get_wordlist_files()
|
| 294 |
|
|
@@ -620,7 +644,7 @@ def _render_score_panel(state: GameState):
|
|
| 620 |
with st.expander("Game summary", expanded=True):
|
| 621 |
for w in state.puzzle.words:
|
| 622 |
pts = state.points_by_word.get(w.text, 0)
|
| 623 |
-
if pts > 0:
|
| 624 |
st.markdown(f"- {w.text} ({len(w.text)}): +{pts} points")
|
| 625 |
st.markdown(f"**Total**: {state.score}")
|
| 626 |
|
|
|
|
| 221 |
files = get_wordlist_files()
|
| 222 |
if "selected_wordlist" not in st.session_state and files:
|
| 223 |
st.session_state.selected_wordlist = "wordlist.txt"
|
| 224 |
+
if "game_mode" not in st.session_state:
|
| 225 |
+
st.session_state.game_mode = "standard"
|
| 226 |
|
| 227 |
words = load_word_list(st.session_state.get("selected_wordlist"))
|
| 228 |
puzzle = generate_puzzle(grid_size=12, words_by_len=words)
|
|
|
|
| 238 |
st.session_state.letter_map = build_letter_map(puzzle)
|
| 239 |
st.session_state.initialized = True
|
| 240 |
st.session_state.radar_gif_path = None # Add this line
|
| 241 |
+
# Ensure game_mode is set
|
| 242 |
+
if "game_mode" not in st.session_state:
|
| 243 |
+
st.session_state.game_mode = "standard"
|
| 244 |
+
|
| 245 |
|
| 246 |
def _new_game() -> None:
|
| 247 |
selected = st.session_state.get("selected_wordlist")
|
| 248 |
+
mode = st.session_state.get("game_mode")
|
| 249 |
st.session_state.clear()
|
| 250 |
if selected:
|
| 251 |
st.session_state.selected_wordlist = selected
|
| 252 |
+
if mode:
|
| 253 |
+
st.session_state.game_mode = mode
|
| 254 |
st.session_state.radar_gif_path = None # Reset radar GIF path
|
| 255 |
_init_session()
|
| 256 |
|
|
|
|
| 264 |
score=st.session_state.score,
|
| 265 |
last_action=st.session_state.last_action,
|
| 266 |
can_guess=st.session_state.can_guess,
|
| 267 |
+
game_mode=st.session_state.get("game_mode", "standard"),
|
| 268 |
points_by_word=st.session_state.points_by_word,
|
| 269 |
)
|
| 270 |
|
|
|
|
| 298 |
"- Radar pulses show the last letter position of each hidden word.\n"
|
| 299 |
"- After each reveal, you may submit one word guess below.\n"
|
| 300 |
"- Scoring: length + unrevealed letters of that word at guess time.")
|
| 301 |
+
|
| 302 |
+
st.header("Game Mode")
|
| 303 |
+
game_modes = ["standard", "easy"]
|
| 304 |
+
default_mode = "standard"
|
| 305 |
+
if "game_mode" not in st.session_state:
|
| 306 |
+
st.session_state.game_mode = default_mode
|
| 307 |
+
current_mode = st.session_state.game_mode
|
| 308 |
+
st.selectbox(
|
| 309 |
+
"Select game mode",
|
| 310 |
+
options=game_modes,
|
| 311 |
+
index=game_modes.index(current_mode) if current_mode in game_modes else 0,
|
| 312 |
+
key="game_mode",
|
| 313 |
+
on_change=_new_game,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
st.header("Wordlist Controls")
|
| 317 |
wordlist_files = get_wordlist_files()
|
| 318 |
|
|
|
|
| 644 |
with st.expander("Game summary", expanded=True):
|
| 645 |
for w in state.puzzle.words:
|
| 646 |
pts = state.points_by_word.get(w.text, 0)
|
| 647 |
+
if pts > 0 or state.game_mode=="easy":
|
| 648 |
st.markdown(f"- {w.text} ({len(w.text)}): +{pts} points")
|
| 649 |
st.markdown(f"**Total**: {state.score}")
|
| 650 |
|
battlewords/word_loader.py
CHANGED
|
@@ -22,6 +22,9 @@ FALLBACK_WORDS: Dict[int, List[str]] = {
|
|
| 22 |
],
|
| 23 |
}
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
def get_wordlist_files() -> list[str]:
|
| 26 |
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 27 |
if not os.path.isdir(words_dir):
|
|
@@ -29,16 +32,20 @@ def get_wordlist_files() -> list[str]:
|
|
| 29 |
files = [f for f in os.listdir(words_dir) if f.lower().endswith(".txt")]
|
| 30 |
return sorted(files)
|
| 31 |
|
|
|
|
| 32 |
@st.cache_data(show_spinner=False)
|
| 33 |
def load_word_list(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
|
| 34 |
"""
|
| 35 |
Load a word list, filter to uppercase A–Z, lengths in {4,5,6}, and dedupe while preserving order.
|
| 36 |
|
| 37 |
If `selected_file` is provided, load battlewords/words/<selected_file>.
|
| 38 |
-
Otherwise, try
|
| 39 |
|
| 40 |
If fewer than 500 entries exist for any required length, fall back to built-ins
|
| 41 |
for that length (per specs).
|
|
|
|
|
|
|
|
|
|
| 42 |
"""
|
| 43 |
words_by_len: Dict[int, List[str]] = {4: [], 5: [], 6: []}
|
| 44 |
used_source = "fallback"
|
|
@@ -58,15 +65,36 @@ def load_word_list(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
|
|
| 58 |
with open(path, "r", encoding="utf-8") as f:
|
| 59 |
return f.read()
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
try:
|
| 62 |
text: Optional[str] = None
|
|
|
|
| 63 |
|
| 64 |
if selected_file:
|
| 65 |
-
#
|
|
|
|
|
|
|
|
|
|
| 66 |
text = _read_text_from_disk(selected_file)
|
|
|
|
| 67 |
else:
|
| 68 |
-
|
| 69 |
-
text
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
seen = {4: set(), 5: set(), 6: set()}
|
| 72 |
for raw in text.splitlines():
|
|
@@ -84,17 +112,17 @@ def load_word_list(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
|
|
| 84 |
seen[L].add(word)
|
| 85 |
|
| 86 |
counts = {k: len(v) for k, v in words_by_len.items()}
|
| 87 |
-
if all(counts[k] >=
|
| 88 |
-
used_source =
|
| 89 |
return _finalize(words_by_len, used_source)
|
| 90 |
|
| 91 |
# Per spec: fallback for any length below threshold
|
| 92 |
mixed: Dict[int, List[str]] = {
|
| 93 |
-
4: words_by_len[4] if counts[4] >=
|
| 94 |
-
5: words_by_len[5] if counts[5] >=
|
| 95 |
-
6: words_by_len[6] if counts[6] >=
|
| 96 |
}
|
| 97 |
-
used_source = "
|
| 98 |
return _finalize(mixed, used_source)
|
| 99 |
|
| 100 |
except Exception:
|
|
|
|
| 22 |
],
|
| 23 |
}
|
| 24 |
|
| 25 |
+
MIN_REQUIRED = 25 # Per specs: require >= 500 per length before using file contents
|
| 26 |
+
|
| 27 |
+
|
| 28 |
def get_wordlist_files() -> list[str]:
|
| 29 |
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 30 |
if not os.path.isdir(words_dir):
|
|
|
|
| 32 |
files = [f for f in os.listdir(words_dir) if f.lower().endswith(".txt")]
|
| 33 |
return sorted(files)
|
| 34 |
|
| 35 |
+
|
| 36 |
@st.cache_data(show_spinner=False)
|
| 37 |
def load_word_list(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
|
| 38 |
"""
|
| 39 |
Load a word list, filter to uppercase A–Z, lengths in {4,5,6}, and dedupe while preserving order.
|
| 40 |
|
| 41 |
If `selected_file` is provided, load battlewords/words/<selected_file>.
|
| 42 |
+
Otherwise, try on-disk default battlewords/words/wordlist.txt; if unavailable, try packaged resource.
|
| 43 |
|
| 44 |
If fewer than 500 entries exist for any required length, fall back to built-ins
|
| 45 |
for that length (per specs).
|
| 46 |
+
|
| 47 |
+
NOTE: To ensure cache updates when the user picks a different file, always pass
|
| 48 |
+
the `selected_file` argument from the UI/generator.
|
| 49 |
"""
|
| 50 |
words_by_len: Dict[int, List[str]] = {4: [], 5: [], 6: []}
|
| 51 |
used_source = "fallback"
|
|
|
|
| 65 |
with open(path, "r", encoding="utf-8") as f:
|
| 66 |
return f.read()
|
| 67 |
|
| 68 |
+
def _read_default_text() -> Optional[str]:
|
| 69 |
+
# Prefer the on-disk default in the editable repo
|
| 70 |
+
try:
|
| 71 |
+
return _read_text_from_disk("wordlist.txt")
|
| 72 |
+
except Exception:
|
| 73 |
+
pass
|
| 74 |
+
# Fallback to packaged data if available
|
| 75 |
+
try:
|
| 76 |
+
return resources.files("battlewords.words").joinpath("wordlist.txt").read_text(encoding="utf-8")
|
| 77 |
+
except Exception:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
try:
|
| 81 |
text: Optional[str] = None
|
| 82 |
+
source_label = "fallback"
|
| 83 |
|
| 84 |
if selected_file:
|
| 85 |
+
# Validate selection against available files to avoid bad paths
|
| 86 |
+
available = set(get_wordlist_files())
|
| 87 |
+
if selected_file not in available:
|
| 88 |
+
raise FileNotFoundError(f"Selected word list '{selected_file}' not found in words/ directory.")
|
| 89 |
text = _read_text_from_disk(selected_file)
|
| 90 |
+
source_label = f"file:{selected_file}"
|
| 91 |
else:
|
| 92 |
+
text = _read_default_text()
|
| 93 |
+
if text is not None:
|
| 94 |
+
source_label = "default"
|
| 95 |
+
|
| 96 |
+
if text is None:
|
| 97 |
+
raise FileNotFoundError("No word list file found on disk or in packaged resources.")
|
| 98 |
|
| 99 |
seen = {4: set(), 5: set(), 6: set()}
|
| 100 |
for raw in text.splitlines():
|
|
|
|
| 112 |
seen[L].add(word)
|
| 113 |
|
| 114 |
counts = {k: len(v) for k, v in words_by_len.items()}
|
| 115 |
+
if all(counts[k] >= MIN_REQUIRED for k in (4, 5, 6)):
|
| 116 |
+
used_source = source_label
|
| 117 |
return _finalize(words_by_len, used_source)
|
| 118 |
|
| 119 |
# Per spec: fallback for any length below threshold
|
| 120 |
mixed: Dict[int, List[str]] = {
|
| 121 |
+
4: words_by_len[4] if counts[4] >= MIN_REQUIRED else FALLBACK_WORDS[4],
|
| 122 |
+
5: words_by_len[5] if counts[5] >= MIN_REQUIRED else FALLBACK_WORDS[5],
|
| 123 |
+
6: words_by_len[6] if counts[6] >= MIN_REQUIRED else FALLBACK_WORDS[6],
|
| 124 |
}
|
| 125 |
+
used_source = f"{source_label}+fallback" if any(counts[k] >= MIN_REQUIRED for k in (4, 5, 6)) else "fallback"
|
| 126 |
return _finalize(mixed, used_source)
|
| 127 |
|
| 128 |
except Exception:
|