Spaces:
Running
Running
Basic Only version step 1
Browse files- .github/copilot-instructions.md +13 -6
- battlewords/modules/__init__.py +0 -6
- battlewords/modules/constants.py +1 -19
- battlewords/ui.py +3 -128
- specs/basic.mdx +138 -0
- tests/test_apptest.py +0 -7
- tests/test_compare_difficulty_functions.py +0 -237
- tests/test_download_game_settings.py +0 -63
- tests/test_generator.py +0 -29
- tests/test_logic.py +0 -55
.github/copilot-instructions.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copilot Instructions
|
| 2 |
+
|
| 3 |
+
## General Guidelines
|
| 4 |
+
- Minimal changes to existing code
|
| 5 |
+
- Preserve functionality when possible
|
| 6 |
+
- Clear and concise comments
|
| 7 |
+
- No test unless specified
|
| 8 |
+
- No plan unless specified
|
| 9 |
+
- No compile unless specified
|
| 10 |
+
|
| 11 |
+
## Project-Specific Rules
|
| 12 |
+
- Remove AI generation of wordlists and audio system from the basic codebase
|
| 13 |
+
- Keep challenge mode/HF storage and PWA support in the basic codebase
|
battlewords/modules/__init__.py
CHANGED
|
@@ -21,14 +21,11 @@ from .storage import (
|
|
| 21 |
|
| 22 |
from .constants import (
|
| 23 |
APP_SETTINGS,
|
| 24 |
-
AI_MODELS,
|
| 25 |
HF_API_TOKEN,
|
| 26 |
CRYPTO_PK,
|
| 27 |
HF_REPO_ID,
|
| 28 |
SPACE_NAME,
|
| 29 |
SHORTENER_JSON_FILE,
|
| 30 |
-
USE_HF_WORDS,
|
| 31 |
-
HF_WORD_LIST_REPO_ID,
|
| 32 |
MAX_DISPLAY_ENTRIES,
|
| 33 |
TMPDIR,
|
| 34 |
upload_file_types,
|
|
@@ -71,14 +68,11 @@ __all__ = [
|
|
| 71 |
|
| 72 |
# constants.py
|
| 73 |
'APP_SETTINGS',
|
| 74 |
-
'AI_MODELS',
|
| 75 |
'HF_API_TOKEN',
|
| 76 |
'CRYPTO_PK',
|
| 77 |
'HF_REPO_ID',
|
| 78 |
'SPACE_NAME',
|
| 79 |
'SHORTENER_JSON_FILE',
|
| 80 |
-
'USE_HF_WORDS',
|
| 81 |
-
'HF_WORD_LIST_REPO_ID',
|
| 82 |
'MAX_DISPLAY_ENTRIES',
|
| 83 |
'TMPDIR',
|
| 84 |
'upload_file_types',
|
|
|
|
| 21 |
|
| 22 |
from .constants import (
|
| 23 |
APP_SETTINGS,
|
|
|
|
| 24 |
HF_API_TOKEN,
|
| 25 |
CRYPTO_PK,
|
| 26 |
HF_REPO_ID,
|
| 27 |
SPACE_NAME,
|
| 28 |
SHORTENER_JSON_FILE,
|
|
|
|
|
|
|
| 29 |
MAX_DISPLAY_ENTRIES,
|
| 30 |
TMPDIR,
|
| 31 |
upload_file_types,
|
|
|
|
| 68 |
|
| 69 |
# constants.py
|
| 70 |
'APP_SETTINGS',
|
|
|
|
| 71 |
'HF_API_TOKEN',
|
| 72 |
'CRYPTO_PK',
|
| 73 |
'HF_REPO_ID',
|
| 74 |
'SPACE_NAME',
|
| 75 |
'SHORTENER_JSON_FILE',
|
|
|
|
|
|
|
| 76 |
'MAX_DISPLAY_ENTRIES',
|
| 77 |
'TMPDIR',
|
| 78 |
'upload_file_types',
|
battlewords/modules/constants.py
CHANGED
|
@@ -23,20 +23,8 @@ CRYPTO_PK = os.getenv("CRYPTO_PK", None)
|
|
| 23 |
HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
|
| 24 |
SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
|
| 25 |
SHORTENER_JSON_FILE = "shortener.json"
|
| 26 |
-
USE_HF_WORDS = os.getenv("USE_HF_WORDS", "false").lower() == "true"
|
| 27 |
-
HF_WORD_LIST_REPO_ID = os.getenv("HF_WORD_LIST_REPO_ID", "ysharma/Chat_with_Meta_llama3_1_8b")
|
| 28 |
MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
|
| 29 |
|
| 30 |
-
# List of smaller, faster fallback models if the primary one fails
|
| 31 |
-
AI_MODELS = [
|
| 32 |
-
"microsoft/Phi-3-mini-4k-instruct",
|
| 33 |
-
"meta-llama/Llama-3.1-8B-Instruct",
|
| 34 |
-
"google/gemma-2b-it",
|
| 35 |
-
"distilbert/distilgpt2",
|
| 36 |
-
"mistralai/Mistral-7B-Instruct-v0.3",
|
| 37 |
-
"NousResearch/Hermes-2-Pro-Llama-3-8B"
|
| 38 |
-
]
|
| 39 |
-
|
| 40 |
# ---------------------------------------------------------------------------
|
| 41 |
# Application Settings
|
| 42 |
# ---------------------------------------------------------------------------
|
|
@@ -63,13 +51,7 @@ def load_settings() -> Dict[str, Any]:
|
|
| 63 |
"show_incorrect_guesses": True,
|
| 64 |
"enable_free_letters": False,
|
| 65 |
"show_challenge_links": True,
|
| 66 |
-
|
| 67 |
-
# Audio settings
|
| 68 |
-
"sound_effects_enabled": True,
|
| 69 |
-
"sound_effects_volume": 30,
|
| 70 |
-
"music_enabled": False,
|
| 71 |
-
"music_volume": 10,
|
| 72 |
-
|
| 73 |
# Game defaults
|
| 74 |
"default_wordlist": "classic.txt",
|
| 75 |
"default_game_mode": "classic",
|
|
|
|
| 23 |
HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
|
| 24 |
SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
|
| 25 |
SHORTENER_JSON_FILE = "shortener.json"
|
|
|
|
|
|
|
| 26 |
MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
# Application Settings
|
| 30 |
# ---------------------------------------------------------------------------
|
|
|
|
| 51 |
"show_incorrect_guesses": True,
|
| 52 |
"enable_free_letters": False,
|
| 53 |
"show_challenge_links": True,
|
| 54 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# Game defaults
|
| 56 |
"default_wordlist": "classic.txt",
|
| 57 |
"default_game_mode": "classic",
|
battlewords/ui.py
CHANGED
|
@@ -18,17 +18,8 @@ from .generator import generate_puzzle
|
|
| 18 |
from .logic import build_letter_map, reveal_cell, 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 .audio import (
|
| 22 |
-
_get_music_dir,
|
| 23 |
-
get_audio_tracks,
|
| 24 |
-
_load_audio_data_url,
|
| 25 |
-
_mount_background_audio,
|
| 26 |
-
_inject_audio_control_sync,
|
| 27 |
-
play_sound_effect,
|
| 28 |
-
)
|
| 29 |
from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
|
| 30 |
-
from .
|
| 31 |
-
from .ui_helpers import render_footer, inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
|
| 32 |
|
| 33 |
# --- Spinner context manager for custom spinner ---
|
| 34 |
class CustomSpinner:
|
|
@@ -157,13 +148,6 @@ def _init_session() -> None:
|
|
| 157 |
if "show_grid_ticks" not in st.session_state:
|
| 158 |
st.session_state.show_grid_ticks = False
|
| 159 |
|
| 160 |
-
# --- Add enable sound effects ---
|
| 161 |
-
if "enable_sound_effects" not in st.session_state:
|
| 162 |
-
st.session_state.enable_sound_effects = False
|
| 163 |
-
|
| 164 |
-
if "hide_footer" not in st.session_state:
|
| 165 |
-
st.session_state["hide_footer"] = False
|
| 166 |
-
|
| 167 |
def _new_game() -> None:
|
| 168 |
spinner_placeholder = st.empty()
|
| 169 |
with CustomSpinner(spinner_placeholder, "Initializing New Game..."):
|
|
@@ -172,12 +156,6 @@ def _new_game() -> None:
|
|
| 172 |
show_grid_ticks = st.session_state.get("show_grid_ticks", False)
|
| 173 |
spacer = st.session_state.get("spacer",1)
|
| 174 |
show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
|
| 175 |
-
# --- Preserve music and effects settings ---
|
| 176 |
-
music_enabled = st.session_state.get("music_enabled", False)
|
| 177 |
-
music_track_path = st.session_state.get("music_track_path")
|
| 178 |
-
music_volume = st.session_state.get("music_volume",15)
|
| 179 |
-
effects_volume = st.session_state.get("effects_volume",25)
|
| 180 |
-
enable_sound_effects = st.session_state.get("enable_sound_effects", True)
|
| 181 |
# NEW: Preserve Show Challenge Share Links
|
| 182 |
show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
|
| 183 |
|
|
@@ -189,13 +167,6 @@ def _new_game() -> None:
|
|
| 189 |
st.session_state.show_grid_ticks = show_grid_ticks
|
| 190 |
st.session_state.spacer = spacer
|
| 191 |
st.session_state.show_incorrect_guesses = show_incorrect_guesses
|
| 192 |
-
# --- Restore music/effects settings ---
|
| 193 |
-
st.session_state.music_enabled = music_enabled
|
| 194 |
-
if music_track_path:
|
| 195 |
-
st.session_state.music_track_path = music_track_path
|
| 196 |
-
st.session_state.music_volume = music_volume
|
| 197 |
-
st.session_state.effects_volume = effects_volume
|
| 198 |
-
st.session_state.enable_sound_effects = enable_sound_effects
|
| 199 |
# NEW: Restore Show Challenge Share Links
|
| 200 |
st.session_state.show_challenge_share_links = show_challenge_share_links
|
| 201 |
|
|
@@ -689,19 +660,6 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
|
|
| 689 |
# Note: letter_map is static and built once in _init_session(), no need to rebuild
|
| 690 |
_sync_back(state)
|
| 691 |
|
| 692 |
-
# Play sound effect based on hit or miss
|
| 693 |
-
action = (state.last_action or "").strip()
|
| 694 |
-
if action.startswith("Revealed '"):
|
| 695 |
-
play_sound_effect(
|
| 696 |
-
"hit",
|
| 697 |
-
volume=(st.session_state.get("effects_volume", 50) / 100),
|
| 698 |
-
)
|
| 699 |
-
elif action.startswith("Revealed empty"):
|
| 700 |
-
play_sound_effect(
|
| 701 |
-
"miss",
|
| 702 |
-
volume=(st.session_state.get("effects_volume", 50) / 100),
|
| 703 |
-
)
|
| 704 |
-
|
| 705 |
st.rerun()
|
| 706 |
|
| 707 |
def _render_hit_miss(state: GameState):
|
|
@@ -939,12 +897,10 @@ def _render_guess_form(state: GameState):
|
|
| 939 |
if correct:
|
| 940 |
st.session_state.radar_gif_path = None
|
| 941 |
st.session_state.radar_gif_signature = None
|
| 942 |
-
play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
|
| 943 |
else:
|
| 944 |
# Update incorrect guesses list - keep only last 10
|
| 945 |
st.session_state.incorrect_guesses.append(guess_text)
|
| 946 |
st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
|
| 947 |
-
play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
|
| 948 |
st.rerun()
|
| 949 |
|
| 950 |
|
|
@@ -1083,16 +1039,6 @@ def _render_score_panel(state: GameState):
|
|
| 1083 |
# -------------------- Game Over Dialog --------------------
|
| 1084 |
|
| 1085 |
def _game_over_content(state: GameState) -> None:
|
| 1086 |
-
# Play congratulations music (not sound effect) as background if enabled
|
| 1087 |
-
music_dir = _get_music_dir()
|
| 1088 |
-
congrats_music_path = os.path.join(music_dir, "congratulations.mp3")
|
| 1089 |
-
if st.session_state.get("music_enabled", False) and os.path.exists(congrats_music_path):
|
| 1090 |
-
src_url = _load_audio_data_url(congrats_music_path)
|
| 1091 |
-
# Play once (no loop) at configured volume
|
| 1092 |
-
_mount_background_audio(enabled=True, src_data_url=src_url, volume=(st.session_state.get("music_volume", 100)) / 100, loop=False)
|
| 1093 |
-
else:
|
| 1094 |
-
_mount_background_audio(False, None, 0.0)
|
| 1095 |
-
|
| 1096 |
# Set end_time if not already set
|
| 1097 |
if state.end_time is None:
|
| 1098 |
st.session_state.end_time = datetime.now()
|
|
@@ -1394,7 +1340,6 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1394 |
# Dialog actions
|
| 1395 |
if st.button("Close", key="close_game_over"):
|
| 1396 |
st.session_state["show_gameover_overlay"] = False
|
| 1397 |
-
st.session_state["remount_background_audio"] = True # <-- set flag
|
| 1398 |
st.rerun()
|
| 1399 |
|
| 1400 |
# Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
|
|
@@ -1418,32 +1363,8 @@ def _render_game_over(state: GameState):
|
|
| 1418 |
spinner_placeholder = st.empty()
|
| 1419 |
with CustomSpinner(spinner_placeholder, "Game Over..."):
|
| 1420 |
visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
|
| 1421 |
-
music_dir = _get_music_dir()
|
| 1422 |
if visible:
|
| 1423 |
-
# Mount congratulations music (play once, do not loop) only if music is enabled
|
| 1424 |
-
congrats_music_path = os.path.join(music_dir, "congratulations.mp3")
|
| 1425 |
-
if st.session_state.get("music_enabled", False) and os.path.exists(congrats_music_path):
|
| 1426 |
-
src_url = _load_audio_data_url(congrats_music_path)
|
| 1427 |
-
_mount_background_audio(enabled=True, src_data_url=src_url, volume=(st.session_state.get("music_volume", 100)) / 100, loop=False)
|
| 1428 |
-
else:
|
| 1429 |
-
_mount_background_audio(False, None, 0.0)
|
| 1430 |
_game_over_dialog(state)
|
| 1431 |
-
else:
|
| 1432 |
-
# Only play background music if music is enabled
|
| 1433 |
-
if st.session_state.get("music_enabled", False):
|
| 1434 |
-
# Prefer user-selected track
|
| 1435 |
-
track_path = st.session_state.get("music_track_path")
|
| 1436 |
-
if track_path and os.path.exists(track_path):
|
| 1437 |
-
src_url = _load_audio_data_url(track_path)
|
| 1438 |
-
_mount_background_audio(True, src_url, (st.session_state.get("music_volume", 100)) / 100)
|
| 1439 |
-
else:
|
| 1440 |
-
# Fallback to a default background track if available
|
| 1441 |
-
background_path = os.path.join(music_dir, "background.mp3")
|
| 1442 |
-
if os.path.exists(background_path):
|
| 1443 |
-
src_url = _load_audio_data_url(background_path)
|
| 1444 |
-
_mount_background_audio(True, src_url, (st.session_state.get("music_volume", 100)) / 100)
|
| 1445 |
-
else:
|
| 1446 |
-
_mount_background_audio(False, None, 0.0)
|
| 1447 |
|
| 1448 |
def _on_game_option_change() -> None:
|
| 1449 |
"""
|
|
@@ -1502,10 +1423,6 @@ def run_app():
|
|
| 1502 |
except Exception:
|
| 1503 |
params = {}
|
| 1504 |
|
| 1505 |
-
# handle no footer query string param (for embedding use cases)
|
| 1506 |
-
if "nofooter" in params:
|
| 1507 |
-
st.session_state["hide_footer"] = True
|
| 1508 |
-
|
| 1509 |
# Handle overlay dismissal
|
| 1510 |
if params.get("overlay") == "0":
|
| 1511 |
# Clear param and remember to hide overlay this session
|
|
@@ -1515,54 +1432,14 @@ def run_app():
|
|
| 1515 |
pass
|
| 1516 |
st.session_state["hide_gameover_overlay"] = True
|
| 1517 |
|
| 1518 |
-
# Handle page navigation via query params
|
| 1519 |
-
page = params.get("page") or "play"
|
| 1520 |
-
|
| 1521 |
# Show spinner on initial load
|
| 1522 |
if not st.session_state.get("initial_page_loaded", False):
|
| 1523 |
spinner_placeholder = st.empty()
|
| 1524 |
with CustomSpinner(spinner_placeholder, "Initial Load..."):
|
| 1525 |
st.session_state["initial_page_loaded"] = True
|
| 1526 |
-
st.session_state["last_nav_page"] = page
|
| 1527 |
-
# st.rerun()
|
| 1528 |
-
|
| 1529 |
-
# Show spinner when navigating via menu (query param page change)
|
| 1530 |
-
last_nav_page = st.session_state.get("last_nav_page")
|
| 1531 |
-
if last_nav_page is not None and last_nav_page != page:
|
| 1532 |
-
spinner_placeholder = st.empty()
|
| 1533 |
-
with CustomSpinner(spinner_placeholder, "Loading..."):
|
| 1534 |
-
st.session_state["last_nav_page"] = page
|
| 1535 |
-
st.rerun()
|
| 1536 |
-
|
| 1537 |
-
# One-time spinner on initial display of the main UI.
|
| 1538 |
-
# if page == "play" and not st.session_state.get("initial_page_loaded", False):
|
| 1539 |
-
# spinner_placeholder = st.empty()
|
| 1540 |
-
# with CustomSpinner(spinner_placeholder, "Loading Game..."):
|
| 1541 |
-
# st.session_state["initial_page_loaded"] = True
|
| 1542 |
-
# st.rerun()
|
| 1543 |
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
with CustomSpinner(spinner_placeholder, "Loading Settings..."):
|
| 1547 |
-
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1548 |
-
#inject_ocean_layers()
|
| 1549 |
-
inject_styles()
|
| 1550 |
-
render_settings_page(_on_game_option_change)
|
| 1551 |
-
if st.session_state["hide_footer"] is not True:
|
| 1552 |
-
render_footer(current_page="settings", params=params)
|
| 1553 |
-
return
|
| 1554 |
-
|
| 1555 |
-
if page == "leaderboard":
|
| 1556 |
-
spinner_placeholder = st.empty()
|
| 1557 |
-
with CustomSpinner(spinner_placeholder, "Loading Leaderboard..."):
|
| 1558 |
-
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1559 |
-
#inject_ocean_layers()
|
| 1560 |
-
inject_styles()
|
| 1561 |
-
st.header("Leaderboard")
|
| 1562 |
-
st.caption("Leaderboard UI will be added as part of the upgrade checklist.")
|
| 1563 |
-
if st.session_state["hide_footer"] is not True:
|
| 1564 |
-
render_footer(current_page="leaderboard", params=params)
|
| 1565 |
-
return
|
| 1566 |
|
| 1567 |
# Handle game_id for loading shared games
|
| 1568 |
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
|
@@ -1614,8 +1491,6 @@ def run_app():
|
|
| 1614 |
state = _to_state()
|
| 1615 |
if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
|
| 1616 |
_render_game_over(state)
|
| 1617 |
-
if st.session_state["hide_footer"] is not True:
|
| 1618 |
-
render_footer(current_page="play", params=params)
|
| 1619 |
finish_root_fade_in(2.0)
|
| 1620 |
|
| 1621 |
def _render_game_tab():
|
|
|
|
| 18 |
from .logic import build_letter_map, reveal_cell, 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 .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
|
| 22 |
+
from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
|
|
|
|
| 23 |
|
| 24 |
# --- Spinner context manager for custom spinner ---
|
| 25 |
class CustomSpinner:
|
|
|
|
| 148 |
if "show_grid_ticks" not in st.session_state:
|
| 149 |
st.session_state.show_grid_ticks = False
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
def _new_game() -> None:
|
| 152 |
spinner_placeholder = st.empty()
|
| 153 |
with CustomSpinner(spinner_placeholder, "Initializing New Game..."):
|
|
|
|
| 156 |
show_grid_ticks = st.session_state.get("show_grid_ticks", False)
|
| 157 |
spacer = st.session_state.get("spacer",1)
|
| 158 |
show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
# NEW: Preserve Show Challenge Share Links
|
| 160 |
show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
|
| 161 |
|
|
|
|
| 167 |
st.session_state.show_grid_ticks = show_grid_ticks
|
| 168 |
st.session_state.spacer = spacer
|
| 169 |
st.session_state.show_incorrect_guesses = show_incorrect_guesses
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
# NEW: Restore Show Challenge Share Links
|
| 171 |
st.session_state.show_challenge_share_links = show_challenge_share_links
|
| 172 |
|
|
|
|
| 660 |
# Note: letter_map is static and built once in _init_session(), no need to rebuild
|
| 661 |
_sync_back(state)
|
| 662 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
st.rerun()
|
| 664 |
|
| 665 |
def _render_hit_miss(state: GameState):
|
|
|
|
| 897 |
if correct:
|
| 898 |
st.session_state.radar_gif_path = None
|
| 899 |
st.session_state.radar_gif_signature = None
|
|
|
|
| 900 |
else:
|
| 901 |
# Update incorrect guesses list - keep only last 10
|
| 902 |
st.session_state.incorrect_guesses.append(guess_text)
|
| 903 |
st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
|
|
|
|
| 904 |
st.rerun()
|
| 905 |
|
| 906 |
|
|
|
|
| 1039 |
# -------------------- Game Over Dialog --------------------
|
| 1040 |
|
| 1041 |
def _game_over_content(state: GameState) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1042 |
# Set end_time if not already set
|
| 1043 |
if state.end_time is None:
|
| 1044 |
st.session_state.end_time = datetime.now()
|
|
|
|
| 1340 |
# Dialog actions
|
| 1341 |
if st.button("Close", key="close_game_over"):
|
| 1342 |
st.session_state["show_gameover_overlay"] = False
|
|
|
|
| 1343 |
st.rerun()
|
| 1344 |
|
| 1345 |
# Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
|
|
|
|
| 1363 |
spinner_placeholder = st.empty()
|
| 1364 |
with CustomSpinner(spinner_placeholder, "Game Over..."):
|
| 1365 |
visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
|
|
|
|
| 1366 |
if visible:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1367 |
_game_over_dialog(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1368 |
|
| 1369 |
def _on_game_option_change() -> None:
|
| 1370 |
"""
|
|
|
|
| 1423 |
except Exception:
|
| 1424 |
params = {}
|
| 1425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1426 |
# Handle overlay dismissal
|
| 1427 |
if params.get("overlay") == "0":
|
| 1428 |
# Clear param and remember to hide overlay this session
|
|
|
|
| 1432 |
pass
|
| 1433 |
st.session_state["hide_gameover_overlay"] = True
|
| 1434 |
|
|
|
|
|
|
|
|
|
|
| 1435 |
# Show spinner on initial load
|
| 1436 |
if not st.session_state.get("initial_page_loaded", False):
|
| 1437 |
spinner_placeholder = st.empty()
|
| 1438 |
with CustomSpinner(spinner_placeholder, "Initial Load..."):
|
| 1439 |
st.session_state["initial_page_loaded"] = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1440 |
|
| 1441 |
+
# Basic branch: only render the play experience
|
| 1442 |
+
# Settings and leaderboard pages are disabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1443 |
|
| 1444 |
# Handle game_id for loading shared games
|
| 1445 |
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
|
|
|
| 1491 |
state = _to_state()
|
| 1492 |
if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
|
| 1493 |
_render_game_over(state)
|
|
|
|
|
|
|
| 1494 |
finish_root_fade_in(2.0)
|
| 1495 |
|
| 1496 |
def _render_game_tab():
|
specs/basic.mdx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Battlewords: Basic Branch Requirements
|
| 2 |
+
|
| 3 |
+
**Branch:** `basic`
|
| 4 |
+
|
| 5 |
+
This document defines the “basic” version scope for Battlewords on the `basic` branch. It intentionally limits features to a minimal, default-settings single-player experience.
|
| 6 |
+
|
| 7 |
+
## Tech Stack
|
| 8 |
+
- Python 3.10+
|
| 9 |
+
- Streamlit (UI)
|
| 10 |
+
- matplotlib (radar)
|
| 11 |
+
- numpy (tick helpers)
|
| 12 |
+
- Pillow (GIF writer)
|
| 13 |
+
|
| 14 |
+
## In Scope
|
| 15 |
+
|
| 16 |
+
### Default gameplay only
|
| 17 |
+
- The game uses **default settings**.
|
| 18 |
+
- No user-configurable options are required.
|
| 19 |
+
- State remains local (Streamlit session state) as implemented in the core app flow.
|
| 20 |
+
|
| 21 |
+
### UI
|
| 22 |
+
- A single play experience accessible from `app.py`.
|
| 23 |
+
- Core UI elements required to play:
|
| 24 |
+
- 12×12 grid
|
| 25 |
+
- Reveal action
|
| 26 |
+
- Guess form
|
| 27 |
+
- Score display
|
| 28 |
+
- Game over summary
|
| 29 |
+
|
| 30 |
+
### Layout constraints
|
| 31 |
+
- No footer navigation.
|
| 32 |
+
- No sidebar.
|
| 33 |
+
|
| 34 |
+
## Out of Scope (Basic Branch)
|
| 35 |
+
|
| 36 |
+
### Settings page
|
| 37 |
+
- The app **does not need** a settings page.
|
| 38 |
+
- No query-param routing to a settings page is required.
|
| 39 |
+
- Any settings-related UI should be omitted/disabled in this branch.
|
| 40 |
+
|
| 41 |
+
### Leaderboard
|
| 42 |
+
- The app **does not need** a leaderboard.
|
| 43 |
+
- No local or remote leaderboard storage is required.
|
| 44 |
+
- No UI navigation or pages for leaderboard are required.
|
| 45 |
+
|
| 46 |
+
### AI generation of wordlists
|
| 47 |
+
- The basic branch does **not** include AI-generated wordlists or HF-model-backed word generation.
|
| 48 |
+
- Related AI model configuration (for example `AI_MODELS`, `USE_HF_WORDS`, `HF_WORD_LIST_REPO_ID`) should be removed/unused in this branch.
|
| 49 |
+
|
| 50 |
+
### Audio (music + sound effects)
|
| 51 |
+
- The basic branch does **not** include background music or sound effects.
|
| 52 |
+
- Audio mounting via `component.html` and any audio assets/modules should be removed/unused in this branch.
|
| 53 |
+
|
| 54 |
+
### Tests
|
| 55 |
+
- The basic branch does **not** include tests or a `tests` folder.
|
| 56 |
+
|
| 57 |
+
## Notes
|
| 58 |
+
- Feature work for Settings/Leaderboard should live on non-`basic` branches.
|
| 59 |
+
|
| 60 |
+
## Basic Functions Kept
|
| 61 |
+
|
| 62 |
+
The following functions are retained in the basic branch:
|
| 63 |
+
- Game initialization and puzzle generation
|
| 64 |
+
- Word list loading and validation
|
| 65 |
+
- Reveal cell action
|
| 66 |
+
- Guess word action
|
| 67 |
+
- Score calculation and tier assignment
|
| 68 |
+
- Radar visualization
|
| 69 |
+
- Game over summary display
|
| 70 |
+
|
| 71 |
+
## UI Transitions and Effects
|
| 72 |
+
|
| 73 |
+
The basic branch uses:
|
| 74 |
+
- `CustomSpinner` for smooth loading and transition effects (New Game, initial load, Game Over/Congratulations popups).
|
| 75 |
+
- Streamlit CSS, markup, and `component.html` for UI styling and transition overlays.
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
## Codebase Constraints (Basic Branch)
|
| 79 |
+
|
| 80 |
+
### Navigation / routing
|
| 81 |
+
- No query-param navigation to non-game pages.
|
| 82 |
+
- Remove/disable any routing paths such as `?page=settings` and `?page=leaderboard`.
|
| 83 |
+
- The basic branch should only render the play experience.
|
| 84 |
+
|
| 85 |
+
### Word list sources
|
| 86 |
+
- Word lists must be loaded from local files in `battlewords/words/`.
|
| 87 |
+
- Do not fetch or generate word lists via Hugging Face models or other AI inference.
|
| 88 |
+
|
| 89 |
+
### Component usage
|
| 90 |
+
- `component.html` is allowed for UI transitions/effects (spinners, scroll-to-anchor, overlays).
|
| 91 |
+
- `component.html` must not be used for audio mounting in the basic branch.
|
| 92 |
+
|
| 93 |
+
## Possible features to remove
|
| 94 |
+
|
| 95 |
+
The following features are currently included, but may be considered for removal in an even more minimal version:
|
| 96 |
+
- Challenge mode / Share Links / Remote Storage (HF)
|
| 97 |
+
- PWA support (service worker, manifest, installability)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
## Implementation Checklist
|
| 101 |
+
|
| 102 |
+
- App starts with no errors.
|
| 103 |
+
- New Game transition remains smooth (uses `CustomSpinner`).
|
| 104 |
+
- Core gameplay works end-to-end (reveal, guess, scoring, radar, game over).
|
| 105 |
+
- Game Over / Congratulations popup behaves correctly and remains smooth.
|
| 106 |
+
- Challenge mode works via `?game_id=...` (load, play, submit/share).
|
| 107 |
+
- PWA support remains functional (service worker/manifest still injected/served).
|
| 108 |
+
- No audio playback occurs (no background music, no sound effects).
|
| 109 |
+
- No AI/HF-model-backed word list generation occurs (local wordlists only).
|
| 110 |
+
- No settings/leaderboard pages are reachable (`?page=settings` / `?page=leaderboard` disabled).
|
| 111 |
+
- No footer navigation and no sidebar are rendered.
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
Initial Proposed Prompt:
|
| 116 |
+
|
| 117 |
+
```text
|
| 118 |
+
You are working in the basic branch. Follow .github/copilot-instructions.md (minimal changes, preserve functionality, clear concise comments, no tests unless asked). Also follow basic.mdx requirements.
|
| 119 |
+
Goal: simplify the codebase to match the basic version.
|
| 120 |
+
Must keep:
|
| 121 |
+
• Core gameplay (grid reveal, guessing, scoring, radar, game over)
|
| 122 |
+
• CustomSpinner and CSS/markup/components.html usage for smooth transitions (initial load, New Game, Game Over popup)
|
| 123 |
+
• Challenge mode / share links / Remote Storage (HF) feature
|
| 124 |
+
• PWA support (service worker / manifest integration)
|
| 125 |
+
Must remove/disable from the basic codebase:
|
| 126 |
+
• AI generation of wordlists / HF-model-backed word generation (remove or make unused AI_MODELS, USE_HF_WORDS, HF_WORD_LIST_REPO_ID and any related code paths)
|
| 127 |
+
• Audio system (no background music, no sound effects; remove imports/usage of audio.py, audio assets, and any audio mounting via components.html)
|
| 128 |
+
Additional constraints:
|
| 129 |
+
• No footer and no sidebar in the UI.
|
| 130 |
+
• No query-param navigation for ?page=settings or ?page=leaderboard; basic should only render the play experience.
|
| 131 |
+
• Word lists must load only from local files in battlewords/words/.
|
| 132 |
+
Deliverable:
|
| 133 |
+
• Make the smallest set of code changes across the repo to enforce the above.
|
| 134 |
+
• Update app.py / ui.py routing accordingly.
|
| 135 |
+
• Remove now-unused modules/files only if they are truly unused after edits.
|
| 136 |
+
• Summarize exactly which files you changed and why.
|
| 137 |
+
• Validate the result against the "Implementation Checklist" section in this document.
|
| 138 |
+
```
|
tests/test_apptest.py
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
# file: D:/Projects/Battlewords/tests/test_apptest.py
|
| 2 |
-
from streamlit.testing.v1 import AppTest
|
| 3 |
-
|
| 4 |
-
def test_app_runs():
|
| 5 |
-
at = AppTest.from_file("app.py")
|
| 6 |
-
at.run()
|
| 7 |
-
assert not at.exception
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_compare_difficulty_functions.py
DELETED
|
@@ -1,237 +0,0 @@
|
|
| 1 |
-
# file: tests/test_compare_difficulty_functions.py
|
| 2 |
-
import os
|
| 3 |
-
import sys
|
| 4 |
-
import pytest
|
| 5 |
-
|
| 6 |
-
# Ensure the modules path is available
|
| 7 |
-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
-
|
| 9 |
-
from battlewords.modules.constants import HF_API_TOKEN
|
| 10 |
-
from battlewords.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 11 |
-
from battlewords.word_loader import compute_word_difficulties, compute_word_difficulties2, compute_word_difficulties3
|
| 12 |
-
|
| 13 |
-
# Ensure the token is set for Hugging Face Hub
|
| 14 |
-
if HF_API_TOKEN:
|
| 15 |
-
os.environ["HF_API_TOKEN"] = HF_API_TOKEN
|
| 16 |
-
|
| 17 |
-
# Define sample_words as a global variable
|
| 18 |
-
sample_words = []
|
| 19 |
-
|
| 20 |
-
def test_compare_difficulty_functions_for_challenge(capsys):
|
| 21 |
-
"""
|
| 22 |
-
Compare compute_word_difficulties, compute_word_difficulties2, and compute_word_difficulties3
|
| 23 |
-
for all users in a challenge identified by short_id.
|
| 24 |
-
"""
|
| 25 |
-
global sample_words # Ensure we modify the global variable
|
| 26 |
-
|
| 27 |
-
# Use a fixed short id for testing
|
| 28 |
-
short_id = "hDjsB_dl"
|
| 29 |
-
|
| 30 |
-
# Step 1: Resolve short ID to full URL
|
| 31 |
-
status, full_url = gen_full_url(
|
| 32 |
-
short_url=short_id,
|
| 33 |
-
repo_id=HF_REPO_ID,
|
| 34 |
-
json_file=SHORTENER_JSON_FILE
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
if status != "success_retrieved_full" or not full_url:
|
| 38 |
-
print(
|
| 39 |
-
f"Could not resolve short id '{short_id}'. "
|
| 40 |
-
f"Status: {status}. "
|
| 41 |
-
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 42 |
-
)
|
| 43 |
-
captured = capsys.readouterr()
|
| 44 |
-
assert "Could not resolve short id" in captured.out
|
| 45 |
-
assert not full_url, "full_url should be empty/None on failure"
|
| 46 |
-
print("settings.json was not found or could not be resolved.")
|
| 47 |
-
return
|
| 48 |
-
|
| 49 |
-
print(f"✓ Resolved short id '{short_id}' to full URL: {full_url}")
|
| 50 |
-
|
| 51 |
-
# Step 2: Extract file path from full URL
|
| 52 |
-
url_parts = full_url.split("/resolve/main/")
|
| 53 |
-
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 54 |
-
file_path = url_parts[1]
|
| 55 |
-
|
| 56 |
-
# Step 3: Download and parse settings.json
|
| 57 |
-
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 58 |
-
assert settings, "Failed to download or parse settings.json"
|
| 59 |
-
print(f"✓ Downloaded settings.json")
|
| 60 |
-
|
| 61 |
-
# Validate settings structure
|
| 62 |
-
assert "challenge_id" in settings
|
| 63 |
-
assert "wordlist_source" in settings
|
| 64 |
-
assert "users" in settings
|
| 65 |
-
|
| 66 |
-
wordlist_source = settings.get("wordlist_source", "wordlist.txt")
|
| 67 |
-
users = settings.get("users", [])
|
| 68 |
-
|
| 69 |
-
print(f"\nChallenge ID: {settings['challenge_id']}")
|
| 70 |
-
print(f"Wordlist Source: {wordlist_source}")
|
| 71 |
-
print(f"Number of Users: {len(users)}")
|
| 72 |
-
|
| 73 |
-
# Step 4: Determine wordlist file path
|
| 74 |
-
# Assuming the wordlist is in battlewords/words/ directory
|
| 75 |
-
words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
|
| 76 |
-
wordlist_path = os.path.join(words_dir, wordlist_source)
|
| 77 |
-
|
| 78 |
-
# If wordlist doesn't exist, try classic.txt as fallback
|
| 79 |
-
if not os.path.exists(wordlist_path):
|
| 80 |
-
print(f"⚠ Wordlist '{wordlist_source}' not found, using 'classic.txt' as fallback")
|
| 81 |
-
wordlist_path = os.path.join(words_dir, "classic.txt")
|
| 82 |
-
|
| 83 |
-
assert os.path.exists(wordlist_path), f"Wordlist file not found: {wordlist_path}"
|
| 84 |
-
print(f"✓ Using wordlist: {wordlist_path}")
|
| 85 |
-
|
| 86 |
-
# Step 5: Compare difficulty functions for each user
|
| 87 |
-
print("\n" + "="*80)
|
| 88 |
-
print("DIFFICULTY COMPARISON BY USER")
|
| 89 |
-
print("="*80)
|
| 90 |
-
|
| 91 |
-
all_results = []
|
| 92 |
-
|
| 93 |
-
for user_idx, user in enumerate(users, 1):
|
| 94 |
-
user_name = user.get("name", f"User {user_idx}")
|
| 95 |
-
word_list = user.get("word_list", [])
|
| 96 |
-
sample_words += word_list # Update the global variable with the latest word list
|
| 97 |
-
|
| 98 |
-
if not word_list:
|
| 99 |
-
print(f"\n[{user_idx}] {user_name}: No words assigned, skipping")
|
| 100 |
-
continue
|
| 101 |
-
|
| 102 |
-
print(f"\n[{user_idx}] {user_name}")
|
| 103 |
-
print(f" Words: {len(word_list)} words")
|
| 104 |
-
print(f" Sample: {', '.join(word_list[:5])}{'...' if len(word_list) > 5 else ''}")
|
| 105 |
-
|
| 106 |
-
# Compute difficulties using all three functions
|
| 107 |
-
total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, word_list)
|
| 108 |
-
total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, word_list)
|
| 109 |
-
total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, word_list)
|
| 110 |
-
|
| 111 |
-
print(f"\n Function 1 (compute_word_difficulties):")
|
| 112 |
-
print(f" Total Difficulty: {total_diff1:.4f}")
|
| 113 |
-
print(f" Words Processed: {len(difficulties1)}")
|
| 114 |
-
|
| 115 |
-
print(f"\n Function 2 (compute_word_difficulties2):")
|
| 116 |
-
print(f" Total Difficulty: {total_diff2:.4f}")
|
| 117 |
-
print(f" Words Processed: {len(difficulties2)}")
|
| 118 |
-
|
| 119 |
-
print(f"\n Function 3 (compute_word_difficulties3):")
|
| 120 |
-
print(f" Total Difficulty: {total_diff3:.4f}")
|
| 121 |
-
print(f" Words Processed: {len(difficulties3)}")
|
| 122 |
-
|
| 123 |
-
# Calculate statistics
|
| 124 |
-
if difficulties1 and difficulties2 and difficulties3:
|
| 125 |
-
avg_diff1 = total_diff1 / len(difficulties1)
|
| 126 |
-
avg_diff2 = total_diff2 / len(difficulties2)
|
| 127 |
-
avg_diff3 = total_diff3 / len(difficulties3)
|
| 128 |
-
|
| 129 |
-
print(f"\n Comparison:")
|
| 130 |
-
print(f" Average Difficulty (Func1): {avg_diff1:.4f}")
|
| 131 |
-
print(f" Average Difficulty (Func2): {avg_diff2:.4f}")
|
| 132 |
-
print(f" Average Difficulty (Func3): {avg_diff3:.4f}")
|
| 133 |
-
print(f" Difference (Func1 vs Func2): {abs(avg_diff1 - avg_diff2):.4f}")
|
| 134 |
-
print(f" Difference (Func1 vs Func3): {abs(avg_diff1 - avg_diff3):.4f}")
|
| 135 |
-
print(f" Difference (Func2 vs Func3): {abs(avg_diff2 - avg_diff3):.4f}")
|
| 136 |
-
|
| 137 |
-
# Store results for final summary
|
| 138 |
-
all_results.append({
|
| 139 |
-
"user_name": user_name,
|
| 140 |
-
"word_count": len(word_list),
|
| 141 |
-
"total_diff1": total_diff1,
|
| 142 |
-
"total_diff2": total_diff2,
|
| 143 |
-
"total_diff3": total_diff3,
|
| 144 |
-
"difficulties1": difficulties1,
|
| 145 |
-
"difficulties2": difficulties2,
|
| 146 |
-
"difficulties3": difficulties3,
|
| 147 |
-
})
|
| 148 |
-
|
| 149 |
-
# Step 6: Print summary comparison
|
| 150 |
-
print("\n" + "="*80)
|
| 151 |
-
print("OVERALL SUMMARY")
|
| 152 |
-
print("="*80)
|
| 153 |
-
|
| 154 |
-
if all_results:
|
| 155 |
-
total1_sum = sum(r["total_diff1"] for r in all_results)
|
| 156 |
-
total2_sum = sum(r["total_diff2"] for r in all_results)
|
| 157 |
-
total3_sum = sum(r["total_diff3"] for r in all_results)
|
| 158 |
-
total_words = sum(r["word_count"] for r in all_results)
|
| 159 |
-
|
| 160 |
-
print(f"\nTotal Users Analyzed: {len(all_results)}")
|
| 161 |
-
print(f"Total Words Across All Users: {total_words}")
|
| 162 |
-
print(f"\nAggregate Total Difficulty:")
|
| 163 |
-
print(f" Function 1: {total1_sum:.4f}")
|
| 164 |
-
print(f" Function 2: {total2_sum:.4f}")
|
| 165 |
-
print(f" Function 3: {total3_sum:.4f}")
|
| 166 |
-
print(f" Difference (Func1 vs Func2): {abs(total1_sum - total2_sum):.4f}")
|
| 167 |
-
print(f" Difference (Func1 vs Func3): {abs(total1_sum - total3_sum):.4f}")
|
| 168 |
-
print(f" Difference (Func2 vs Func3): {abs(total2_sum - total3_sum):.4f}")
|
| 169 |
-
|
| 170 |
-
# Validate that all functions returned results for all users
|
| 171 |
-
assert all(r["difficulties1"] for r in all_results), "Function 1 failed for some users"
|
| 172 |
-
assert all(r["difficulties2"] for r in all_results), "Function 2 failed for some users"
|
| 173 |
-
assert all(r["difficulties3"] for r in all_results), "Function 3 failed for some users"
|
| 174 |
-
|
| 175 |
-
print("\n✓ All tests passed!")
|
| 176 |
-
else:
|
| 177 |
-
print("\n⚠ No users with words found in this challenge")
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def test_compare_difficulty_functions_with_classic_wordlist():
|
| 181 |
-
"""
|
| 182 |
-
Test all three difficulty functions using the classic.txt wordlist
|
| 183 |
-
with a sample set of words.
|
| 184 |
-
"""
|
| 185 |
-
global sample_words # Use the global variable
|
| 186 |
-
|
| 187 |
-
words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
|
| 188 |
-
wordlist_path = os.path.join(words_dir, "classic.txt")
|
| 189 |
-
|
| 190 |
-
if not os.path.exists(wordlist_path):
|
| 191 |
-
pytest.skip(f"classic.txt not found at {wordlist_path}")
|
| 192 |
-
|
| 193 |
-
# Use the global sample_words if already populated, otherwise set a default
|
| 194 |
-
if not sample_words:
|
| 195 |
-
sample_words = ["ABLE", "ACID", "AREA", "ARMY", "BEAR", "BOWL", "CAVE", "COIN", "ECHO", "GOLD"]
|
| 196 |
-
|
| 197 |
-
print("\n" + "="*80)
|
| 198 |
-
print("TESTING WITH CLASSIC.TXT WORDLIST")
|
| 199 |
-
print("="*80)
|
| 200 |
-
print(f"Sample Words: {', '.join(sample_words)}")
|
| 201 |
-
|
| 202 |
-
# Compute difficulties
|
| 203 |
-
total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, sample_words)
|
| 204 |
-
total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, sample_words)
|
| 205 |
-
total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, sample_words)
|
| 206 |
-
|
| 207 |
-
print(f"\nFunction compute_word_difficulties Results:")
|
| 208 |
-
print(f" Total Difficulty: {total_diff1:.4f}")
|
| 209 |
-
for word in sample_words:
|
| 210 |
-
if word in difficulties1:
|
| 211 |
-
print(f" {word}: {difficulties1[word]:.4f}")
|
| 212 |
-
|
| 213 |
-
print(f"\nFunction compute_word_difficulties2 Results:")
|
| 214 |
-
print(f" Total Difficulty: {total_diff2:.4f}")
|
| 215 |
-
for word in sample_words:
|
| 216 |
-
if word in difficulties2:
|
| 217 |
-
print(f" {word}: {difficulties2[word]:.4f}")
|
| 218 |
-
|
| 219 |
-
print(f"\nFunction compute_word_difficulties3 Results:")
|
| 220 |
-
print(f" Total Difficulty: {total_diff3:.4f}")
|
| 221 |
-
for word in sample_words:
|
| 222 |
-
if word in difficulties3:
|
| 223 |
-
print(f" {word}: {difficulties3[word]:.4f}")
|
| 224 |
-
|
| 225 |
-
# Assertions
|
| 226 |
-
assert len(difficulties1) == len(set(sample_words)), "Function 1 didn't process all words"
|
| 227 |
-
assert len(difficulties2) == len(set(sample_words)), "Function 2 didn't process all words"
|
| 228 |
-
assert len(difficulties3) == len(set(sample_words)), "Function 3 didn't process all words"
|
| 229 |
-
assert total_diff1 > 0, "Function 1 total difficulty should be positive"
|
| 230 |
-
assert total_diff2 > 0, "Function 2 total difficulty should be positive"
|
| 231 |
-
assert total_diff3 > 0, "Function 3 total difficulty should be positive"
|
| 232 |
-
|
| 233 |
-
print("\n✓ Classic wordlist test passed!")
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
if __name__ == "__main__":
|
| 237 |
-
pytest.main(["-s", "-v", __file__])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_download_game_settings.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
# file: tests/test_download_game_settings.py
|
| 2 |
-
import os
|
| 3 |
-
import sys
|
| 4 |
-
import pytest
|
| 5 |
-
|
| 6 |
-
# Ensure the modules path is available
|
| 7 |
-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
-
|
| 9 |
-
from battlewords.modules.constants import HF_API_TOKEN # <-- Import the token
|
| 10 |
-
from battlewords.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 11 |
-
|
| 12 |
-
# Ensure the token is set for Hugging Face Hub
|
| 13 |
-
if HF_API_TOKEN:
|
| 14 |
-
os.environ["HF_API_TOKEN"] = HF_API_TOKEN
|
| 15 |
-
|
| 16 |
-
def test_download_settings_by_short_id_handles_both(capsys):
|
| 17 |
-
# Use a fixed short id for testing
|
| 18 |
-
short_id = "hDjsB_dl"
|
| 19 |
-
|
| 20 |
-
# Step 1: Resolve short ID to full URL
|
| 21 |
-
status, full_url = gen_full_url(
|
| 22 |
-
short_url=short_id,
|
| 23 |
-
repo_id=HF_REPO_ID,
|
| 24 |
-
json_file=SHORTENER_JSON_FILE
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
# Failure branch: provide a helpful message and assert expected failure shape
|
| 28 |
-
if status != "success_retrieved_full" or not full_url:
|
| 29 |
-
print(
|
| 30 |
-
f"Could not resolve short id '{short_id}'. "
|
| 31 |
-
f"Status: {status}. "
|
| 32 |
-
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 33 |
-
)
|
| 34 |
-
captured = capsys.readouterr()
|
| 35 |
-
assert "Could not resolve short id" in captured.out
|
| 36 |
-
# Ensure failure shape is consistent
|
| 37 |
-
assert not full_url, "full_url should be empty/None on failure"
|
| 38 |
-
print("settings.json was not found or could not be resolved.")
|
| 39 |
-
return
|
| 40 |
-
else:
|
| 41 |
-
print(f"Resolved short id '{short_id}' to full URL: {full_url}")
|
| 42 |
-
|
| 43 |
-
# Success branch
|
| 44 |
-
assert status == "success_retrieved_full", f"Failed to resolve short ID: {status}"
|
| 45 |
-
assert full_url, "No full URL returned"
|
| 46 |
-
|
| 47 |
-
# Step 2: Extract file path from full URL
|
| 48 |
-
url_parts = full_url.split("/resolve/main/")
|
| 49 |
-
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 50 |
-
file_path = url_parts[1]
|
| 51 |
-
|
| 52 |
-
# Step 3: Download and parse settings.json
|
| 53 |
-
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 54 |
-
assert settings, "Failed to download or parse settings.json"
|
| 55 |
-
|
| 56 |
-
print("\nDownloaded settings.json contents:", settings)
|
| 57 |
-
# Optionally, add more assertions about the settings structure
|
| 58 |
-
assert "challenge_id" in settings
|
| 59 |
-
assert "wordlist_source" in settings
|
| 60 |
-
assert "users" in settings
|
| 61 |
-
|
| 62 |
-
if __name__ == "__main__":
|
| 63 |
-
pytest.main(["-s", __file__])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_generator.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
import unittest
|
| 2 |
-
|
| 3 |
-
from battlewords.generator import generate_puzzle, validate_puzzle
|
| 4 |
-
from battlewords.models import Coord
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class TestGenerator(unittest.TestCase):
|
| 8 |
-
def test_generate_valid_puzzle(self):
|
| 9 |
-
# Provide a minimal word pool for deterministic testing
|
| 10 |
-
words_by_len = {
|
| 11 |
-
4: ["TREE", "BOAT"],
|
| 12 |
-
5: ["APPLE", "RIVER"],
|
| 13 |
-
6: ["ORANGE", "PYTHON"],
|
| 14 |
-
}
|
| 15 |
-
p = generate_puzzle(grid_size=12, words_by_len=words_by_len, seed=1234)
|
| 16 |
-
validate_puzzle(p, grid_size=12)
|
| 17 |
-
# Ensure 6 words and 6 radar pulses
|
| 18 |
-
self.assertEqual(len(p.words), 6)
|
| 19 |
-
self.assertEqual(len(p.radar), 6)
|
| 20 |
-
# Ensure no overlaps
|
| 21 |
-
seen = set()
|
| 22 |
-
for w in p.words:
|
| 23 |
-
for c in w.cells:
|
| 24 |
-
self.assertNotIn(c, seen)
|
| 25 |
-
seen.add(c)
|
| 26 |
-
self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
|
| 27 |
-
|
| 28 |
-
if __name__ == "__main__":
|
| 29 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_logic.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
import unittest
|
| 2 |
-
|
| 3 |
-
from battlewords.logic import build_letter_map, reveal_cell, guess_word, is_game_over
|
| 4 |
-
from battlewords.models import Coord, Word, Puzzle, GameState
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class TestLogic(unittest.TestCase):
|
| 8 |
-
def make_state(self):
|
| 9 |
-
w1 = Word("TREE", Coord(0, 0), "H")
|
| 10 |
-
w2 = Word("APPLE", Coord(2, 0), "H")
|
| 11 |
-
w3 = Word("ORANGE", Coord(4, 0), "H")
|
| 12 |
-
w4 = Word("WIND", Coord(0, 6), "V")
|
| 13 |
-
w5 = Word("MOUSE", Coord(0, 8), "V")
|
| 14 |
-
w6 = Word("PYTHON", Coord(0, 10), "V")
|
| 15 |
-
p = Puzzle([w1, w2, w3, w4, w5, w6])
|
| 16 |
-
state = GameState(
|
| 17 |
-
grid_size=12,
|
| 18 |
-
puzzle=p,
|
| 19 |
-
revealed=set(),
|
| 20 |
-
guessed=set(),
|
| 21 |
-
score=0,
|
| 22 |
-
last_action="",
|
| 23 |
-
can_guess=False,
|
| 24 |
-
)
|
| 25 |
-
return state, p
|
| 26 |
-
|
| 27 |
-
def test_reveal_and_guess_gating(self):
|
| 28 |
-
state, puzzle = self.make_state()
|
| 29 |
-
letter_map = build_letter_map(puzzle)
|
| 30 |
-
# Can't guess before reveal
|
| 31 |
-
ok, pts = guess_word(state, "TREE")
|
| 32 |
-
self.assertFalse(ok)
|
| 33 |
-
self.assertEqual(pts, 0)
|
| 34 |
-
# Reveal one cell then guess
|
| 35 |
-
reveal_cell(state, letter_map, Coord(0, 0))
|
| 36 |
-
self.assertTrue(state.can_guess)
|
| 37 |
-
ok, pts = guess_word(state, "TREE")
|
| 38 |
-
self.assertTrue(ok)
|
| 39 |
-
self.assertGreater(pts, 0)
|
| 40 |
-
self.assertIn("TREE", state.guessed)
|
| 41 |
-
self.assertFalse(state.can_guess)
|
| 42 |
-
|
| 43 |
-
def test_game_over(self):
|
| 44 |
-
state, puzzle = self.make_state()
|
| 45 |
-
letter_map = build_letter_map(puzzle)
|
| 46 |
-
# Guess all words after a reveal each time
|
| 47 |
-
for w in puzzle.words:
|
| 48 |
-
reveal_cell(state, letter_map, w.start)
|
| 49 |
-
ok, _ = guess_word(state, w.text)
|
| 50 |
-
self.assertTrue(ok)
|
| 51 |
-
self.assertTrue(is_game_over(state))
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
if __name__ == "__main__":
|
| 55 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|