diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..b3222711146fb2a44b22030d43da2434644d0605 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,37 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..10e6911356c1314be7b5d02f898524ba103b56be --- /dev/null +++ b/.gitignore @@ -0,0 +1,493 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env +*.venv + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc +**/**/__pycache__/ +**/*.pyc + + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# VS Code +.vscode/ + +# Streamlit +#.streamlit/ + +# Docker +*.env +.env.* + +# System files +.DS_Store +Thumbs.db + +# Local words directory (if you want to ignore user-added wordlists) +# wrdler/words/*.txt + +# Ignore secrets +secrets.* +/.vs +/wrdler/__pycache__/ui.cpython-311.pyc +/wrdler/__pycache__/__init__.cpython-311.pyc +/package.json +/package-lock.json +/.claude diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..282aa68084f94e210725974f7f806b2e3188f130 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,10 @@ +[server] +enableStaticServing = true + +[theme] +base="dark" +primaryColor="#1d64c8" +backgroundColor="#1d64c8" +secondaryBackgroundColor="#262730" +textColor="#ffffff" +font="sans serif" \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..52e675ea24646d1a15e8641d9107ceba9cdc5409 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,426 @@ +# Wrdler - Project Context + +## Project Overview +Wrdler is a simplified vocabulary puzzle game based on BattleWords, with these key differences: +- **8x6 grid** (instead of 12x12) +- **One word per row, horizontal only** (no vertical words) +- **No scope/radar visualization** +- **2 free letter guesses at game start** (all instances of chosen letters are revealed) + +**Current Version:** 0.0.1 (Initial Wrdler release) +**Repository:** https://github.com/Oncorporation/Wrdler.git +**Live Demo:** [DEPLOYMENT_URL_HERE] + +## Recent Changes + +**Latest (v0.0.1):** +- Project renamed from BattleWords to Wrdler +- Grid resized from 12x12 to 8x6 +- Removed vertical word placement (horizontal only) +- Removed scope/radar visualization +- Added 2 free letter guesses at game start +- Version reset to 0.0.1 +- All documentation updated to reflect Wrdler specifications + +## Core Gameplay +- 8x6 grid with 6 hidden words (one per row, horizontal only) +- No scope/radar visualization +- Players start by choosing 2 letters; all instances are revealed +- Players click cells to reveal letters or empty spaces +- After revealing a letter, players can guess words +- Scoring: word length + bonus for unrevealed letters +- Game ends when all words are guessed or all word letters are revealed +- Incorrect guess history with optional display (enabled by default) +- 10 incorrect guess limit per game +- **✅ IMPLEMENTED:** Challenge Mode with game sharing via short URLs +- **✅ IMPLEMENTED:** Remote storage via Hugging Face datasets +- **✅ IMPLEMENTED:** PWA install support +- **PLANNED:** Local persistent storage for game results and high scores + +### Scoring Tiers +- **Fantastic:** 42+ points +- **Great:** 38-41 points +- **Good:** 34-37 points +- **Keep practicing:** < 34 points + +## Technical Architecture + +### Technology Stack +- **Framework:** Streamlit 1.51.0 +- **Language:** Python 3.12.8 +- **Visualization:** Matplotlib, NumPy +- **Data Processing:** Pandas, Altair +- **Storage:** JSON-based local persistence +- **Testing:** Pytest +- **Package Manager:** UV + +### Project Structure +``` +wrdler/ +├── app.py # Streamlit entry point +├── wrdler/ # Main package +│ ├── __init__.py # Version: 0.0.1 +│ ├── models.py # Data models (Coord, Word, Puzzle, GameState) +│ ├── generator.py # Puzzle generation with deterministic seeding +│ ├── logic.py # Game mechanics (reveal, guess, scoring) +│ ├── ui.py # Streamlit UI +│ ├── word_loader.py # Word list management +│ ├── audio.py # Background music system +│ ├── sounds.py # Sound effects management +│ ├── generate_sounds.py # Sound generation utilities +│ ├── game_storage.py # HF game storage wrapper +│ ├── version_info.py # Version display +│ ├── modules/ # Shared utility modules (from OpenBadge) +│ │ ├── __init__.py # Module exports +│ │ ├── storage.py # HuggingFace storage & URL shortener +│ │ ├── storage.md # Storage module documentation +│ │ ├── constants.py # Storage-related constants (trimmed) +│ │ └── file_utils.py # File utility functions +│ └── words/ # Word list files +│ ├── classic.txt # Default word list +│ ├── fourth_grade.txt # Elementary word list +│ └── wordlist.txt # Full word list +├── tests/ # Unit tests +├── specs/ # Documentation +│ ├── specs.md # Game specifications +│ ├── requirements.md # Implementation requirements +│ └── history.md # Game history +├── .env # Environment variables +├── pyproject.toml # Project metadata +├── requirements.txt # Dependencies +├── uv.lock # UV lock file +├── Dockerfile # Container deployment +└── CLAUDE.md # This file - project context for Claude +``` + +## Key Features + +### Game Modes +1. **Classic Mode:** Allows consecutive guessing after correct answers +2. **Too Easy Mode:** Single guess per reveal + +### Audio & Visual Effects +- **Background Music:** Toggleable ocean-themed background music with volume control +- **Sound Effects:** Hit/miss/correct/incorrect guess sounds with volume control +- **Animated Radar:** Pulsing rings showing word boundaries (last letter locations) +- **Ocean Theme:** Gradient animated background with wave effects +- **Incorrect Guess History:** Visual display of wrong guesses (toggleable in settings) + +### ✅ Challenge Mode & Remote Storage (v0.2.20+) +- **Game ID System:** Short URL-based challenge sharing + - Format: `?game_id=` in URL (shortened URL reference) + - Each player gets different random words from the same wordlist + - Enables fair challenges between players + - Stored in Hugging Face dataset repository +- **Remote Storage via HuggingFace Hub:** + - Per-game settings JSON in `games/{uid}/settings.json` + - Shortened URL mapping in `shortener.json` + - Multi-user leaderboards with score, time, and difficulty tracking + - Results sorted by: highest score → fastest time → highest difficulty +- **Challenge Features:** + - Submit results to existing challenges + - Create new challenges from any completed game + - Top 5 leaderboard display in Challenge Mode banner + - Optional player names (defaults to "Anonymous") + - Word list difficulty calculation and display + - "Show Challenge Share Links" toggle (default OFF) to control URL visibility + +### PLANNED: Local Player Storage (v0.3.0) +- **Local Storage:** + - Location: `~/.wrdler/data/` + - Files: `game_results.json`, `highscores.json` + - Privacy-first: no cloud dependency, offline-capable +- **Personal High Scores:** + - Top 100 scores tracked automatically on local machine + - Filterable by wordlist and game mode + - High score sidebar expander display +- **Player Statistics:** + - Games played, average score, best score + - Fastest completion time + - Per-player history on local device + +### Puzzle Generation +- Deterministic seeding support for reproducible puzzles +- Configurable word spacing (spacer: 0-2) + - 0: Words may touch + - 1: At least 1 blank cell between words (default) + - 2: At least 2 blank cells between words +- Validation ensures no overlaps, proper bounds, correct word distribution + +### UI Components (Current) +- **Game Grid:** Interactive 8x6 button grid with responsive layout +- **Score Panel:** Real-time scoring with client-side JavaScript timer +- **Settings Sidebar:** + - Word list picker (classic, fourth_grade, wordlist) + - Game mode selector + - Word spacing configuration (0-2) + - Audio volume controls (music and effects separate) + - Toggle for incorrect guess history display +- **Theme System:** Ocean gradient background with CSS animations +- **Game Over Dialog:** Final score display with tier ranking +- **Incorrect Guess Display:** Shows history of wrong guesses with count +- **✅ Challenge Mode UI (v0.2.20+):** + - Challenge Mode banner with leaderboard (top 5 players) + - Share challenge button in game over dialog + - Submit result or create new challenge options + - Word list difficulty display + - Conditional share URL visibility toggle +- **PLANNED (v0.3.0):** Local high scores expander in sidebar +- **PLANNED (v0.3.0):** Personal statistics display + +### Recent Changes & Branch Status +**Branch:** cc-01 (Storage and sharing features - v0.3.0 development) + +**Latest (v0.2.17):** +- Documentation updates and corrections + - Updated CLAUDE.md with accurate feature status + - Clarified v0.3.0 planned features vs current implementation + - Added comprehensive project structure details + - Improved version tracking and roadmap clarity + +**Previously Fixed (v0.2.16):** +- Replace question marks with underscores in score panel +- Add toggle for incorrect guess history display (enabled by default) +- Game over popup positioning improvements +- Music playback after game end +- Sound effect and music volume issues +- Radar alignment inconsistencies + - Added `fig.subplots_adjust(left=0, right=0.9, top=0.9, bottom=0)` + - Set `fig.patch.set_alpha(0.0)` for transparent background + - Maintains 2% margin for tick visibility while ensuring consistent layer alignment + +**Completed (v0.2.20-0.2.27 - Challenge Mode):** +- ✅ Imported storage modules from OpenBadge project: + - `wrdler/modules/storage.py` (v0.1.5) - HuggingFace storage & URL shortener + - `wrdler/modules/constants.py` (trimmed) - Storage-related constants + - `wrdler/modules/file_utils.py` - File utility functions + - `wrdler/modules/storage.md` - Documentation +- ✅ Created `wrdler/game_storage.py` (v0.1.0) - Wrdler storage wrapper: + - `save_game_to_hf()` - Save game to HF repo and generate short URL + - `load_game_from_sid()` - Load game from short ID + - `generate_uid()` - Generate unique game identifiers + - `serialize_game_settings()` - Convert game data to JSON + - `get_shareable_url()` - Generate shareable URLs + - `add_user_result_to_game()` - Append results to existing challenges +- ✅ UI integration complete (`wrdler/ui.py`): + - Query parameter parsing for `?game_id=` on app load + - Load shared game settings into session state + - Challenge Mode banner with leaderboard (top 5) + - Share button in game over dialog with "Generate Share Link" or "Submit Result" + - Conditional share URL display based on settings toggle + - Automatic save to HuggingFace on game completion + - Word list difficulty calculation and display +- ✅ Generator updates (`wrdler/generator.py`): + - Added `target_words` parameter for loading specific words + - Added `may_overlap` parameter (for future crossword mode) + - Support for shared game replay with randomized word positions + +**In Progress (v0.3.0 - Local Player History):** +- ⏳ Local storage module (`wrdler/local_storage.py`) +- ⏳ Personal high score tracking (local JSON files) +- ⏳ High score sidebar UI display +- ⏳ Player statistics tracking and display + +## Data Models + +### Core Classes +```python +@dataclass +class Coord: + x: int # row, 0-based + y: int # col, 0-based + +@dataclass +class Word: + text: str + start: Coord + direction: Direction # "H" or "V" + cells: List[Coord] + +@dataclass +class Puzzle: + words: List[Word] + radar: List[Coord] + may_overlap: bool + spacer: int + uid: str # Unique identifier for caching + +@dataclass +class GameState: + grid_size: int + puzzle: Puzzle + revealed: Set[Coord] + guessed: Set[str] + score: int + last_action: str + can_guess: bool + game_mode: str + points_by_word: Dict[str, int] + start_time: Optional[datetime] + end_time: Optional[datetime] +``` + +## Development Workflow + +### Running Locally +```bash +# Install dependencies +uv pip install -r requirements.txt --link-mode=copy + +# Run app +uv run streamlit run app.py +# or +streamlit run app.py +``` + +### Docker Deployment +```bash +docker build -t wrdler . +docker run -p 8501:8501 wrdler +``` + +### Testing +```bash +pytest tests/ +``` + +### Environment Variables (for Challenge Mode) +Challenge Mode requires HuggingFace Hub access for remote storage. Create a `.env` file in the project root: + +```bash +# Required for Challenge Mode +HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN +HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo +SPACE_NAME=YourUsername/Wrdler # Your HF Space name + +# Optional +CRYPTO_PK= # Reserved for future signing +``` + +**How to get your HF_API_TOKEN:** +1. Go to https://huggingface.co/settings/tokens +2. Create a new token with `write` access +3. Add to `.env` file as `HF_API_TOKEN=hf_...` + +**HF_REPO_ID Structure:** +The dataset repository will contain: +- `shortener.json` - Short URL mappings +- `games/{uid}/settings.json` - Per-game challenge data +- `games/{uid}/result.json` - Optional detailed results + +**Note:** The app will work without these variables but Challenge Mode features (sharing, leaderboards) will be disabled. + +## Git Configuration & Deployment +**Current Branch:** main (or development branch) +**Purpose:** Wrdler - vocabulary puzzle game with simplified 8x6 grid +**Main Branch:** main + +### Remotes +- **ONCORP (origin):** https://github.com/Oncorporation/Wrdler.git (main repository) +- **Hugging:** https://huggingface.co/spaces/[USERNAME]/Wrdler (live deployment) + +## Known Issues +- Word list loading bug: App may not select proper word lists in some environments + - Investigation needed in `word_loader.get_wordlist_files()` and `load_word_list()` + - Sidebar selection persistence needs verification + +## v0.0.1 Development Status + +### Completed ✅ +- Project renamed from BattleWords to Wrdler +- Grid resized from 12x12 to 8x6 +- Removed vertical word placement (horizontal only) +- Removed scope/radar visualization +- Added 2 free letter guesses at game start +- Updated version to 0.0.1 +- Updated all documentation + +### In Progress ⏳ +- Generator updates for 8x6 grid and horizontal-only placement +- UI adjustments for new grid size and free letter guesses +- Testing with new gameplay mechanics + +### Planned 📋 +- Local persistent storage module +- High score tracking and display +- Player statistics +- Share results functionality + +## Future Roadmap + +### Phase 1.0 (v0.0.1) - Current ✅ +- 8x6 grid with horizontal words only +- Free letter guesses at start +- Challenge Mode with remote storage +- PWA support + +### Phase 2.0 (v0.1.0) +- Local persistent storage (backend complete) +- High score tracking and display +- Player statistics + +### Phase 3.0 (v1.0.0) +- Enhanced UX and animations +- Multiple difficulty levels +- Daily puzzle mode +- Internationalization (i18n) support + +## Deployment Targets +- **Hugging Face Spaces:** Primary deployment platform +- **Docker:** Containerized deployment for any platform +- **Local:** Development and testing + +### Privacy & Data +- All storage is local (no telemetry) +- Player names optional +- No data leaves user's machine +- Easy to delete: just remove `~/.wrdler/data/` + +## Notes for Claude +- Project uses modern Python features (3.12+) +- Heavy use of Streamlit session state for game state management +- Client-side JavaScript for timer updates without page refresh +- CSS heavily customized for game aesthetics +- All file paths should be absolute when working in WSL environment +- Storage features are backward-compatible (game works without storage) +- Game IDs are deterministic for consistent sharing +- JSON storage chosen for simplicity and privacy +- Generator needs updating to handle 8x6 grid and horizontal-only placement +- Radar/scope visualization removed entirely + +### WSL Environment Python Versions +The development environment is WSL (Windows Subsystem for Linux) with access to both native Linux and Windows Python installations: + +**Native WSL (Linux):** +- `python3` → Python 3.10.12 (`/usr/bin/python3`) +- `python3.10` → Python 3.10.12 + +**Windows Python (accessible via WSL):** +- `python311.exe` → Python 3.11.9 (`/mnt/c/Users/cfettinger/AppData/Local/Programs/Python/Python311/`) +- `python3.13.exe` → Python 3.13.1 (`/mnt/c/ProgramData/chocolatey/bin/`) + +**Note:** Windows Python executables (`.exe`) can be invoked directly from WSL and are useful for testing compatibility across Python versions. The project targets Python 3.12+ but can run on 3.10+. + +## Documentation Structure + +This file (CLAUDE.md) serves as a **living context document** for AI-assisted development. It complements the formal specification documents: + +- **[specs/specs.md](specs/specs.md)** - Game rules, requirements, and feature specifications +- **[specs/requirements.md](specs/requirements.md)** - Implementation phases, acceptance criteria, and technical tasks +- **[README.md](README.md)** - User-facing documentation, installation guide, and changelog + +**When to use each:** +- **specs.md** - Understanding game rules, scoring, and player experience +- **requirements.md** - Planning implementation work, tracking phases, and defining done criteria +- **CLAUDE.md** - Quick reference for codebase structure, recent changes, and development context +- **README.md** - Public-facing information, setup instructions, and feature announcements + +**Synchronization:** +Changes to game mechanics should update specs.md → requirements.md → CLAUDE.md → README.md in that order + +## Challenge Mode & Remote Storage + +- The app supports a Challenge Mode where games can be shared via a short link (`?game_id=`). +- Results are stored in a Hugging Face dataset repo using `game_storage.py`. +- The leaderboard for a challenge is sorted by highest score (descending), then by fastest time (ascending). +- Each user result is appended to the challenge's `users` array in the remote JSON. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4e746a36893497d41eedbcb31d0ab2af2298427e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +FROM python:3.12.8-slim + +WORKDIR /app + +# System dependencies required for runtime +# - curl for debugging +# - git if needed by pip +# - libfreetype6 and libpng16-16 required by matplotlib (Agg backend) +# - fonts-dejavu-core for font rendering +# - libglib2.0-0, libsm6, libxext6, libxrender1 are safe image libs some backends use +# - ca-certificates to avoid TLS issues during pip installs and at runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + git \ + ca-certificates \ + libfreetype6 \ + libpng16-16 \ + fonts-dejavu-core \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + && rm -rf /var/lib/apt/lists/* + +# Environment optimizations and Streamlit defaults +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + STREAMLIT_SERVER_HEADLESS=true \ + STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + MPLBACKEND=Agg \ + MPLCONFIGDIR=/tmp/matplotlib + +# Upgrade pip tooling to avoid build failures +RUN python -m pip install --upgrade pip setuptools wheel + +# Install Python dependencies first (layer caching) +COPY requirements.txt ./ +RUN pip3 install -r requirements.txt + +# Copy PWA injection files +COPY pwa-head-inject.html ./pwa-head-inject.html +COPY inject-pwa-head.sh ./inject-pwa-head.sh +RUN chmod +x ./inject-pwa-head.sh && ./inject-pwa-head.sh + +# Copy application source +COPY app.py ./app.py +COPY wrdler ./wrdler +COPY static ./static + +# Hugging Face Spaces sets $PORT (default 7860). Expose it for clarity. using 8501 for local consistency with Streamlit defaults + +EXPOSE 8501 + +HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health + +# Rely on Spaces health checking; do not add Docker HEALTHCHECK to avoid premature failures + +# Use shell form so $PORT expands at runtime +ENTRYPOINT ["sh", "-c", "streamlit run app.py --server.port=${PORT:-8501} --server.address=0.0.0.0"] \ No newline at end of file diff --git a/LOCALHOST_PWA_README.md b/LOCALHOST_PWA_README.md new file mode 100644 index 0000000000000000000000000000000000000000..79c5f703b1cf834dd6f15a5f37c3d4b4d8e23258 --- /dev/null +++ b/LOCALHOST_PWA_README.md @@ -0,0 +1,269 @@ +# PWA on Localhost - Important Information + +## Summary + +**The PWA files were created successfully**, but they **won't work fully on `localhost:8501`** due to Streamlit's static file serving limitations. + +--- + +## What You're Seeing (or Not Seeing) + +### ✅ What DOES Work on Localhost: + +1. **Game functionality**: Everything works normally +2. **Challenge Mode**: Loading `?game_id=...` works (if HF credentials configured) +3. **PWA meta tags**: Injected into HTML (check page source) +4. **Service worker registration attempt**: Runs in browser console + +### ❌ What DOESN'T Work on Localhost: + +1. **`manifest.json` not accessible**: + ``` + http://localhost:8501/app/static/manifest.json + → Returns HTML instead of JSON (Streamlit doesn't serve /app/static/) + ``` + +2. **Icons not accessible**: + ``` + http://localhost:8501/app/static/icon-192.png + → Returns 404 or HTML + ``` + +3. **Service worker fails to register**: + ```javascript + // Browser console shows: + Failed to register service worker: 404 Not Found + ``` + +4. **No PWA install prompt**: + - No banner at bottom of screen + - No install icon in address bar + - PWA features disabled + +--- + +## Why This Happens + +**Streamlit's Static File Serving:** + +- Streamlit only serves files from: + - `/.streamlit/static/` (internal Streamlit assets) + - Component assets via `declare_component()` + - NOT from arbitrary `battlewords/static/` directories + +- On HuggingFace Spaces: + - `/app/static/` is mapped by HF infrastructure + - Files in `battlewords/static/` are accessible at `/app/static/` + - ✅ PWA works perfectly + +- On localhost: + - No `/app/static/` mapping exists + - Streamlit returns HTML for all unrecognized paths + - ❌ PWA files return 404 + +--- + +## How to Test PWA Locally + +### Option 1: Use ngrok (HTTPS Tunnel) ⭐ **RECOMMENDED** + +This is the **best way** to test PWA locally with full functionality: + +```bash +# Terminal 1: Run Streamlit +streamlit run app.py + +# Terminal 2: Expose with HTTPS +ngrok http 8501 + +# Output shows: +# Forwarding https://abc123.ngrok-free.app -> http://localhost:8501 +``` + +**Then visit the HTTPS URL on your phone or desktop:** +- ✅ Full PWA functionality +- ✅ Install prompt appears +- ✅ manifest.json loads +- ✅ Service worker registers +- ✅ Icons display correctly + +**ngrok Setup:** +1. Download: https://ngrok.com/download +2. Sign up for free account +3. Install: `unzip /path/to/ngrok.zip` (or chocolatey on Windows: `choco install ngrok`) +4. Authenticate: `ngrok config add-authtoken ` +5. Run: `ngrok http 8501` + +--- + +### Option 2: Deploy to HuggingFace Spaces ⭐ **PRODUCTION** + +PWA works out-of-the-box on HF Spaces: + +```bash +git add wrdler/static/ wrdler/ui.py +git commit -m "Add PWA support" +git push + +# HF Spaces auto-deploys +# Visit: https://[YourUsername]-wrdler.hf.space +``` + +**Then test PWA:** +- Android Chrome: "Add to Home Screen" prompt appears +- iOS Safari: Share → "Add to Home Screen" +- Desktop Chrome: Install icon in address bar + +✅ **This is where PWA is meant to work!** + +--- + +###Option 3: Manual Static File Server (Advanced) + +You can serve the static files separately: + +```bash +# Terminal 1: Run Streamlit +streamlit run app.py + +# Terminal 2: Serve static files +cd wrdler/static +python3 -m http.server 8502 + +# Then access: +# Streamlit: http://localhost:8501 +# Static files: http://localhost:8502/manifest.json +``` + +**Then modify the PWA paths in `ui.py`:** +```python +pwa_meta_tags = """ + + + +""" +``` + +❌ **Not recommended**: Too complex, defeats the purpose + +--- + +## What About Challenge Mode? + +**Question:** "I loaded `localhost:8501/?game_id=hDjsB_dl` but don't see anything" + +**Answer:** Challenge Mode is **separate from PWA**. You should see a blue banner at the top if: + +### ✅ Requirements for Challenge Mode to Work: + +1. **Environment variables configured** (`.env` file): + ```bash + HF_API_TOKEN=hf_xxxxxxxxxxxxx + HF_REPO_ID=Surn/Storage + SPACE_NAME=Surn/BattleWords + ``` + +2. **Valid game_id exists** in the HF repo: + - `hDjsB_dl` must be a real challenge created previously + - Check HuggingFace dataset repo: https://huggingface.co/datasets/Surn/Storage + - Look for: `games//settings.json` + - Verify `shortener.json` has entry for `hDjsB_dl` + +3. **Internet connection** (to fetch challenge data) + +### If Challenge Mode ISN'T Working: + +**Check browser console (F12 → Console):** +```javascript +// Look for errors: +"[game_storage] Could not resolve sid: hDjsB_dl" ← Challenge not found +"Failed to load game from sid" ← HF API error +"HF_API_TOKEN not configured" ← Missing credentials +``` + +**If you see errors:** +1. Verify `.env` file exists with correct variables +2. Restart Streamlit (`Ctrl+C` and `streamlit run app.py` again) +3. Try a different `game_id` from a known challenge +4. Check HF repo has the challenge data + +**Note:** Challenge Mode works the same in Wrdler as it did in BattleWords. + +--- + +## Summary Table + +| Feature | Localhost | Localhost + ngrok | HF Spaces (Production) | +|---------|-----------|-------------------|------------------------| +| **Game works** | ✅ | ✅ | ✅ | +| **Challenge Mode** | ✅ (if .env configured) | ✅ | ✅ | +| **PWA manifest loads** | ❌ | ✅ | ✅ | +| **Service worker registers** | ❌ | ✅ | ✅ | +| **Install prompt** | ❌ | ✅ | ✅ | +| **Icons display** | ❌ | ✅ | ✅ | +| **Full-screen mode** | ❌ | ✅ | ✅ | + +--- + +## What You Should Do + +### For Development: +✅ **Just develop normally on localhost** +- Game features work fine +- Challenge Mode works (if .env configured) +- PWA features won't work, but that's okay +- Test PWA when you deploy + +### For PWA Testing: +✅ **Use ngrok for quick local PWA testing** +- 5 minutes to setup +- Full PWA functionality +- Test on real phone + +### For Production: +✅ **Deploy to HuggingFace Spaces** +- PWA works automatically +- No configuration needed +- `/app/static/` path works out-of-the-box + +--- + +## Bottom Line + +**Your question:** "Should I see something at the bottom of the screen?" + +**Answer:** + +1. **PWA install prompt**: ❌ Not on `localhost:8501` (Streamlit limitation) + - **Will work** on HF Spaces production deployment ✅ + - **Will work** with ngrok HTTPS tunnel ✅ + +2. **Challenge Mode banner**: ✅ Should appear at TOP (not bottom) + - Check if `?game_id=hDjsB_dl` exists in your HF repo + - Check browser console for errors + - Verify `.env` has `HF_API_TOKEN` configured + +The PWA implementation is **correct** and **ready for production**. It just won't work on bare localhost due to Streamlit's static file serving limitations. Once you deploy to HuggingFace Spaces, everything will work perfectly! + +--- + +## Quick Test Command + +```bash +# Check if .env is configured: +cat .env | grep HF_ + +# Should show: +# HF_API_TOKEN=hf_xxxxx +# HF_REPO_ID=YourUsername/Storage +# SPACE_NAME=YourUsername/Wrdler + +# If missing, Challenge Mode won't work locally +``` + +--- + +**Next Steps:** +1. Test game functionality on localhost ✅ +2. Deploy to HF Spaces for PWA testing ✅ +3. Or install ngrok for local PWA testing ✅ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..dd2f586611d64683da46b5637e41004518de5371 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include wrdler/words *.txt diff --git a/PWA_INSTALL_GUIDE.md b/PWA_INSTALL_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..1de604a5d2678dcf71ee93f0af3a57062267a7ad --- /dev/null +++ b/PWA_INSTALL_GUIDE.md @@ -0,0 +1,208 @@ +# Wrdler PWA Installation Guide + +Wrdler can now be installed as a Progressive Web App (PWA) on your mobile device or desktop, giving you a native app experience directly from your browser! + +## What is a PWA? + +A Progressive Web App allows you to: +- ✅ Install Wrdler on your home screen (Android/iOS) +- ✅ Run in full-screen mode without browser UI +- ✅ Access the app quickly from your app drawer +- ✅ Get automatic updates (always the latest version) +- ✅ Basic offline functionality (cached assets) + +## Installation Instructions + +### Android (Chrome, Edge, Samsung Internet) + +1. **Visit the app**: Open https://[YourUsername]-wrdler.hf.space in Chrome +2. **Look for the install prompt**: A banner will appear at the bottom saying "Add Wrdler to Home screen" +3. **Tap "Add"** or **"Install"** +4. **Alternative method** (if no prompt): + - Tap the **three-dot menu** (⋮) in the top-right + - Select **"Install app"** or **"Add to Home screen"** + - Tap **"Install"** +5. **Launch**: Find the Wrdler icon on your home screen and tap to open! + +**Result**: The app opens full-screen without the browser address bar, just like a native app. + +--- + +### iOS (Safari) + +**Note**: iOS requires using Safari browser (Chrome/Firefox won't work for PWA installation) + +1. **Visit the app**: Open https://[YourUsername]-wrdler.hf.space in Safari +2. **Tap the Share button**: The square with an arrow pointing up (at the bottom of the screen) +3. **Scroll down** and tap **"Add to Home Screen"** +4. **Edit the name** (optional): You can rename it from "Wrdler" if desired +5. **Tap "Add"** in the top-right corner +6. **Launch**: Find the Wrdler icon on your home screen and tap to open! + +**Result**: The app opens in standalone mode, similar to a native iOS app. + +--- + +### Desktop (Chrome, Edge, Brave) + +1. **Visit the app**: Open https://[YourUsername]-wrdler.hf.space +2. **Look for the install icon**: + - Chrome/Edge: Click the **install icon** (⊕) in the address bar + - Or click the **three-dot menu** → **"Install Wrdler"** +3. **Click "Install"** in the confirmation dialog +4. **Launch**: + - Windows: Find Wrdler in Start Menu or Desktop + - Mac: Find Wrdler in Applications folder + - Linux: Find in application launcher + +**Result**: Wrdler opens in its own window, separate from your browser. + +--- + +## Features of the PWA + +### Works Immediately ✅ +- Full game functionality (reveal cells, guess words, scoring) +- Challenge Mode (create and play shared challenges) +- Sound effects and background music +- Ocean-themed animated background +- All current features preserved + +### Offline Support 🌐 +- App shell cached for faster loading +- Icons and static assets available offline +- **Note**: Challenge Mode requires internet connection (needs to fetch/save from HuggingFace) + +### Updates 🔄 +- Automatic updates when you open the app +- Always get the latest features and bug fixes +- No manual update process needed + +### Privacy & Security 🔒 +- No new data collection (same as web version) +- Environment variables stay on server (never exposed to PWA) +- Service worker only caches public assets +- All game data in Challenge Mode handled server-side + +--- + +## Uninstalling the PWA + +### Android +1. Long-press the Wrdler icon +2. Tap "Uninstall" or drag to "Remove" + +### iOS +1. Long-press the Wrdler icon +2. Tap "Remove App" +3. Confirm "Delete App" + +### Desktop +- **Chrome/Edge**: Go to `chrome://apps` or `edge://apps`, right-click Wrdler, select "Uninstall" +- **Windows**: Settings → Apps → Wrdler → Uninstall +- **Mac**: Delete from Applications folder + +--- + +## Troubleshooting + +### "Install" option doesn't appear +- **Android**: Make sure you're using Chrome, Edge, or Samsung Internet (not Firefox) +- **iOS**: Must use Safari browser +- **Desktop**: Check if you're using a supported browser (Chrome, Edge, Brave) +- Try refreshing the page (the install prompt may take a moment to appear) + +### App won't open after installation +- Try uninstalling and reinstalling +- Clear browser cache and try again +- Make sure you have internet connection for first launch + +### Service worker errors in console +- This is normal during development +- The app will still function without the service worker +- Full offline support requires the service worker to register successfully + +### Icons don't show up correctly +- Wait a moment after installation (icons may take time to download) +- Try force-refreshing the PWA (close and reopen) + +--- + +## Technical Details + +### Files Added for PWA Support + +``` +wrdler/ +├── static/ +│ ├── manifest.json # PWA configuration +│ ├── service-worker.js # Offline caching logic +│ ├── icon-192.png # App icon (small) +│ └── icon-512.png # App icon (large) +└── ui.py # Added PWA meta tags +``` + +### What's Cached Offline + +- App shell (HTML structure) +- Icons (192x192, 512x512) +- Manifest file +- Previous game states (if you were playing before going offline) + +### What Requires Internet + +- Creating new challenges +- Submitting results to leaderboards +- Loading shared challenges +- Downloading word lists (first time) +- Fetching game updates + +--- + +## Comparison: PWA vs Native App + +| Feature | PWA | Native App | +|---------|-----|------------| +| Installation | Quick (1 tap) | Slow (app store) | +| Size | ~5-10 MB | ~15-30 MB | +| Updates | Automatic | Manual | +| Platform support | Android, iOS, Desktop | Separate builds | +| Offline mode | Partial | Full | +| Performance | 90% of native | 100% | +| App store presence | No | Yes | +| Development time | 2-4 hours ✅ | 40-60 hours per platform | + +--- + +## Feedback + +If you encounter issues installing or using the PWA, please: +1. Check the browser console for errors (F12 → Console tab) +2. Report issues at: https://github.com/Oncorporation/Wrdler/issues +3. Include: Device type, OS version, browser version, and error messages + +--- + +## For Developers + +To regenerate the PWA icons: +```bash +python3 generate_pwa_icons.py +``` + +To modify PWA behavior: +- Edit `wrdler/static/manifest.json` (app metadata) +- Edit `wrdler/static/service-worker.js` (caching logic) +- Edit `wrdler/ui.py` (PWA meta tags, lines 34-86) + +To test PWA locally: +```bash +streamlit run app.py +# Open http://localhost:8501 in Chrome +# Chrome DevTools → Application → Manifest (verify manifest.json loads) +# Chrome DevTools → Application → Service Workers (verify registration) +``` + +--- + +**Enjoy Wrdler as a native-like app experience! 🎮🌊** diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..31e9f846a5240e9819138fa007bead5d730590dc --- /dev/null +++ b/README.md @@ -0,0 +1,535 @@ +--- +title: Wrdler +emoji: 🎲 +colorFrom: blue +colorTo: indigo +sdk: streamlit +sdk_version: 1.51.0 +python_version: 3.12.8 +app_port: 8501 +app_file: app.py +tags: + - game + - vocabulary + - streamlit + - education +--- + +# Wrdler + +> **This project is based on BattleWords, but adapted for a simpler word puzzle game with an 8x6 grid, horizontal words only, and free letter guesses at the start.** + +Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed. + +## Key Differences from BattleWords + +- **8x6 grid** (instead of 12x12) with **6 words total** (one per row) +- **Horizontal words only** (no vertical placement) +- **No scope/radar visualization** +- **2 free letter guesses** at the start - choose letters to reveal all instances in the grid + +## Features + +### Core Gameplay +- 8x6 grid with six hidden words (one per row, all horizontal) +- Game starts with 2 free letter guesses; all instances of chosen letters are revealed +- Reveal grid cells and guess words for points +- Scoring tiers: Good (34–37), Great (38–41), Fantastic (42+) +- Game ends when all words are guessed or all word letters are revealed +- Incorrect guess history with tooltip and optional display (enabled by default) +- 10 incorrect guess limit per game +- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal) + +### Audio & Visuals +- Ocean-themed gradient background with wave animations +- Background music system (toggleable with volume control) +- Sound effects for hits, misses, correct/incorrect guesses +- Responsive UI built with Streamlit + +### Customization +- Multiple word lists (classic, fourth_grade, wordlist) +- Wordlist sidebar controls (picker + one-click sort) +- Audio volume controls (music and effects separate) + +### ✅ Challenge Mode +- **Shareable challenge links** via short URLs (`?game_id=`) +- **Multi-user leaderboards** sorted by score and time +- **Remote storage** via Hugging Face datasets +- **Word list difficulty calculation** and display +- **Submit results** to existing challenges or create new ones +- **Top 5 leaderboard** display in Challenge Mode banner +- **"Show Challenge Share Links" toggle** (default OFF) to control URL visibility +- Each player gets different random words from the same wordlist + +### Deployment & Technical +- **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms +- **Environment variables** for Challenge Mode (HF_API_TOKEN, HF_REPO_ID, SPACE_NAME) +- Works offline without HF credentials (Challenge Mode features disabled gracefully) + +### Progressive Web App (PWA) +- Installable on desktop and mobile from your browser +- Includes `service worker` and `manifest.json` with basic offline caching of static assets +- See `INSTALL_GUIDE.md` for platform-specific steps + +### Planned +- Local persistent storage for personal game history +- Personal high scores sidebar (offline-capable) +- Player statistics tracking +- Deterministic seed UI for custom puzzles + +## Challenge Mode & Leaderboard + +When playing a shared challenge (via a `game_id` link), the leaderboard displays all submitted results for that challenge. The leaderboard is **sorted by highest score (descending), then by fastest time (ascending)**. This means players with the most points appear at the top, and ties are broken by the shortest completion time. + +## Installation +1. Clone the repository: + ``` + git clone https://github.com/Oncorporation/Wrdler.git + cd wrdler + ``` +2. (Optional) Create and activate a virtual environment: + ``` + python -m venv venv + source venv/bin/activate # On Windows use `venv\Scripts\activate` + ``` +3. Install dependencies: ( add --system if not using a virutal environment) + ``` + uv pip install -r requirements.txt --link-mode=copy + ``` + + +## Running Wrdler + +You can run the app locally using either [uv](https://github.com/astral-sh/uv) or Streamlit directly: + +``` +uv run streamlit run app.py +``` + +or +``` +streamlit run app.py +``` + +### Dockerfile Deployment (Hugging Face Spaces and more) + +Wrdler supports containerized deployment using a `Dockerfile`. This is the recommended method for deploying to [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces-sdks-docker) or any Docker-compatible environment. + +To deploy on Hugging Face Spaces: +1. Add a `Dockerfile` to your repository root (see [Spaces Dockerfile guide](https://huggingface.co/docs/hub/spaces-sdks-docker)). +2. Push your code to your Hugging Face Space. +3. The platform will build and run your app automatically. + +For local Docker runs: +```sh +docker build -t wrdler . +docker run -p8501:8501 wrdler +``` + +### Environment Variables (for Challenge Mode) + +Challenge Mode requires a `.env` file in the project root with HuggingFace Hub credentials: + +```bash +# Required for Challenge Mode +HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN +HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo +SPACE_NAME=YourUsername/Wrdler # Your HF Space name + +# Optional +CRYPTO_PK= # Reserved for future signing +``` + +**How to get your HF_API_TOKEN:** +1. Go to https://huggingface.co/settings/tokens +2. Create a new token with `write` access +3. Add to `.env` file as `HF_API_TOKEN=hf_...` + +**Note:** The app works without these variables, but Challenge Mode features (sharing, leaderboards) will be disabled. + +## Folder Structure + +- `app.py` – Streamlit entry point +- `wrdler/` – Python package + - `models.py` – data models and types + - `word_loader.py` – word list loading and validation + - `generator.py` – word placement logic (8x6, horizontal only) + - `logic.py` – game mechanics (reveal, guess, scoring, free letters) + - `ui.py` – Streamlit UI composition + - `game_storage.py` – Hugging Face remote storage integration and challenge sharing + - `local_storage.py` – local JSON storage for results and high scores + - `storage.py` – (legacy) local storage and high scores + - `words/wordlist.txt` – candidate words +- `specs/` – documentation (`specs.md`, `requirements.md`) +- `tests/` – unit tests + +## How to Play + +1. **Start with 2 free letter guesses** - choose two letters to reveal all their instances in the grid. +2. Click grid squares to reveal letters or empty spaces. +3. After revealing a letter, enter a guess for a word in the text box. +4. Earn points for correct guesses and bonus points for unrevealed letters. +5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.** +6. **To play a shared challenge, use a link with `?game_id=`. Your result will be added to the challenge leaderboard.** + +## Changelog + +### v0.0.1 (Initial Wrdler Release) + - Project renamed from BattleWords to Wrdler + - Grid resized from 12x12 to 8x6 + - Changed to one word per row (6 total), horizontal only + - Removed vertical word placement + - Removed scope/radar visualization + - Added 2 free letter guesses at game start + - Version reset to 0.0.1 + +### v0.3.0 (planned - post-launch) + - Local persistent storage for personal game history (offline-capable) + - Personal high scores sidebar with filtering + - Player statistics tracking (games played, averages, bests) + +### Previous BattleWords Versions (v0.2.x - before Wrdler fork) + +-0.2.29 + - change difficulty calculation + - add test_compare_difficulty_functions + - streamlit version update to 1.51.0 + +-0.2.28 + - PWA INSTALL_GUIDE.md added + - PWA implementation with service worker and manifest.json added + +-0.2.27 + - Add "Show Challenge Share Links" setting (default: off) + - When disabled: + - Header Challenge Mode: hides the Share Challenge link + - Game Over: allows submitting results but suppresses displaying the generated share URL + - The setting is saved in session state and preserved across "New Game" + - No changes to game logic or storage; only UI visibility behavior + +-0.2.26 + - fix copy/share link button + +-0.2.25 + - Share challenge from expander + - fix incorrect guess overlap of guess box + +-0.2.24 + - compress height + - change incorrect guess tooltip location + - update final screen layout + - add word difficulty formula + - update documentation + +-0.2.23 + - Update miss and correct guess sound effects to new versions + - allow iframe hosted version to pass url as a query string parameter (&iframe_host=https%3A%2F%2Fwww.battlewords.com%2Fplaynow.html) url encoding is required. + - minimal security added to prevent users from changing the options in a challenge. + +-0.2.22 + - fix challenge mode link + - challenge mode UI improvements + +-0.2.21 + - fix tests + +-0.2.20 + - Remote Storage game_id: + - Per-game JSON settings uploaded to a storage server (Hugging Face repo) under unique `games/{uid}/settings.json` + - A shortened URL id (sid) is generated; shareable link: `?game_id=` + - On load with `game_id`, the app resolves sid to the JSON and applies word_list, game_mode, grid_size, puzzle options + - High Scores: add remote `highscores/highscores.json` (repo) alongside local highscores + - Dependencies: add `huggingface_hub` and `python-dotenv` + - Env: `.env` should include `HF_API_TOKEN` (or `HF_TOKEN`), `CRYPTO_PK`, `HF_REPO_ID`, `SPACE_NAME` + +### Environment Variables +- HF_API_TOKEN or HF_TOKEN: HF Hub access token +- CRYPTO_PK: reserved for signing (optional) +- HF_REPO_ID: e.g., Surn/Storage +- SPACE_NAME: e.g., Surn/BattleWords + +### Remote Storage Structure +- shortener.json +- games/{uid}/settings.json +- highscores/highscores.json + +Note +- `battlewords/storage.py` remains local-only storage; a separate HF integration wrapper is provided as `game_storage.py` for remote challenge mode. + +-0.2.19 + - Fix music and sound effect volume issues + - Update documentation for proposed new features + +-0.2.18 + - Fix sound effect volume wiring and apply volume to all effects (hit/miss/correct/incorrect) + - Respect "Enable music" and "Volume" when playing congratulations music and when resuming background music (uses selected track) + - Add "Enable Sound Effects" checkbox (on by default) and honor it across the app + - Save generated effects to `assets/audio/effects/` so they are picked up by the app + - Add `requests` dependency for sound effect generation + +-0.2.17 + - documentation updates and corrections + - updated CLAUDE.md with accurate feature status and project structure + - clarified v0.3.0 planned features vs current implementation + +-0.2.16 + - replace question marks in score panel with underscores + - add option to toggle incorrect guess history display in settings (enabled by default) + - game over popup updated to ensure it is fully visible on screen + +-0.2.15 + - fix music playing after game end + - change incorrect guesses icon + - fix sound effect and music volume issues + +-0.2.14 + - bug fix on final score popup + - score panel alignment centered + - change incorrect guess history UI + +-0.2.13 + - upgrade background ocean view + - apply volume control to sound effects + +-0.2.12 + - fix music looping on congratulations screen + +-0.2.11 + - update timer to be live during gameplay, but reset with each action + - compact design + - remove fullscreen image tooltip + +-0.2.10 + - reduce sonar graphic size + - update music and special effects file locations + - remove some music and sound effects + - change Guess Text input color + - incorrect guess UI update + - scoreboard update + +-0.2.9 + - fix sonar grid alignment issue on some browsers + - When all letters of a word are revealed, it is automatically marked as found. + +-0.2.8 + - Add10 incorrect guess limit per game + +-0.2.7 + - fix background music playback issue on some browsers + - add sound effects + - enhance sonar grid visualization + - add claude.md documentation + +-0.2.6 + - fix sonar grid alignment + - improve score summary layout and styling + - Add timer to game display in sidebar + +-0.2.5 + - fix finale pop up issue + - make grid cells square on wider devices + +-0.2.4 + - Add music files to repo + - disable music by default + +-0.2.3 + - Update version information display + - adjust sonar grid alignment + - fix settings scroll issue + +-0.2.2 + - Add Musical background and settings to toggle sound on/off. + +-0.2.1 + - Add Theme toggle (light/dark/custom) in sidebar. + +-0.2.0 + - Added a loading screen when starting a new game. + - Added a congratulations screen with your final score and tier when the game ends. + +-0.1.13 + - Improved score summary layout for clarity and style. + +-0.1.12 + - Improved score summary layout and styling. + - Enhanced overall appearance and readability. + +-0.1.11 + - Game now ends when all words are found or revealed. + - Added word spacing logic and improved settings. + +-0.1.10 + - Added game mode selector and improved UI feedback. + +-0.1.9 + - Improved background and mobile layout. + +-0.1.8 + - Updated to Python3.12. + +-0.1.5 + - Added hit/miss indicator and improved grid feedback. + +-0.1.4 + - Radar visualization improved and mobile layout enhanced. + +-0.1.3 + - Added wordlist picker and sort feature. + - Improved score panel and final score display. + +## Known Issues / TODO + +- Word list loading bug: the app may not select the proper word lists in some environments. Investigate `word_loader.get_wordlist_files()` / `load_word_list()` and sidebar selection persistence to ensure the chosen file is correctly used by the generator. + +## Development Phases + +- **Proof of Concept (0.1.0):** No overlaps, basic UI, single session. +- **Beta (0.5.0):** Overlaps allowed on shared letters, responsive layout, keyboard support, deterministic seed. +- **Full (1.0.0):** Enhanced UX, persistence, leaderboards, daily/practice modes, advanced features. + +See `specs/requirements.md` and `specs/specs.md` for full details and roadmap. + +## License + +Wrdler is based on BattleWords. BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner. + +## Hugging Face Spaces Configuration + +Wrdler is deployable as a Hugging Face Space. You can use either the YAML config block or a Dockerfile for advanced/custom deployments. + +To configure your Space with the YAML block, add it at the top of your `README.md`: + +```yaml +--- +title: Wrdler +emoji: 🎲 +colorFrom: blue +colorTo: indigo +sdk: streamlit +sdk_version: 1.51.0 +python_version: 3.12.8 +app_file: app.py +tags: + - game + - vocabulary + - streamlit + - education +--- +``` + +**Key parameters:** +- `title`, `emoji`, `colorFrom`, `colorTo`: Visuals for your Space. +- `sdk`: Use `streamlit` for Streamlit apps. +- `sdk_version`: Latest supported Streamlit version. +- `python_version`: Python version (default is3.10). +- `app_file`: Entry point for your app. +- `tags`: List of descriptive tags. + +**Dependencies:** +Add a `requirements.txt` with your Python dependencies (e.g., `streamlit`, etc.). + +**Port:** +Streamlit Spaces use port `8501` by default. + +**Embedding:** +Spaces can be embedded in other sites using an ` +``` + +For full configuration options, see [Spaces Config Reference](https://huggingface.co/docs/hub/spaces-config-reference) and [Streamlit SDK Guide](https://huggingface.co/docs/hub/spaces-sdks-streamlit). + +# Assets Setup + +To fully experience Wrdler, especially the audio elements, ensure you set up the following assets: + +- Place your background music `.mp3` files in `wrdler/assets/audio/music/` to enable music. +- Place your sound effect files (`.mp3` or `.wav`) in `wrdler/assets/audio/effects/` for sound effects. + +Refer to the documentation for guidance on compatible audio formats and common troubleshooting tips. + +# Sound Asset Generation + +To generate and save custom sound effects for Wrdler, you can use the `generate_sound_effect` function. + +## Function: `generate_sound_effect` + +```python +def generate_sound_effect(effect: str, save_to_assets: bool = False, use_api: str = "huggingface") -> str: + """ + Generate a sound effect and save it as a file. + + Parameters: + - `effect`: Name of the effect to generate. + - `save_to_assets`: If `True`, saves the effect to the assets directory; + if `False`, saves to a temporary location. Default is `False`. + - `use_api`: API to use for generation. Options are "huggingface" or "replicate". Default is "huggingface". + + Returns: + - File path to the saved sound effect. + ``` + +## Parameters + +- `effect`: The name of the sound effect you want to generate (e.g., "explosion", "powerup"). +- `save_to_assets` (optional): Set to `True` to save the generated sound effect to the game's assets directory. If `False`, the effect is saved to a temporary location. Default is `False`. +- `use_api` (optional): The API to use for generating the sound. Options are `"huggingface"` or `"replicate"`. Default is `"huggingface"`. + +## Returns + +- The function returns the file path to the saved sound effect, whether it's in the assets directory or a temporary location. + +## Usage Example + +To generate a sound effect and save it to the assets directory: + +```python +generate_sound_effect("your_effect_name", save_to_assets=True) +``` + +To generate a sound effect and keep it in a temporary location: + +```python +temp_path = generate_sound_effect("your_effect_name", save_to_assets=False) +``` + +## Note + +Ensure you have the necessary permissions and API access (if required) to use the sound generation service. Generated sounds are subject to the terms of use of the respective API. + +For any issues or enhancements, please refer to the project documentation or contact the project maintainer. + +Happy gaming and sound designing! + +## What's New in v0.2.20-0.2.27: Challenge Mode 🎯 + +### Remote Challenge Sharing 🔗 +- Share challenges with friends via short URLs (`?game_id=`) +- Each player gets different random words from the same wordlist +- Multi-user leaderboards sorted by score and time +- Word list difficulty calculation and display +- Compare your performance against others! + +### Leaderboards 🏆 +- Top 5 players displayed in Challenge Mode banner +- Results sorted by: highest score → fastest time → highest difficulty +- Submit results to existing challenges or create new ones +- Player names supported (optional, defaults to "Anonymous") + +### Remote Storage 💾 +- Challenge data stored in Hugging Face dataset repositories +- Automatic save on game completion (with user consent) +- "Show Challenge Share Links" toggle for privacy control (default OFF) +- Works offline when HF credentials not configured + +## What's Planned for v0.3.0 + +### Local Player History (Coming Soon) +- Personal game results saved locally in `~/.wrdler/data/` +- Offline-capable high score tracking +- Player statistics (games played, averages, bests) +- Privacy-first: no cloud dependency for personal data +- Easy data management (delete `~/.wrdler/data/` to reset) + diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..0dbb6bc5b438e06470a4eb114dc3b92ff0b1c39c --- /dev/null +++ b/app.py @@ -0,0 +1,22 @@ +import streamlit as st + +from wrdler.ui import run_app, _init_session + + +def _new_game() -> None: + st.session_state.clear() + _init_session() + st.rerun() + + +def main(opened=False): + st.set_page_config( + page_title="Wrdler", + layout="wide", + initial_sidebar_state="expanded" if opened else "collapsed" + ) + run_app() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/generate_pwa_icons.py b/generate_pwa_icons.py new file mode 100644 index 0000000000000000000000000000000000000000..9205ef6ef52923b43d56f331d1c4831d92220d7f --- /dev/null +++ b/generate_pwa_icons.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Generate PWA icons for BattleWords. +Creates 192x192 and 512x512 icons with ocean theme and 'BW' text. +""" + +from PIL import Image, ImageDraw, ImageFont +import os + +def create_icon(size, filename): + """Create a square icon with ocean gradient background and 'BW' text.""" + + # Create image with ocean blue gradient + img = Image.new('RGB', (size, size)) + draw = ImageDraw.Draw(img) + + # Draw vertical gradient (ocean theme) + water_sky = (29, 100, 200) # #1d64c8 + water_deep = (11, 42, 74) # #0b2a4a + + for y in range(size): + # Interpolate between sky and deep + ratio = y / size + r = int(water_sky[0] * (1 - ratio) + water_deep[0] * ratio) + g = int(water_sky[1] * (1 - ratio) + water_deep[1] * ratio) + b = int(water_sky[2] * (1 - ratio) + water_deep[2] * ratio) + draw.rectangle([(0, y), (size, y + 1)], fill=(r, g, b)) + + # Draw circular background for better icon appearance + circle_margin = size // 10 + circle_bbox = [circle_margin, circle_margin, size - circle_margin, size - circle_margin] + + # Draw white circle with transparency + overlay = Image.new('RGBA', (size, size), (255, 255, 255, 0)) + overlay_draw = ImageDraw.Draw(overlay) + overlay_draw.ellipse(circle_bbox, fill=(255, 255, 255, 40)) + + # Composite the overlay + img = img.convert('RGBA') + img = Image.alpha_composite(img, overlay) + + # Draw 'BW' text + font_size = size // 3 + try: + # Try to load a nice bold font + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size) + except Exception: + try: + # Fallback for Windows + font = ImageFont.truetype("C:/Windows/Fonts/arialbd.ttf", font_size) + except Exception: + # Ultimate fallback + font = ImageFont.load_default() + + draw = ImageDraw.Draw(img) + text = "BW" + + # Get text bounding box for centering + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Center the text + x = (size - text_width) // 2 + y = (size - text_height) // 2 - (bbox[1] // 2) + + # Draw text with shadow for depth + shadow_offset = size // 50 + draw.text((x + shadow_offset, y + shadow_offset), text, fill=(0, 0, 0, 100), font=font) + draw.text((x, y), text, fill='white', font=font) + + # Convert back to RGB for saving as PNG + if img.mode == 'RGBA': + background = Image.new('RGB', img.size, (11, 42, 74)) + background.paste(img, mask=img.split()[3]) # Use alpha channel as mask + img = background + + # Save + img.save(filename, 'PNG', optimize=True) + print(f"[OK] Created {filename} ({size}x{size})") + +def main(): + """Generate both icon sizes.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + static_dir = os.path.join(script_dir, 'battlewords', 'static') + + # Ensure directory exists + os.makedirs(static_dir, exist_ok=True) + + # Generate icons + print("Generating PWA icons for BattleWords...") + create_icon(192, os.path.join(static_dir, 'icon-192.png')) + create_icon(512, os.path.join(static_dir, 'icon-512.png')) + print("\n[SUCCESS] PWA icons generated successfully!") + print(f" Location: {static_dir}") + +if __name__ == '__main__': + main() diff --git a/inject-pwa-head.sh b/inject-pwa-head.sh new file mode 100644 index 0000000000000000000000000000000000000000..3abef397a078892fd793229f6a550d2955cd0ae2 --- /dev/null +++ b/inject-pwa-head.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Inject PWA meta tags into Streamlit's index.html head section +# This script modifies the Streamlit index.html during Docker build + +set -e + +echo "[PWA] Injecting PWA meta tags into Streamlit's index.html..." + +# Find Streamlit's index.html +STREAMLIT_INDEX=$(python3 -c "import streamlit; import os; print(os.path.join(os.path.dirname(streamlit.__file__), 'static', 'index.html'))") + +if [ ! -f "$STREAMLIT_INDEX" ]; then + echo "[PWA] ERROR: Streamlit index.html not found at: $STREAMLIT_INDEX" + exit 1 +fi + +echo "[PWA] Found Streamlit index.html at: $STREAMLIT_INDEX" + +# Check if already injected (to make script idempotent) +if grep -q "PWA (Progressive Web App) Meta Tags" "$STREAMLIT_INDEX"; then + echo "[PWA] PWA tags already injected, skipping..." + exit 0 +fi + +# Read the injection content +INJECT_FILE="/app/pwa-head-inject.html" +if [ ! -f "$INJECT_FILE" ]; then + echo "[PWA] ERROR: Injection file not found at: $INJECT_FILE" + exit 1 +fi + +# Create backup +cp "$STREAMLIT_INDEX" "${STREAMLIT_INDEX}.backup" + +# Use awk to inject after tag +awk -v inject_file="$INJECT_FILE" ' +// { + print + while ((getline line < inject_file) > 0) { + print line + } + close(inject_file) + next +} +{ print } +' "${STREAMLIT_INDEX}.backup" > "$STREAMLIT_INDEX" + +echo "[PWA] PWA meta tags successfully injected!" +echo "[PWA] Backup saved as: ${STREAMLIT_INDEX}.backup" diff --git a/pwa-head-inject.html b/pwa-head-inject.html new file mode 100644 index 0000000000000000000000000000000000000000..871a24dad200903f101ac8754d2e157ef702d2b5 --- /dev/null +++ b/pwa-head-inject.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..e1e79aca42966fa9d72e4619b871046909af1b61 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "wrdler" +version = "0.0.1" +description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "streamlit>=1.51.0", + "matplotlib>=3.8", + "requests>=2.31.0", +] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = [""] +include = ["wrdler*"] + +[tool.setuptools.package-data] +"wrdler.words" = ["*.txt"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..192fe7ed14990f1e34ca7936122efa1b441bef9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +altair +pandas +typing +pathlib +streamlit +matplotlib +numpy +Pillow +pytest +flake8 +mypy +requests +huggingface_hub +python-dotenv +google-api-core \ No newline at end of file diff --git a/specs/requirements.md b/specs/requirements.md new file mode 100644 index 0000000000000000000000000000000000000000..985c0416a97fdcb4cbec3300e8a068752d4bc41e --- /dev/null +++ b/specs/requirements.md @@ -0,0 +1,164 @@ +# Wrdler: Implementation Requirements + +This document breaks down the tasks to build Wrdler using the game rules described in `specs.md`. Wrdler is based on BattleWords but with a simplified 8x6 grid, horizontal-only words, and free letter guesses at the start. + +## Key Differences from BattleWords +- 8x6 grid instead of 12x12 +- One word per row (6 total) instead of flexible placement +- Horizontal words only (no vertical) +- No radar/scope visualization +- 2 free letter guesses at game start + +## Assumptions +- Tech stack: Python 3.10+, Streamlit for UI, numpy, Pillow for animations +- Single-player, local state stored in Streamlit session state +- Grid is always 8x6 with exactly six words (one per row) +- All words placed horizontally only +- No word overlaps +- Entry point is `app.py` + +## Streamlit Components (API Usage Plan) +- State & caching + - `st.session_state` for `puzzle`, `grid_size`, `revealed`, `guessed`, `score`, `last_action`, `can_guess` + - `st.session_state.points_by_word` for per-word score breakdown + - `st.session_state.letter_map` derived from puzzle + - `st.session_state.selected_wordlist` for sidebar picker + - `st.session_state.show_incorrect_guesses` toggle + - `st.session_state.show_challenge_share_links` toggle (v0.0.1, default OFF) + +- Layout & structure + - `st.title`, `st.subheader`, `st.markdown` for headers + - `st.columns(8)` to render the 8×6 grid + - `st.sidebar` for secondary controls + - `st.expander` for help/stats + +- Widgets (interaction) + - `st.button` for each grid cell (48 total) with unique `key` + - Free letter choice buttons (2) at game start + - `st.form` + `st.text_input` + `st.form_submit_button("OK")` for word guessing + - `st.button("New Game")` to reset state + - Sidebar `selectbox` for wordlist selection + +- Visualization + - Ocean-themed gradient background + - No animated radar (unlike BattleWords) + - Responsive grid layout + +- Control flow + - App reruns on interaction using `st.rerun()` + - `st.stop()` after game over to freeze UI + +## Folder Structure +- `app.py` – Streamlit entry point +- `wrdler/` – Python package + - `__init__.py` (version 0.0.1) + - `models.py` – data models and types + - `word_loader.py` – load/validate/cached word lists + - `generator.py` – word placement (8x6, horizontal only) + - `logic.py` – game mechanics (reveal, guess, scoring, tiers, free letters) + - `ui.py` – Streamlit UI composition + - `words/wordlist.txt` – candidate words +- `specs/` – documentation (this file and `specs.md`) +- `tests/` – unit tests + +## Phase 1: Wrdler v0.0.1 (Initial Release) + +Goal: A playable 8x6 grid game with free letter guesses, horizontal-only words, and Challenge Mode support. + +### 1) Data Models +- Define `Coord(x:int, y:int)` +- Define `Word(text:str, start:Coord, direction:str{"H"}, cells:list[Coord])` (H only) +- Define `Puzzle(words:list[Word], uid:str)` (no radar, no spacing config) +- Define `GameState(grid_size:int=48, puzzle:Puzzle, revealed:set[Coord], guessed:set[str], score:int, free_letters_used:int=0, ...)` + +Acceptance: Types exist and are consumed by generator/logic. + +### 2) Word List +- English word list filtered to alphabetic uppercase, lengths in {4,5,6} +- Loader centralized in `word_loader.py` + +Acceptance: Loading function returns lists by length with >= 25 words per length. + +### 3) Puzzle Generation (8x6 Horizontal) +- Randomly place 6 words (mix of 4, 5, 6-letter) on 8x6 grid, one per row +- Constraints: + - Horizontal (left→right) only + - One word per row (no stacking) + - No overlapping letters +- Retry strategy with max attempts + +Acceptance: Generator returns valid `Puzzle` with 6 words, no collisions, in-bounds. + +### 4) Free Letter Guesses +- At game start, show 2 buttons for letter selection +- On selection, reveal all instances of that letter in the grid +- Mark as used; disable buttons after 2 uses +- Set `can_guess=True` after free letters chosen + +Acceptance: Both free letters properly reveal all matching cells; buttons disabled appropriately. + +### 5) Game Mechanics +- Reveal: Click a covered cell to reveal letter or mark empty +- Guess: After revealing, guess word (4-6 letters) or use free letters +- Scoring: Base + bonus for unrevealed cells +- End: All words guessed or all word letters revealed +- Incorrect guess limit: 10 per game + +Acceptance: Unit tests cover reveal, guess gating, scoring, tiers. + +### 6) UI (Streamlit) +- Layout: + - Title and instructions + - Left: 8×6 grid using `st.columns(8)` + - Right: Score panel, guess form, incorrect guess history + - Sidebar: New Game, wordlist select, game mode, settings +- Visuals: + - Ocean gradient background + - Covered vs revealed cell styles + - Completed word highlighting + +Acceptance: Users can play end-to-end; all features functional. + +### 7) Challenge Mode (v0.0.1) +- Parse `game_id` from query params +- Load game settings from HF repo +- Share button generates shareable URL +- Display top 5 leaderboard in Challenge Mode banner +- "Show Challenge Share Links" toggle + +Acceptance: +- URL with `game_id` loads correctly +- Share button works +- Leaderboard displays properly + +### 8) Basic Tests +- Placement validity (bounds, no overlaps, correct counts) +- Scoring logic and bonuses +- Free letter reveal behavior +- Guess gating +- Challenge Mode load/share + +## Known Issues / TODO +- Generator needs validation for 8x6 horizontal-only placement +- UI needs adjustment for new grid size (48 cells vs 144) +- Radar visualization should be removed entirely +- Free letter buttons UI needs design +- Game logic needs update for free letters + +## Future Roadmap + +### v0.1.0 +- Local persistent storage in `~/.wrdler/data/` +- High score tracking and display +- Player statistics + +### v1.0.0 +- Enhanced UX and animations +- Multiple difficulty levels +- Daily puzzle mode +- Internationalization + +## Deployment Targets +- Hugging Face Spaces (primary) +- Docker containerization +- Local development diff --git a/specs/specs.md b/specs/specs.md new file mode 100644 index 0000000000000000000000000000000000000000..2a344ad0cafe6699651902a9d5a00bded7f762f4 --- /dev/null +++ b/specs/specs.md @@ -0,0 +1,195 @@ +# Wrdler Game Requirements (specs.md) + +## Overview +Wrdler is a simplified vocabulary puzzle game based on BattleWords, but with key differences. The objective is to discover hidden words on a grid by making strategic guesses and using free letter reveals at the game start. + +## Key Differences from BattleWords +- **8x6 grid** (instead of 12x12) +- **One word per row** (instead of 6 words placed anywhere) +- **Horizontal words only** (no vertical placement) +- **No scope/radar visualization** +- **2 free letter guesses at game start** (all instances of chosen letters are revealed) + +## Game Board +- 8 x 6 grid +- Six hidden words: + - One word per row (row 0-5) + - All placed horizontally (left-right) + - No vertical placement + - No diagonal placement + - Words do not overlap +- Entry point is `app.py` +- **Supports Dockerfile-based deployment for Hugging Face Spaces and other container platforms** + +## Gameplay (Core) +- Players start by choosing 2 letters; all instances of those letters are revealed in the grid +- Players click grid squares to reveal letters or empty spaces +- Empty revealed squares are styled with CSS class `empty` +- After any reveal, the app immediately reruns (`st.rerun`) to show the change +- After revealing a letter, players may guess a word by entering it in a text box +- Guess submission triggers an immediate rerun to reflect results +- Only one guess per letter reveal; must uncover another letter before guessing again +- In the default mode, a correct guess allows chaining an additional guess without another reveal +- **The game ends when all six words are guessed or all word letters are revealed** + +## Scoring +- Each correct word guess awards points: + - 1 point per letter in the word + - Bonus points for each hidden letter at the time of guessing +- Score tiers: + - Good: 34-37 + - Great: 38-41 + - Fantastic: 42+ +- **Game over is triggered by either all words being guessed or all word letters being revealed** + +## Core Rules (v0.0.1) +- 8x6 grid with one word per row +- Horizontal words only; no vertical placement +- No overlaps: words do not overlap or share letters +- No radar/scope visualization +- 2 free letter guesses at game start +- Incorrect guess history with optional display +- 10 incorrect guess limit per game +- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal) + +## New Features (Challenge Mode) +- **Game ID Sharing:** Each puzzle generates a shareable link with `?game_id=` to challenge others with the same word list +- **Remote Storage:** Game results and leaderboards stored in Hugging Face dataset repos +- **Leaderboards:** Multi-user leaderboards sorted by score (descending) then time (ascending) +- **Word List Difficulty:** Calculated and displayed for each challenge +- **Top 5 Display:** Leaderboard banner shows top 5 players +- **Optional Sharing:** "Show Challenge Share Links" toggle (default OFF) controls URL visibility + +## New Features (PWA Support) +- **PWA Installation:** App is installable as a Progressive Web App on desktop and mobile + - Added `service worker` and `manifest.json` + - Basic offline caching of static assets + - INSTALL_GUIDE.md added with platform-specific install steps + - No gameplay logic changes + +## Storage +- Game results and high scores are stored in JSON files for privacy and offline access (planned for v0.3.0) +- Game ID is generated from the word list for replay/sharing +- Local storage location: `~/.wrdler/data/` (planned for v0.3.0) +- Challenge Mode uses remote storage via Hugging Face datasets (implemented in v0.0.1) + +## UI Elements +- 8x6 grid (48 cells total) +- Free letter guess buttons (2 at game start) +- Text box for word guesses +- Score display (shows word, base points, bonus points, total score) +- Guess status indicator (Correct/Try Again) +- Incorrect guess history display (toggleable) +- Game ID display and share button in game over dialog +- Challenge Mode banner with leaderboard (top 5) +- High score expander in sidebar +- Player name input in sidebar +- Checkbox: "Show Challenge Share Links" (default OFF) + - When OFF: + - Challenge Mode header hides the Share Challenge link + - Game Over dialog still supports submitting/creating challenges, but does not display the generated share URL + - Persisted in session state and preserved across "New Game" + +## Word List +- External list at `wrdler/words/wordlist.txt` +- Loaded by `wrdler.word_loader.load_word_list()` with caching +- Filtered to uppercase A-Z, lengths in {4,5,6}; falls back if < 25 per length + +## Generator +- Centralized word loader +- No duplicate word texts are selected +- Horizontal-only word placement +- One word per row in 8x6 grid +- No word spacing configuration (fixed one word per row) + +## Entry Point +- The Streamlit entry point is `app.py` +- **A `Dockerfile` can be used for containerized deployment (recommended for Hugging Face Spaces)** + +## Deployment Requirements + +### Basic Deployment (Offline Mode) +No special configuration needed. The app will run with all core gameplay features. +Optional: Install as PWA from the browser menu (Add to Home Screen/Install app). + +### Challenge Mode Deployment (Remote Storage) +Requires HuggingFace Hub integration for challenge sharing and leaderboards. + +**Required Environment Variables:** +```bash +HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN (write access required) +HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repository +SPACE_NAME=YourUsername/Wrdler # Your HF Space name for URL generation +``` + +**Optional Environment Variables:** +```bash +CRYPTO_PK= # Reserved for future challenge signing +``` + +**Setup Steps:** +1. Create a HuggingFace account at https://huggingface.co +2. Create a dataset repository (e.g., `YourUsername/WrdlerStorage`) +3. Generate an access token with `write` permissions: + - Go to https://huggingface.co/settings/tokens + - Click "New token" + - Select "Write" access + - Copy the token (starts with `hf_`) +4. Create a `.env` file in project root with the variables above +5. For Hugging Face Spaces deployment, add these as Space secrets + +**Repository Structure (automatically created):** +``` +HF_REPO_ID/ +├── shortener.json # Short URL mappings (sid -> full URL) +└── games/ + └── {uid}/ + └── settings.json # Challenge data with users array +``` + +**Data Privacy:** +- Challenge Mode stores: word lists, scores, times, game modes, player names +- No PII beyond optional player name (defaults to "Anonymous") +- Players control URL visibility via "Show Challenge Share Links" setting +- App functions fully offline when HF credentials not configured + +**Deployment Platforms:** +- Local development: Run with `streamlit run app.py` +- Docker: Use provided `Dockerfile` +- Hugging Face Spaces: Dockerfile deployment (recommended) +- Any Python 3.10+ hosting with Streamlit support + +## Copyright +Wrdler is based on BattleWords. BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner. + +## v0.2.20: Remote Storage and Shortened game_id URL + +Game Sharing +- Each puzzle can be shared via a link containing a `game_id` querystring (short id / sid) +- `game_id` resolves to a settings JSON on the storage server (HF repo) +- JSON fields: + - word_list (list of 6 uppercase words) + - score (int), time (int seconds) [metadata only] + - game_mode (e.g., classic, too easy) + - grid_size (e.g., 12) + - puzzle_options (e.g., { spacer, may_overlap }) +- On load with `game_id`, fetch and apply: word_list, game_mode, grid_size, puzzle_options + +High Scores +- Repository maintains `highscores/highscores.json` for top scores +- Local highscores remain supported for offline use + +UI/UX +- Show the current `game_id` (sid) and a �Share Challenge� link +- When loading with a `game_id`, indicate the puzzle is a shared challenge + +Security/Privacy +- Only game configuration and scores are stored; no personal data is required +- `game_id` is a short reference; full URL is stored in a repo JSON shortener index + +## Challenge Mode & Leaderboard + +- When loading a shared challenge via `game_id`, the leaderboard displays all user results for that challenge. +- **Sorting:** The leaderboard is sorted by highest score (descending), then by fastest time (ascending). +- **Difficulty:** Each result now displays a computed word list difficulty value. +- Results are stored remotely in a Hugging Face dataset repo and updated via the app. diff --git a/specs/wrdler_implementation_plan.md b/specs/wrdler_implementation_plan.md new file mode 100644 index 0000000000000000000000000000000000000000..d2c70fc4b47a137fdf7be32995e064f23a631404 --- /dev/null +++ b/specs/wrdler_implementation_plan.md @@ -0,0 +1,525 @@ +# Wrdler Implementation Plan +**Version:** 0.0.1 +**Status:** Planning Phase +**Last Updated:** 2025-10-31 + +## Overview +This document outlines the step-by-step implementation plan for converting BattleWords to Wrdler, focusing on the core gameplay differences: +- 8×6 rectangular grid (8 columns, 6 rows) +- Horizontal words only (one per row) +- No radar/scope visualization +- 2 free letter guesses at game start + +## Current State Analysis + +### What Works (Inheritance from BattleWords) +- ✅ Word loading and validation +- ✅ Challenge mode and remote storage +- ✅ Audio system (music and sound effects) +- ✅ PWA support +- ✅ Scoring system (can be reused) +- ✅ Incorrect guess tracking +- ✅ Timer functionality + +### What Needs Changes +- ❌ Square grid assumption (12×12) +- ❌ Vertical word placement +- ❌ Radar/scope visualization throughout UI +- ❌ Game initialization (needs free letter selection) +- ❌ Word count (currently 6 words of mixed lengths) + +--- + +## Phase 1: Data Model Updates + +### 1.1 Coordinate System (models.py) +**Current:** Square grid with single `grid_size` parameter +**Target:** Rectangular grid with separate width and height + +**Files to Modify:** +- `wrdler/models.py` + +**Changes:** +```python +# Current +@dataclass(frozen=True, order=True) +class Coord: + x: int # row, 0-based + y: int # col, 0-based + + def in_bounds(self, size: int) -> bool: + return 0 <= self.x < size and 0 <= self.y < size + +# Proposed +@dataclass(frozen=True, order=True) +class Coord: + x: int # row, 0-based + y: int # col, 0-based + + def in_bounds(self, size: int) -> bool: + """Legacy square grid check (deprecated)""" + return 0 <= self.x < size and 0 <= self.y < size + + def in_bounds_rect(self, rows: int, cols: int) -> bool: + """Rectangular grid boundary check""" + return 0 <= self.x < rows and 0 <= self.y < cols +``` + +**Testing:** +- Unit tests for `in_bounds_rect()` with 6×8 grid +- Verify backward compatibility with square grids + +### 1.2 Game State Model (models.py) +**Current:** Single `grid_size` field +**Target:** Separate `grid_rows` and `grid_cols` fields + +**Changes:** +```python +# Current +@dataclass +class GameState: + grid_size: int + # ... other fields + +# Proposed +@dataclass +class GameState: + grid_rows: int = 6 + grid_cols: int = 8 + # Add backward compatibility property + @property + def grid_size(self) -> int: + """Legacy property for square grids""" + if self.grid_rows == self.grid_cols: + return self.grid_rows + raise ValueError("grid_size not applicable for rectangular grids") + # ... other fields + free_letters: Set[str] = field(default_factory=set) # NEW: Track free letter guesses +``` + +**Migration Strategy:** +- Add default values for smooth transition +- Keep `grid_size` as computed property for backward compatibility +- Add `free_letters` field to track initial letter reveals + +### 1.3 Puzzle Model (models.py) +**Current:** Includes radar visualization data +**Target:** Remove radar, simplify to word list only + +**Changes:** +```python +# Current +@dataclass +class Puzzle: + words: List[Word] + radar: List[Coord] = field(default_factory=list) # TO BE REMOVED + may_overlap: bool = False + spacer: int = 1 + uid: str = field(default_factory=lambda: uuid.uuid4().hex) + +# Proposed +@dataclass +class Puzzle: + words: List[Word] + # radar field removed entirely + spacer: int = 1 # Still relevant for word spacing + uid: str = field(default_factory=lambda: uuid.uuid4().hex) + grid_rows: int = 6 # NEW: Track grid dimensions + grid_cols: int = 8 # NEW +``` + +--- + +## Phase 2: Puzzle Generator Updates + +### 2.1 Horizontal-Only Word Placement (generator.py) +**Current:** Places words horizontally or vertically +**Target:** Horizontal only, one word per row + +**Files to Modify:** +- `wrdler/generator.py` + +**Key Changes:** +1. Remove vertical placement logic +2. Implement row-based placement (each word on a different row) +3. Update word length requirements for 8-column grid + +**Algorithm:** +```python +def generate_puzzle( + grid_rows: int = 6, + grid_cols: int = 8, + words_by_len: Optional[Dict[int, List[str]]] = None, + seed: Optional[Union[int, str]] = None, + spacer: int = 1, + target_words: Optional[List[str]] = None, +) -> Puzzle: + """ + Generate 6 horizontal words (one per row) for 8-column grid. + + Word length constraints: + - Max length: 8 letters (full row) + - Min length: 3 letters (reasonable minimum) + - Distribution: Mix of lengths (e.g., 2×4, 2×5, 2×6 or 2×5, 2×6, 2×7) + """ + # 1. Select 6 words (target lengths TBD based on difficulty) + # 2. Shuffle row order for variety + # 3. For each row, randomly position word within bounds + # 4. Ensure words don't touch if spacer > 0 +``` + +**Word Selection Strategy:** +- **Easy:** Shorter words (4-5 letters) +- **Medium:** Mix of 4-6 letters +- **Hard:** Longer words (6-8 letters) + +**Placement Logic:** +```python +for row_idx, word_text in enumerate(selected_words): + # Word must fit in row with padding + max_start_col = grid_cols - len(word_text) + if max_start_col < 0: + raise ValueError(f"Word '{word_text}' too long for {grid_cols} columns") + + # Randomly position within valid range + start_col = rng.randint(0, max_start_col) + + # Create word with direction="H" + word = Word( + text=word_text, + start=Coord(row_idx, start_col), + direction="H" + ) +``` + +### 2.2 Validation Updates (generator.py) +**Current:** Validates for square grid and no overlaps +**Target:** Validate for rectangular grid, horizontal only + +**Changes:** +```python +def validate_puzzle(puzzle: Puzzle, grid_rows: int = 6, grid_cols: int = 8) -> None: + """Validate Wrdler puzzle constraints.""" + # 1. Exactly 6 words + assert len(puzzle.words) == 6, f"Expected 6 words, got {len(puzzle.words)}" + + # 2. All horizontal + for w in puzzle.words: + assert w.direction == "H", f"Word {w.text} is not horizontal" + + # 3. One word per row + rows_used = [w.start.x for w in puzzle.words] + assert len(set(rows_used)) == 6, "Must have one word per row" + + # 4. All cells in bounds + for w in puzzle.words: + for c in w.cells: + assert c.in_bounds_rect(grid_rows, grid_cols), \ + f"Word {w.text} cell {c} out of bounds" + + # 5. No overlaps (should be impossible with one word per row, but verify) + all_cells = set() + for w in puzzle.words: + for c in w.cells: + assert c not in all_cells, f"Cell {c} used twice" + all_cells.add(c) +``` + +--- + +## Phase 3: Remove Radar/Scope Visualization + +### 3.1 UI Code Cleanup (ui.py) +**Files to Modify:** +- `wrdler/ui.py` + +**Functions to Remove:** +- Radar rendering functions (matplotlib-based animations) +- Scope overlay generation +- Radar caching logic + +**CSS/JavaScript to Remove:** +- Radar container styling +- Pulsing animation keyframes +- Scope positioning logic + +**Search Terms for Cleanup:** +```bash +grep -n "radar" wrdler/ui.py +grep -n "scope" wrdler/ui.py +grep -n "pulse" wrdler/ui.py +``` + +### 3.2 Session State Cleanup +**Remove:** +- `st.session_state.radar_*` variables +- Radar cache keys +- Scope image references + +--- + +## Phase 4: Free Letter Guesses Feature + +### 4.1 Game Initialization Flow (ui.py) +**Current:** Game starts immediately with blank grid +**Target:** User selects 2 letters, then game begins with those letters revealed + +**New Flow:** +1. User arrives at game +2. Show "Choose 2 free letters" interface +3. User selects 2 letters (A-Z) +4. Reveal all instances of those letters in the grid +5. Game proceeds normally + +**UI Design:** +```python +def render_free_letter_selection(): + """Render letter selection interface at game start.""" + st.markdown("### Choose 2 Free Letters") + st.markdown("Select any 2 letters to reveal all instances in the puzzle.") + + # Letter grid (A-Z in rows of 7-8) + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + cols = st.columns(7) + + selected_letters = st.session_state.get('free_letters_selected', []) + + for i, letter in enumerate(alphabet): + col_idx = i % 7 + with cols[col_idx]: + if st.button( + letter, + key=f"free_letter_{letter}", + disabled=len(selected_letters) >= 2 and letter not in selected_letters, + type="primary" if letter in selected_letters else "secondary" + ): + if letter in selected_letters: + selected_letters.remove(letter) + else: + selected_letters.append(letter) + st.session_state.free_letters_selected = selected_letters + st.rerun() + + # Confirm button + if len(selected_letters) == 2: + if st.button("Start Game with These Letters", type="primary"): + _initialize_game_with_free_letters(selected_letters) + st.rerun() +``` + +### 4.2 Letter Reveal Logic (logic.py) +**New Function:** +```python +def reveal_free_letters( + state: GameState, + letters: List[str], + letter_map: Dict[Coord, str] +) -> GameState: + """ + Reveal all instances of the given letters in the puzzle. + + Args: + state: Current game state + letters: List of 2 letters to reveal + letter_map: Mapping of coordinates to letters + + Returns: + Updated game state with letters revealed + """ + new_revealed = set(state.revealed) + + for coord, letter in letter_map.items(): + if letter.upper() in [l.upper() for l in letters]: + new_revealed.add(coord) + + # Update state + state.revealed = new_revealed + state.free_letters = set(letters) + state.last_action = f"Revealed free letters: {', '.join(letters)}" + + return state +``` + +### 4.3 Session State Management +**New Fields:** +```python +# In _init_session() +st.session_state.free_letters_selected = [] # Letters chosen by user +st.session_state.free_letters_revealed = False # Whether free letters have been applied +st.session_state.game_phase = "select_letters" # "select_letters" | "playing" | "game_over" +``` + +--- + +## Phase 5: UI Grid Updates + +### 5.1 Grid Rendering (ui.py) +**Current:** Square grid (12×12) with equal width/height +**Target:** Rectangular grid (6 rows × 8 columns) + +**Changes:** +```python +# Current +def render_grid(state: GameState, letter_map): + size = state.grid_size + for row in range(size): + cols = st.columns(size) + for col in range(size): + # ... + +# Proposed +def render_grid(state: GameState, letter_map): + rows = state.grid_rows + cols = state.grid_cols + for row in range(rows): + col_widgets = st.columns(cols) + for col in range(cols): + # ... +``` + +### 5.2 CSS Grid Styling +**Update:** +- Grid container max-width/height ratios +- Cell sizing for 8:6 aspect ratio +- Responsive breakpoints + +--- + +## Phase 6: Testing Strategy + +### 6.1 Unit Tests +**New Tests Needed:** +- `test_rectangular_grid()` - Verify 8×6 grid creation +- `test_horizontal_only_placement()` - Ensure no vertical words +- `test_one_word_per_row()` - Validate row distribution +- `test_free_letter_reveal()` - Verify letter reveal logic +- `test_grid_bounds_rect()` - Test `in_bounds_rect()` + +### 6.2 Integration Tests +- Complete game flow with free letters +- Challenge mode with 8×6 grid +- Scoring with new grid size + +### 6.3 Manual Testing Checklist +- [ ] Game loads with letter selection screen +- [ ] Can select exactly 2 letters +- [ ] Selected letters are revealed in grid +- [ ] Grid displays as 8 columns × 6 rows +- [ ] All words are horizontal +- [ ] One word per row +- [ ] Game over conditions work correctly +- [ ] Scoring system functions properly +- [ ] Challenge mode creates/loads games correctly +- [ ] No radar/scope elements visible + +--- + +## Phase 7: Migration Path + +### 7.1 Backward Compatibility +**Decision:** Wrdler v0.0.1 is a breaking change from BattleWords +- Old challenge URLs will not work with new game +- Fresh start with new grid system +- Document migration in README + +### 7.2 Database/Storage +**Challenge Mode:** +- Update `serialize_game_settings()` to include `grid_rows` and `grid_cols` +- Update `load_game_from_sid()` to handle new format +- Add version check for format compatibility + +--- + +## Implementation Order (Recommended) + +### Sprint 1: Core Data Models (2-3 hours) +1. Update `Coord.in_bounds_rect()` +2. Update `GameState` with `grid_rows`, `grid_cols`, `free_letters` +3. Remove radar from `Puzzle` model +4. Update all tests + +### Sprint 2: Generator (3-4 hours) +1. Modify `generate_puzzle()` for horizontal-only placement +2. Implement one-word-per-row logic +3. Update `validate_puzzle()` +4. Test with various word lists + +### Sprint 3: Remove Radar (1-2 hours) +1. Delete radar rendering functions +2. Clean up CSS/JavaScript +3. Remove session state variables +4. Update UI layout + +### Sprint 4: Free Letters UI (2-3 hours) +1. Create letter selection interface +2. Implement reveal logic +3. Update game initialization flow +4. Test user experience + +### Sprint 5: Grid UI Updates (2-3 hours) +1. Update grid rendering for 8×6 +2. Adjust CSS styling +3. Test responsive layout +4. Update score panel + +### Sprint 6: Integration & Testing (2-3 hours) +1. End-to-end game flow testing +2. Challenge mode compatibility +3. Fix any bugs +4. Performance optimization + +### Sprint 7: Documentation (1 hour) +1. Update README with new gameplay +2. Create migration guide +3. Update screenshots/GIFs +4. Announce v0.0.1 release + +--- + +## Risk Assessment + +### High Risk +- **Grid rendering performance** - 8×6 may need optimization +- **Challenge mode compatibility** - Breaking changes to storage format + +### Medium Risk +- **Word list compatibility** - Need sufficient 3-8 letter words +- **User confusion** - Free letter selection might need tutorial + +### Low Risk +- **CSS layout** - Rectangular grid is simpler than square +- **Scoring system** - Logic remains mostly unchanged + +--- + +## Success Criteria + +### Must Have (v0.0.1) +- ✅ 8×6 rectangular grid displays correctly +- ✅ Only horizontal words (6 total, one per row) +- ✅ No radar/scope visualization +- ✅ 2 free letter guesses at game start +- ✅ Game completes and scores correctly +- ✅ Challenge mode works with new format + +### Nice to Have (v0.1.0+) +- Difficulty levels (word length variations) +- Tutorial/onboarding for new users +- Animated letter reveal for free letters +- Statistics tracking for free letter choices + +--- + +## Next Steps + +1. **Review this plan** with stakeholders +2. **Set up development branch** (`wrdler-v0.0.1-implementation`) +3. **Begin Sprint 1** (Core Data Models) +4. **Iterate and adjust** based on findings + +--- + +## Notes +- Keep BattleWords code in git history for reference +- Consider feature flags for gradual rollout +- Monitor user feedback closely after launch diff --git a/src/streamlit_app.py b/src/streamlit_app.py new file mode 100644 index 0000000000000000000000000000000000000000..99d0b84662681e7d21a08fcce44908344fa86f80 --- /dev/null +++ b/src/streamlit_app.py @@ -0,0 +1,40 @@ +import altair as alt +import numpy as np +import pandas as pd +import streamlit as st + +""" +# Welcome to Streamlit! + +Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:. +If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community +forums](https://discuss.streamlit.io). + +In the meantime, below is an example of what you can do with just a few lines of code: +""" + +num_points = st.slider("Number of points in spiral", 1, 10000, 1100) +num_turns = st.slider("Number of turns in spiral", 1, 300, 31) + +indices = np.linspace(0, 1, num_points) +theta = 2 * np.pi * num_turns * indices +radius = indices + +x = radius * np.cos(theta) +y = radius * np.sin(theta) + +df = pd.DataFrame({ + "x": x, + "y": y, + "idx": indices, + "rand": np.random.randn(num_points), +}) + +st.altair_chart(alt.Chart(df, height=700, width=700) + .mark_point(filled=True) + .encode( + x=alt.X("x", axis=None), + y=alt.Y("y", axis=None), + color=alt.Color("idx", legend=None, scale=alt.Scale()), + size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])), + )) \ No newline at end of file diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b446f3be4c6cc788f04dd0defce4f31f318cb013 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0764e93acaf6d6d5b082c74a7a6c8d335c3dc299 Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..fa3bc2de6e588c2f112d8c82fdca6d0744bfaccc --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "Wrdler", + "short_name": "Wrdler", + "description": "Simplified vocabulary puzzle game based on BattleWords. Discover hidden words on an 8x6 grid with 2 free letter guesses.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0b2a4a", + "theme_color": "#165ba8", + "icons": [ + { + "src": "/app/static/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/app/static/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["games", "education"], + "screenshots": [] +} diff --git a/static/service-worker.js b/static/service-worker.js new file mode 100644 index 0000000000000000000000000000000000000000..6fafd9c76192fa404833ba0d8c52e191de2a5601 --- /dev/null +++ b/static/service-worker.js @@ -0,0 +1,99 @@ +/** + * Wrdler Service Worker + * Enables PWA functionality: offline caching, install prompt, etc. + * + * Security Note: This file contains no secrets or sensitive data. + * It only caches public assets for offline access. + */ + +const CACHE_NAME = 'wrdler-v0.0.1'; +const RUNTIME_CACHE = 'wrdler-runtime'; + +// Assets to cache on install (minimal for faster install) +const PRECACHE_URLS = [ + '/', + '/app/static/manifest.json', + '/app/static/icon-192.png', + '/app/static/icon-512.png' +]; + +// Install event - cache essential files +self.addEventListener('install', event => { + console.log('[ServiceWorker] Installing...'); + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('[ServiceWorker] Precaching app shell'); + return cache.addAll(PRECACHE_URLS); + }) + .then(() => self.skipWaiting()) // Activate immediately + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', event => { + console.log('[ServiceWorker] Activating...'); + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) { + console.log('[ServiceWorker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => self.clients.claim()) // Take control immediately + ); +}); + +// Fetch event - network first, fall back to cache +self.addEventListener('fetch', event => { + // Skip non-GET requests + if (event.request.method !== 'GET') { + return; + } + + // Skip chrome-extension and other non-http requests + if (!event.request.url.startsWith('http')) { + return; + } + + event.respondWith( + caches.open(RUNTIME_CACHE).then(cache => { + return fetch(event.request) + .then(response => { + // Cache successful responses for future offline access + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + return response; + }) + .catch(() => { + // Network failed, try cache + return caches.match(event.request).then(cachedResponse => { + if (cachedResponse) { + console.log('[ServiceWorker] Serving from cache:', event.request.url); + return cachedResponse; + } + + // No cache available, return offline page or error + return new Response('Offline - Please check your connection', { + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ + 'Content-Type': 'text/plain' + }) + }); + }); + }); + }) + ); +}); + +// Message event - handle commands from the app +self.addEventListener('message', event => { + if (event.data.action === 'skipWaiting') { + self.skipWaiting(); + } +}); diff --git a/tests/test_apptest.py b/tests/test_apptest.py new file mode 100644 index 0000000000000000000000000000000000000000..9ed72b74652609899dfa3028e067f53360141c52 --- /dev/null +++ b/tests/test_apptest.py @@ -0,0 +1,7 @@ +# file: D:/Projects/Battlewords/tests/test_apptest.py +from streamlit.testing.v1 import AppTest + +def test_app_runs(): + at = AppTest.from_file("app.py") + at.run() + assert not at.exception \ No newline at end of file diff --git a/tests/test_compare_difficulty_functions.py b/tests/test_compare_difficulty_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..bb6b46816eb9cfd1c7b4244f06caa269cd6a1b24 --- /dev/null +++ b/tests/test_compare_difficulty_functions.py @@ -0,0 +1,237 @@ +# file: tests/test_compare_difficulty_functions.py +import os +import sys +import pytest + +# Ensure the modules path is available +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from wrdler.modules.constants import HF_API_TOKEN +from wrdler.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE +from wrdler.word_loader import compute_word_difficulties, compute_word_difficulties2, compute_word_difficulties3 + +# Ensure the token is set for Hugging Face Hub +if HF_API_TOKEN: + os.environ["HF_API_TOKEN"] = HF_API_TOKEN + +# Define sample_words as a global variable +sample_words = [] + +def test_compare_difficulty_functions_for_challenge(capsys): + """ + Compare compute_word_difficulties, compute_word_difficulties2, and compute_word_difficulties3 + for all users in a challenge identified by short_id. + """ + global sample_words # Ensure we modify the global variable + + # Use a fixed short id for testing + short_id = "hDjsB_dl" + + # Step 1: Resolve short ID to full URL + status, full_url = gen_full_url( + short_url=short_id, + repo_id=HF_REPO_ID, + json_file=SHORTENER_JSON_FILE + ) + + if status != "success_retrieved_full" or not full_url: + print( + f"Could not resolve short id '{short_id}'. " + f"Status: {status}. " + f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'." + ) + captured = capsys.readouterr() + assert "Could not resolve short id" in captured.out + assert not full_url, "full_url should be empty/None on failure" + print("settings.json was not found or could not be resolved.") + return + + print(f"✓ Resolved short id '{short_id}' to full URL: {full_url}") + + # Step 2: Extract file path from full URL + url_parts = full_url.split("/resolve/main/") + assert len(url_parts) == 2, f"Invalid full URL format: {full_url}" + file_path = url_parts[1] + + # Step 3: Download and parse settings.json + settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset") + assert settings, "Failed to download or parse settings.json" + print(f"✓ Downloaded settings.json") + + # Validate settings structure + assert "challenge_id" in settings + assert "wordlist_source" in settings + assert "users" in settings + + wordlist_source = settings.get("wordlist_source", "wordlist.txt") + users = settings.get("users", []) + + print(f"\nChallenge ID: {settings['challenge_id']}") + print(f"Wordlist Source: {wordlist_source}") + print(f"Number of Users: {len(users)}") + + # Step 4: Determine wordlist file path + # Assuming the wordlist is in battlewords/words/ directory + words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words") + wordlist_path = os.path.join(words_dir, wordlist_source) + + # If wordlist doesn't exist, try classic.txt as fallback + if not os.path.exists(wordlist_path): + print(f"⚠ Wordlist '{wordlist_source}' not found, using 'classic.txt' as fallback") + wordlist_path = os.path.join(words_dir, "classic.txt") + + assert os.path.exists(wordlist_path), f"Wordlist file not found: {wordlist_path}" + print(f"✓ Using wordlist: {wordlist_path}") + + # Step 5: Compare difficulty functions for each user + print("\n" + "="*80) + print("DIFFICULTY COMPARISON BY USER") + print("="*80) + + all_results = [] + + for user_idx, user in enumerate(users, 1): + user_name = user.get("name", f"User {user_idx}") + word_list = user.get("word_list", []) + sample_words += word_list # Update the global variable with the latest word list + + if not word_list: + print(f"\n[{user_idx}] {user_name}: No words assigned, skipping") + continue + + print(f"\n[{user_idx}] {user_name}") + print(f" Words: {len(word_list)} words") + print(f" Sample: {', '.join(word_list[:5])}{'...' if len(word_list) > 5 else ''}") + + # Compute difficulties using all three functions + total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, word_list) + total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, word_list) + total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, word_list) + + print(f"\n Function 1 (compute_word_difficulties):") + print(f" Total Difficulty: {total_diff1:.4f}") + print(f" Words Processed: {len(difficulties1)}") + + print(f"\n Function 2 (compute_word_difficulties2):") + print(f" Total Difficulty: {total_diff2:.4f}") + print(f" Words Processed: {len(difficulties2)}") + + print(f"\n Function 3 (compute_word_difficulties3):") + print(f" Total Difficulty: {total_diff3:.4f}") + print(f" Words Processed: {len(difficulties3)}") + + # Calculate statistics + if difficulties1 and difficulties2 and difficulties3: + avg_diff1 = total_diff1 / len(difficulties1) + avg_diff2 = total_diff2 / len(difficulties2) + avg_diff3 = total_diff3 / len(difficulties3) + + print(f"\n Comparison:") + print(f" Average Difficulty (Func1): {avg_diff1:.4f}") + print(f" Average Difficulty (Func2): {avg_diff2:.4f}") + print(f" Average Difficulty (Func3): {avg_diff3:.4f}") + print(f" Difference (Func1 vs Func2): {abs(avg_diff1 - avg_diff2):.4f}") + print(f" Difference (Func1 vs Func3): {abs(avg_diff1 - avg_diff3):.4f}") + print(f" Difference (Func2 vs Func3): {abs(avg_diff2 - avg_diff3):.4f}") + + # Store results for final summary + all_results.append({ + "user_name": user_name, + "word_count": len(word_list), + "total_diff1": total_diff1, + "total_diff2": total_diff2, + "total_diff3": total_diff3, + "difficulties1": difficulties1, + "difficulties2": difficulties2, + "difficulties3": difficulties3, + }) + + # Step 6: Print summary comparison + print("\n" + "="*80) + print("OVERALL SUMMARY") + print("="*80) + + if all_results: + total1_sum = sum(r["total_diff1"] for r in all_results) + total2_sum = sum(r["total_diff2"] for r in all_results) + total3_sum = sum(r["total_diff3"] for r in all_results) + total_words = sum(r["word_count"] for r in all_results) + + print(f"\nTotal Users Analyzed: {len(all_results)}") + print(f"Total Words Across All Users: {total_words}") + print(f"\nAggregate Total Difficulty:") + print(f" Function 1: {total1_sum:.4f}") + print(f" Function 2: {total2_sum:.4f}") + print(f" Function 3: {total3_sum:.4f}") + print(f" Difference (Func1 vs Func2): {abs(total1_sum - total2_sum):.4f}") + print(f" Difference (Func1 vs Func3): {abs(total1_sum - total3_sum):.4f}") + print(f" Difference (Func2 vs Func3): {abs(total2_sum - total3_sum):.4f}") + + # Validate that all functions returned results for all users + assert all(r["difficulties1"] for r in all_results), "Function 1 failed for some users" + assert all(r["difficulties2"] for r in all_results), "Function 2 failed for some users" + assert all(r["difficulties3"] for r in all_results), "Function 3 failed for some users" + + print("\n✓ All tests passed!") + else: + print("\n⚠ No users with words found in this challenge") + + +def test_compare_difficulty_functions_with_classic_wordlist(): + """ + Test all three difficulty functions using the classic.txt wordlist + with a sample set of words. + """ + global sample_words # Use the global variable + + words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words") + wordlist_path = os.path.join(words_dir, "classic.txt") + + if not os.path.exists(wordlist_path): + pytest.skip(f"classic.txt not found at {wordlist_path}") + + # Use the global sample_words if already populated, otherwise set a default + if not sample_words: + sample_words = ["ABLE", "ACID", "AREA", "ARMY", "BEAR", "BOWL", "CAVE", "COIN", "ECHO", "GOLD"] + + print("\n" + "="*80) + print("TESTING WITH CLASSIC.TXT WORDLIST") + print("="*80) + print(f"Sample Words: {', '.join(sample_words)}") + + # Compute difficulties + total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, sample_words) + total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, sample_words) + total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, sample_words) + + print(f"\nFunction compute_word_difficulties Results:") + print(f" Total Difficulty: {total_diff1:.4f}") + for word in sample_words: + if word in difficulties1: + print(f" {word}: {difficulties1[word]:.4f}") + + print(f"\nFunction compute_word_difficulties2 Results:") + print(f" Total Difficulty: {total_diff2:.4f}") + for word in sample_words: + if word in difficulties2: + print(f" {word}: {difficulties2[word]:.4f}") + + print(f"\nFunction compute_word_difficulties3 Results:") + print(f" Total Difficulty: {total_diff3:.4f}") + for word in sample_words: + if word in difficulties3: + print(f" {word}: {difficulties3[word]:.4f}") + + # Assertions + assert len(difficulties1) == len(set(sample_words)), "Function 1 didn't process all words" + assert len(difficulties2) == len(set(sample_words)), "Function 2 didn't process all words" + assert len(difficulties3) == len(set(sample_words)), "Function 3 didn't process all words" + assert total_diff1 > 0, "Function 1 total difficulty should be positive" + assert total_diff2 > 0, "Function 2 total difficulty should be positive" + assert total_diff3 > 0, "Function 3 total difficulty should be positive" + + print("\n✓ Classic wordlist test passed!") + + +if __name__ == "__main__": + pytest.main(["-s", "-v", __file__]) \ No newline at end of file diff --git a/tests/test_download_game_settings.py b/tests/test_download_game_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..5b878145534ebff06d0d46c9115b1c15d4a1a7cd --- /dev/null +++ b/tests/test_download_game_settings.py @@ -0,0 +1,63 @@ +# file: tests/test_download_game_settings.py +import os +import sys +import pytest + +# Ensure the modules path is available +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from wrdler.modules.constants import HF_API_TOKEN # <-- Import the token +from wrdler.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE + +# Ensure the token is set for Hugging Face Hub +if HF_API_TOKEN: + os.environ["HF_API_TOKEN"] = HF_API_TOKEN + +def test_download_settings_by_short_id_handles_both(capsys): + # Use a fixed short id for testing + short_id = "hDjsB_dl" + + # Step 1: Resolve short ID to full URL + status, full_url = gen_full_url( + short_url=short_id, + repo_id=HF_REPO_ID, + json_file=SHORTENER_JSON_FILE + ) + + # Failure branch: provide a helpful message and assert expected failure shape + if status != "success_retrieved_full" or not full_url: + print( + f"Could not resolve short id '{short_id}'. " + f"Status: {status}. " + f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'." + ) + captured = capsys.readouterr() + assert "Could not resolve short id" in captured.out + # Ensure failure shape is consistent + assert not full_url, "full_url should be empty/None on failure" + print("settings.json was not found or could not be resolved.") + return + else: + print(f"Resolved short id '{short_id}' to full URL: {full_url}") + + # Success branch + assert status == "success_retrieved_full", f"Failed to resolve short ID: {status}" + assert full_url, "No full URL returned" + + # Step 2: Extract file path from full URL + url_parts = full_url.split("/resolve/main/") + assert len(url_parts) == 2, f"Invalid full URL format: {full_url}" + file_path = url_parts[1] + + # Step 3: Download and parse settings.json + settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset") + assert settings, "Failed to download or parse settings.json" + + print("\nDownloaded settings.json contents:", settings) + # Optionally, add more assertions about the settings structure + assert "challenge_id" in settings + assert "wordlist_source" in settings + assert "users" in settings + +if __name__ == "__main__": + pytest.main(["-s", __file__]) \ No newline at end of file diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..8e5fcb85a39b2d6dffb583a0acf478be8d11c9b5 --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,29 @@ +import unittest + +from wrdler.generator import generate_puzzle, validate_puzzle +from wrdler.models import Coord + + +class TestGenerator(unittest.TestCase): + def test_generate_valid_puzzle(self): + # Provide a minimal word pool for deterministic testing + words_by_len = { + 4: ["TREE", "BOAT"], + 5: ["APPLE", "RIVER"], + 6: ["ORANGE", "PYTHON"], + } + p = generate_puzzle(grid_size=12, words_by_len=words_by_len, seed=1234) + validate_puzzle(p, grid_size=12) + # Ensure 6 words and 6 radar pulses + self.assertEqual(len(p.words), 6) + self.assertEqual(len(p.radar), 6) + # Ensure no overlaps + seen = set() + for w in p.words: + for c in w.cells: + self.assertNotIn(c, seen) + seen.add(c) + self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_logic.py b/tests/test_logic.py new file mode 100644 index 0000000000000000000000000000000000000000..386ab14c191a00747c99cdcc81b5159180abbe4f --- /dev/null +++ b/tests/test_logic.py @@ -0,0 +1,55 @@ +import unittest + +from wrdler.logic import build_letter_map, reveal_cell, guess_word, is_game_over +from wrdler.models import Coord, Word, Puzzle, GameState + + +class TestLogic(unittest.TestCase): + def make_state(self): + w1 = Word("TREE", Coord(0, 0), "H") + w2 = Word("APPLE", Coord(2, 0), "H") + w3 = Word("ORANGE", Coord(4, 0), "H") + w4 = Word("WIND", Coord(0, 6), "V") + w5 = Word("MOUSE", Coord(0, 8), "V") + w6 = Word("PYTHON", Coord(0, 10), "V") + p = Puzzle([w1, w2, w3, w4, w5, w6]) + state = GameState( + grid_size=12, + puzzle=p, + revealed=set(), + guessed=set(), + score=0, + last_action="", + can_guess=False, + ) + return state, p + + def test_reveal_and_guess_gating(self): + state, puzzle = self.make_state() + letter_map = build_letter_map(puzzle) + # Can't guess before reveal + ok, pts = guess_word(state, "TREE") + self.assertFalse(ok) + self.assertEqual(pts, 0) + # Reveal one cell then guess + reveal_cell(state, letter_map, Coord(0, 0)) + self.assertTrue(state.can_guess) + ok, pts = guess_word(state, "TREE") + self.assertTrue(ok) + self.assertGreater(pts, 0) + self.assertIn("TREE", state.guessed) + self.assertFalse(state.can_guess) + + def test_game_over(self): + state, puzzle = self.make_state() + letter_map = build_letter_map(puzzle) + # Guess all words after a reveal each time + for w in puzzle.words: + reveal_cell(state, letter_map, w.start) + ok, _ = guess_word(state, w.text) + self.assertTrue(ok) + self.assertTrue(is_game_over(state)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..556311bfc5b427cb43a78cb38a3dacf99096add2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,626 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "altair" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305, upload-time = "2024-11-23T23:39:58.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200, upload-time = "2024-11-23T23:39:56.4Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "battlewords" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "matplotlib" }, + { name = "streamlit" }, +] + +[package.metadata] +requires-dist = [ + { name = "matplotlib", specifier = ">=3.8" }, + { name = "streamlit", specifier = ">=1.50.0" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "fonttools" +version = "4.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" }, + { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" }, +] + +[[package]] +name = "narwhals" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/b8/3cb005704866f1cc19e8d6b15d0467255821ba12d82f20ea15912672e54c/narwhals-2.5.0.tar.gz", hash = "sha256:8ae0b6f39597f14c0dc52afc98949d6f8be89b5af402d2d98101d2f7d3561418", size = 558573, upload-time = "2025-09-12T10:04:24.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5a/22741c5c0e5f6e8050242bfc2052ba68bc94b1735ed5bca35404d136d6ec/narwhals-2.5.0-py3-none-any.whl", hash = "sha256:7e213f9ca7db3f8bf6f7eff35eaee6a1cf80902997e1b78d49b7755775d8f423", size = 407296, upload-time = "2025-09-12T10:04:22.524Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, + { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, +] + +[[package]] +name = "protobuf" +version = "6.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411, upload-time = "2025-09-11T21:38:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738, upload-time = "2025-09-11T21:38:30.959Z" }, + { url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454, upload-time = "2025-09-11T21:38:34.076Z" }, + { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013, upload-time = "2025-09-11T21:38:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" }, +] + +[[package]] +name = "pyarrow" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, + { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, +] + +[[package]] +name = "pydeck" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "streamlit" +version = "1.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "tornado" }, + { name = "typing-extensions" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/f6/f7d3a0146577c1918439d3163707040f7111a7d2e7e2c73fa7adeb169c06/streamlit-1.50.0.tar.gz", hash = "sha256:87221d568aac585274a05ef18a378b03df332b93e08103fffcf3cd84d852af46", size = 9664808, upload-time = "2025-09-23T19:24:00.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/38/991bbf9fa3ed3d9c8e69265fc449bdaade8131c7f0f750dbd388c3c477dc/streamlit-1.50.0-py3-none-any.whl", hash = "sha256:9403b8f94c0a89f80cf679c2fcc803d9a6951e0fba542e7611995de3f67b4bb3", size = 10068477, upload-time = "2025-09-23T19:23:57.245Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] diff --git a/wrdler/__init__.py b/wrdler/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7fa4c8fb8fa5444bd0e40027b091436aa4204820 --- /dev/null +++ b/wrdler/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.0.1" +__all__ = ["models", "generator", "logic", "ui", "game_storage"] \ No newline at end of file diff --git a/wrdler/assets/audio/effects/correct_guess.mp3 b/wrdler/assets/audio/effects/correct_guess.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..95437e2a47c8920f09bd65dce51e00e271e2e0c1 --- /dev/null +++ b/wrdler/assets/audio/effects/correct_guess.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65fcf49f02fd7a6c70dd3c270c254c03dd286e9fb50e382d285b79cb5e24d22d +size 97255 diff --git a/wrdler/assets/audio/effects/hit.mp3 b/wrdler/assets/audio/effects/hit.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e87bd42174767361c690df49c23bbd606f6fd0b4 --- /dev/null +++ b/wrdler/assets/audio/effects/hit.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baaf44f8d29b5543d6c3418ae8ea5d8144046362055b95678b552965f3850a6b +size 25833 diff --git a/wrdler/assets/audio/effects/incorrect_guess.mp3 b/wrdler/assets/audio/effects/incorrect_guess.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a21cc6195c254d964f92fca7bb361128abe26482 --- /dev/null +++ b/wrdler/assets/audio/effects/incorrect_guess.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07f70499881c735fc284e9a6b17f0f6d383b35b6e06f0c90aa672597110b916c +size 23449 diff --git a/wrdler/assets/audio/effects/miss.mp3 b/wrdler/assets/audio/effects/miss.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a21cc6195c254d964f92fca7bb361128abe26482 --- /dev/null +++ b/wrdler/assets/audio/effects/miss.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07f70499881c735fc284e9a6b17f0f6d383b35b6e06f0c90aa672597110b916c +size 23449 diff --git a/wrdler/assets/audio/music/background.mp3 b/wrdler/assets/audio/music/background.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..6d10a550a4e6915ac3ccadd4db7d6b0ab7055406 --- /dev/null +++ b/wrdler/assets/audio/music/background.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05fce6cbeff847885ee01717c879b6fad7f347460c3006cfc071cafc37b59451 +size 2161810 diff --git a/wrdler/assets/audio/music/congratulations.mp3 b/wrdler/assets/audio/music/congratulations.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a22a75e7407d0e20ea50098fc3e2a85413f594d4 --- /dev/null +++ b/wrdler/assets/audio/music/congratulations.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:232ca809d3940e7d3491f29ac97230fe0691f21e46993d6d3f42f905c9d225bf +size 1811619 diff --git a/wrdler/assets/scope.gif b/wrdler/assets/scope.gif new file mode 100644 index 0000000000000000000000000000000000000000..45c6c2618e718a2aaa2c97cb0f17c31c83e257b2 Binary files /dev/null and b/wrdler/assets/scope.gif differ diff --git a/wrdler/assets/scope_blue.gif b/wrdler/assets/scope_blue.gif new file mode 100644 index 0000000000000000000000000000000000000000..9def1f6f521bf6544cd509bb31fd10cc459aadc0 Binary files /dev/null and b/wrdler/assets/scope_blue.gif differ diff --git a/wrdler/assets/scope_blue.png b/wrdler/assets/scope_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..a20c1a5b79a779717388a3ab992a07faa799614c Binary files /dev/null and b/wrdler/assets/scope_blue.png differ diff --git a/wrdler/audio.py b/wrdler/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..492fafbe23fe6eaca4dc0708f3119322162a8d3f --- /dev/null +++ b/wrdler/audio.py @@ -0,0 +1,246 @@ +import os +from typing import Optional +import streamlit as st + +def _get_music_dir() -> str: + return os.path.join(os.path.dirname(__file__), "assets", "audio", "music") + +def _get_effects_dir() -> str: + return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects") + +def get_audio_tracks() -> list[tuple[str, str]]: + """Return list of (label, absolute_path) for .mp3 files in assets/audio/music.""" + audio_dir = _get_music_dir() + if not os.path.isdir(audio_dir): + return [] + tracks = [] + for fname in os.listdir(audio_dir): + if fname.lower().endswith('.mp3'): + path = os.path.join(audio_dir, fname) + # Use the filename without extension as the display name + name = os.path.splitext(fname)[0] + tracks.append((name, path)) + return tracks + +@st.cache_data(show_spinner=False) +def _load_audio_data_url(path: str) -> str: + """Return a data: URL for the given audio file so the browser can play it.""" + import base64, mimetypes + mime, _ = mimetypes.guess_type(path) + if not mime: + # Default to mp3 to avoid blocked playback if unknown + mime = "audio/mpeg" + with open(path, "rb") as fp: + encoded = base64.b64encode(fp.read()).decode("ascii") + return f"data:{mime};base64,{encoded}" + +def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float, loop: bool = True) -> None: + """Create/update a single hidden