Commit
·
4f625d4
0
Parent(s):
Initial Commit v0.0.1
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +37 -0
- .gitignore +493 -0
- .streamlit/config.toml +10 -0
- CLAUDE.md +426 -0
- Dockerfile +61 -0
- LOCALHOST_PWA_README.md +269 -0
- MANIFEST.in +1 -0
- PWA_INSTALL_GUIDE.md +208 -0
- README.md +535 -0
- app.py +22 -0
- generate_pwa_icons.py +98 -0
- inject-pwa-head.sh +49 -0
- pwa-head-inject.html +8 -0
- pyproject.toml +25 -0
- requirements.txt +15 -0
- specs/requirements.md +164 -0
- specs/specs.md +195 -0
- specs/wrdler_implementation_plan.md +525 -0
- src/streamlit_app.py +40 -0
- static/icon-192.png +0 -0
- static/icon-512.png +0 -0
- static/manifest.json +27 -0
- static/service-worker.js +99 -0
- tests/test_apptest.py +7 -0
- tests/test_compare_difficulty_functions.py +237 -0
- tests/test_download_game_settings.py +63 -0
- tests/test_generator.py +29 -0
- tests/test_logic.py +55 -0
- uv.lock +626 -0
- wrdler/__init__.py +2 -0
- wrdler/assets/audio/effects/correct_guess.mp3 +3 -0
- wrdler/assets/audio/effects/hit.mp3 +3 -0
- wrdler/assets/audio/effects/incorrect_guess.mp3 +3 -0
- wrdler/assets/audio/effects/miss.mp3 +3 -0
- wrdler/assets/audio/music/background.mp3 +3 -0
- wrdler/assets/audio/music/congratulations.mp3 +3 -0
- wrdler/assets/scope.gif +0 -0
- wrdler/assets/scope_blue.gif +0 -0
- wrdler/assets/scope_blue.png +0 -0
- wrdler/audio.py +246 -0
- wrdler/game_storage.py +546 -0
- wrdler/generate_sounds.py +174 -0
- wrdler/generator.py +223 -0
- wrdler/local_storage.py +193 -0
- wrdler/logic.py +175 -0
- wrdler/models.py +102 -0
- wrdler/modules/__init__.py +80 -0
- wrdler/modules/constants.py +60 -0
- wrdler/modules/file_utils.py +204 -0
- wrdler/modules/storage.md +227 -0
.gitattributes
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Ignore Visual Studio temporary files, build results, and
|
| 2 |
+
## files generated by popular Visual Studio add-ons.
|
| 3 |
+
##
|
| 4 |
+
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
| 5 |
+
|
| 6 |
+
# User-specific files
|
| 7 |
+
*.rsuser
|
| 8 |
+
*.suo
|
| 9 |
+
*.user
|
| 10 |
+
*.userosscache
|
| 11 |
+
*.sln.docstates
|
| 12 |
+
*.env
|
| 13 |
+
*.venv
|
| 14 |
+
|
| 15 |
+
# User-specific files (MonoDevelop/Xamarin Studio)
|
| 16 |
+
*.userprefs
|
| 17 |
+
|
| 18 |
+
# Mono auto generated files
|
| 19 |
+
mono_crash.*
|
| 20 |
+
|
| 21 |
+
# Build results
|
| 22 |
+
[Dd]ebug/
|
| 23 |
+
[Dd]ebugPublic/
|
| 24 |
+
[Rr]elease/
|
| 25 |
+
[Rr]eleases/
|
| 26 |
+
x64/
|
| 27 |
+
x86/
|
| 28 |
+
[Ww][Ii][Nn]32/
|
| 29 |
+
[Aa][Rr][Mm]/
|
| 30 |
+
[Aa][Rr][Mm]64/
|
| 31 |
+
[Aa][Rr][Mm]64[Ee][Cc]/
|
| 32 |
+
bld/
|
| 33 |
+
[Oo]bj/
|
| 34 |
+
[Oo]ut/
|
| 35 |
+
[Ll]og/
|
| 36 |
+
[Ll]ogs/
|
| 37 |
+
|
| 38 |
+
# Build results on 'Bin' directories
|
| 39 |
+
**/[Bb]in/*
|
| 40 |
+
# Uncomment if you have tasks that rely on *.refresh files to move binaries
|
| 41 |
+
# (https://github.com/github/gitignore/pull/3736)
|
| 42 |
+
#!**/[Bb]in/*.refresh
|
| 43 |
+
|
| 44 |
+
# Visual Studio 2015/2017 cache/options directory
|
| 45 |
+
.vs/
|
| 46 |
+
# Uncomment if you have tasks that create the project's static files in wwwroot
|
| 47 |
+
#wwwroot/
|
| 48 |
+
|
| 49 |
+
# Visual Studio 2017 auto generated files
|
| 50 |
+
Generated\ Files/
|
| 51 |
+
|
| 52 |
+
# MSTest test Results
|
| 53 |
+
[Tt]est[Rr]esult*/
|
| 54 |
+
[Bb]uild[Ll]og.*
|
| 55 |
+
*.trx
|
| 56 |
+
|
| 57 |
+
# NUnit
|
| 58 |
+
*.VisualState.xml
|
| 59 |
+
TestResult.xml
|
| 60 |
+
nunit-*.xml
|
| 61 |
+
|
| 62 |
+
# Approval Tests result files
|
| 63 |
+
*.received.*
|
| 64 |
+
|
| 65 |
+
# Build Results of an ATL Project
|
| 66 |
+
[Dd]ebugPS/
|
| 67 |
+
[Rr]eleasePS/
|
| 68 |
+
dlldata.c
|
| 69 |
+
|
| 70 |
+
# Benchmark Results
|
| 71 |
+
BenchmarkDotNet.Artifacts/
|
| 72 |
+
|
| 73 |
+
# .NET Core
|
| 74 |
+
project.lock.json
|
| 75 |
+
project.fragment.lock.json
|
| 76 |
+
artifacts/
|
| 77 |
+
|
| 78 |
+
# ASP.NET Scaffolding
|
| 79 |
+
ScaffoldingReadMe.txt
|
| 80 |
+
|
| 81 |
+
# StyleCop
|
| 82 |
+
StyleCopReport.xml
|
| 83 |
+
|
| 84 |
+
# Files built by Visual Studio
|
| 85 |
+
*_i.c
|
| 86 |
+
*_p.c
|
| 87 |
+
*_h.h
|
| 88 |
+
*.ilk
|
| 89 |
+
*.meta
|
| 90 |
+
*.obj
|
| 91 |
+
*.idb
|
| 92 |
+
*.iobj
|
| 93 |
+
*.pch
|
| 94 |
+
*.pdb
|
| 95 |
+
*.ipdb
|
| 96 |
+
*.pgc
|
| 97 |
+
*.pgd
|
| 98 |
+
*.rsp
|
| 99 |
+
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
| 100 |
+
!Directory.Build.rsp
|
| 101 |
+
*.sbr
|
| 102 |
+
*.tlb
|
| 103 |
+
*.tli
|
| 104 |
+
*.tlh
|
| 105 |
+
*.tmp
|
| 106 |
+
*.tmp_proj
|
| 107 |
+
*_wpftmp.csproj
|
| 108 |
+
*.log
|
| 109 |
+
*.tlog
|
| 110 |
+
*.vspscc
|
| 111 |
+
*.vssscc
|
| 112 |
+
.builds
|
| 113 |
+
*.pidb
|
| 114 |
+
*.svclog
|
| 115 |
+
*.scc
|
| 116 |
+
|
| 117 |
+
# Chutzpah Test files
|
| 118 |
+
_Chutzpah*
|
| 119 |
+
|
| 120 |
+
# Visual C++ cache files
|
| 121 |
+
ipch/
|
| 122 |
+
*.aps
|
| 123 |
+
*.ncb
|
| 124 |
+
*.opendb
|
| 125 |
+
*.opensdf
|
| 126 |
+
*.sdf
|
| 127 |
+
*.cachefile
|
| 128 |
+
*.VC.db
|
| 129 |
+
*.VC.VC.opendb
|
| 130 |
+
|
| 131 |
+
# Visual Studio profiler
|
| 132 |
+
*.psess
|
| 133 |
+
*.vsp
|
| 134 |
+
*.vspx
|
| 135 |
+
*.sap
|
| 136 |
+
|
| 137 |
+
# Visual Studio Trace Files
|
| 138 |
+
*.e2e
|
| 139 |
+
|
| 140 |
+
# TFS 2012 Local Workspace
|
| 141 |
+
$tf/
|
| 142 |
+
|
| 143 |
+
# Guidance Automation Toolkit
|
| 144 |
+
*.gpState
|
| 145 |
+
|
| 146 |
+
# ReSharper is a .NET coding add-in
|
| 147 |
+
_ReSharper*/
|
| 148 |
+
*.[Rr]e[Ss]harper
|
| 149 |
+
*.DotSettings.user
|
| 150 |
+
|
| 151 |
+
# TeamCity is a build add-in
|
| 152 |
+
_TeamCity*
|
| 153 |
+
|
| 154 |
+
# DotCover is a Code Coverage Tool
|
| 155 |
+
*.dotCover
|
| 156 |
+
|
| 157 |
+
# AxoCover is a Code Coverage Tool
|
| 158 |
+
.axoCover/*
|
| 159 |
+
!.axoCover/settings.json
|
| 160 |
+
|
| 161 |
+
# Coverlet is a free, cross platform Code Coverage Tool
|
| 162 |
+
coverage*.json
|
| 163 |
+
coverage*.xml
|
| 164 |
+
coverage*.info
|
| 165 |
+
|
| 166 |
+
# Visual Studio code coverage results
|
| 167 |
+
*.coverage
|
| 168 |
+
*.coveragexml
|
| 169 |
+
|
| 170 |
+
# NCrunch
|
| 171 |
+
_NCrunch_*
|
| 172 |
+
.NCrunch_*
|
| 173 |
+
.*crunch*.local.xml
|
| 174 |
+
nCrunchTemp_*
|
| 175 |
+
|
| 176 |
+
# MightyMoose
|
| 177 |
+
*.mm.*
|
| 178 |
+
AutoTest.Net/
|
| 179 |
+
|
| 180 |
+
# Web workbench (sass)
|
| 181 |
+
.sass-cache/
|
| 182 |
+
|
| 183 |
+
# Installshield output folder
|
| 184 |
+
[Ee]xpress/
|
| 185 |
+
|
| 186 |
+
# DocProject is a documentation generator add-in
|
| 187 |
+
DocProject/buildhelp/
|
| 188 |
+
DocProject/Help/*.HxT
|
| 189 |
+
DocProject/Help/*.HxC
|
| 190 |
+
DocProject/Help/*.hhc
|
| 191 |
+
DocProject/Help/*.hhk
|
| 192 |
+
DocProject/Help/*.hhp
|
| 193 |
+
DocProject/Help/Html2
|
| 194 |
+
DocProject/Help/html
|
| 195 |
+
|
| 196 |
+
# Click-Once directory
|
| 197 |
+
publish/
|
| 198 |
+
|
| 199 |
+
# Publish Web Output
|
| 200 |
+
*.[Pp]ublish.xml
|
| 201 |
+
*.azurePubxml
|
| 202 |
+
# Note: Comment the next line if you want to checkin your web deploy settings,
|
| 203 |
+
# but database connection strings (with potential passwords) will be unencrypted
|
| 204 |
+
*.pubxml
|
| 205 |
+
*.publishproj
|
| 206 |
+
|
| 207 |
+
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
| 208 |
+
# checkin your Azure Web App publish settings, but sensitive information contained
|
| 209 |
+
# in these scripts will be unencrypted
|
| 210 |
+
PublishScripts/
|
| 211 |
+
|
| 212 |
+
# NuGet Packages
|
| 213 |
+
*.nupkg
|
| 214 |
+
# NuGet Symbol Packages
|
| 215 |
+
*.snupkg
|
| 216 |
+
# The packages folder can be ignored because of Package Restore
|
| 217 |
+
**/[Pp]ackages/*
|
| 218 |
+
# except build/, which is used as an MSBuild target.
|
| 219 |
+
!**/[Pp]ackages/build/
|
| 220 |
+
# Uncomment if necessary however generally it will be regenerated when needed
|
| 221 |
+
#!**/[Pp]ackages/repositories.config
|
| 222 |
+
# NuGet v3's project.json files produces more ignorable files
|
| 223 |
+
*.nuget.props
|
| 224 |
+
*.nuget.targets
|
| 225 |
+
|
| 226 |
+
# Microsoft Azure Build Output
|
| 227 |
+
csx/
|
| 228 |
+
*.build.csdef
|
| 229 |
+
|
| 230 |
+
# Microsoft Azure Emulator
|
| 231 |
+
ecf/
|
| 232 |
+
rcf/
|
| 233 |
+
|
| 234 |
+
# Windows Store app package directories and files
|
| 235 |
+
AppPackages/
|
| 236 |
+
BundleArtifacts/
|
| 237 |
+
Package.StoreAssociation.xml
|
| 238 |
+
_pkginfo.txt
|
| 239 |
+
*.appx
|
| 240 |
+
*.appxbundle
|
| 241 |
+
*.appxupload
|
| 242 |
+
|
| 243 |
+
# Visual Studio cache files
|
| 244 |
+
# files ending in .cache can be ignored
|
| 245 |
+
*.[Cc]ache
|
| 246 |
+
# but keep track of directories ending in .cache
|
| 247 |
+
!?*.[Cc]ache/
|
| 248 |
+
|
| 249 |
+
# Others
|
| 250 |
+
ClientBin/
|
| 251 |
+
~$*
|
| 252 |
+
*~
|
| 253 |
+
*.dbmdl
|
| 254 |
+
*.dbproj.schemaview
|
| 255 |
+
*.jfm
|
| 256 |
+
*.pfx
|
| 257 |
+
*.publishsettings
|
| 258 |
+
orleans.codegen.cs
|
| 259 |
+
|
| 260 |
+
# Including strong name files can present a security risk
|
| 261 |
+
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
| 262 |
+
#*.snk
|
| 263 |
+
|
| 264 |
+
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
| 265 |
+
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
| 266 |
+
#bower_components/
|
| 267 |
+
|
| 268 |
+
# RIA/Silverlight projects
|
| 269 |
+
Generated_Code/
|
| 270 |
+
|
| 271 |
+
# Backup & report files from converting an old project file
|
| 272 |
+
# to a newer Visual Studio version. Backup files are not needed,
|
| 273 |
+
# because we have git ;-)
|
| 274 |
+
_UpgradeReport_Files/
|
| 275 |
+
Backup*/
|
| 276 |
+
UpgradeLog*.XML
|
| 277 |
+
UpgradeLog*.htm
|
| 278 |
+
ServiceFabricBackup/
|
| 279 |
+
*.rptproj.bak
|
| 280 |
+
|
| 281 |
+
# SQL Server files
|
| 282 |
+
*.mdf
|
| 283 |
+
*.ldf
|
| 284 |
+
*.ndf
|
| 285 |
+
|
| 286 |
+
# Business Intelligence projects
|
| 287 |
+
*.rdl.data
|
| 288 |
+
*.bim.layout
|
| 289 |
+
*.bim_*.settings
|
| 290 |
+
*.rptproj.rsuser
|
| 291 |
+
*- [Bb]ackup.rdl
|
| 292 |
+
*- [Bb]ackup ([0-9]).rdl
|
| 293 |
+
*- [Bb]ackup ([0-9][0-9]).rdl
|
| 294 |
+
|
| 295 |
+
# Microsoft Fakes
|
| 296 |
+
FakesAssemblies/
|
| 297 |
+
|
| 298 |
+
# GhostDoc plugin setting file
|
| 299 |
+
*.GhostDoc.xml
|
| 300 |
+
|
| 301 |
+
# Node.js Tools for Visual Studio
|
| 302 |
+
.ntvs_analysis.dat
|
| 303 |
+
node_modules/
|
| 304 |
+
|
| 305 |
+
# Visual Studio 6 build log
|
| 306 |
+
*.plg
|
| 307 |
+
|
| 308 |
+
# Visual Studio 6 workspace options file
|
| 309 |
+
*.opt
|
| 310 |
+
|
| 311 |
+
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
| 312 |
+
*.vbw
|
| 313 |
+
|
| 314 |
+
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
| 315 |
+
*.vbp
|
| 316 |
+
|
| 317 |
+
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
| 318 |
+
*.dsw
|
| 319 |
+
*.dsp
|
| 320 |
+
|
| 321 |
+
# Visual Studio 6 technical files
|
| 322 |
+
*.ncb
|
| 323 |
+
*.aps
|
| 324 |
+
|
| 325 |
+
# Visual Studio LightSwitch build output
|
| 326 |
+
**/*.HTMLClient/GeneratedArtifacts
|
| 327 |
+
**/*.DesktopClient/GeneratedArtifacts
|
| 328 |
+
**/*.DesktopClient/ModelManifest.xml
|
| 329 |
+
**/*.Server/GeneratedArtifacts
|
| 330 |
+
**/*.Server/ModelManifest.xml
|
| 331 |
+
_Pvt_Extensions
|
| 332 |
+
|
| 333 |
+
# Paket dependency manager
|
| 334 |
+
**/.paket/paket.exe
|
| 335 |
+
paket-files/
|
| 336 |
+
|
| 337 |
+
# FAKE - F# Make
|
| 338 |
+
**/.fake/
|
| 339 |
+
|
| 340 |
+
# CodeRush personal settings
|
| 341 |
+
**/.cr/personal
|
| 342 |
+
|
| 343 |
+
# Python Tools for Visual Studio (PTVS)
|
| 344 |
+
**/__pycache__/
|
| 345 |
+
*.pyc
|
| 346 |
+
**/**/__pycache__/
|
| 347 |
+
**/*.pyc
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# Cake - Uncomment if you are using it
|
| 351 |
+
#tools/**
|
| 352 |
+
#!tools/packages.config
|
| 353 |
+
|
| 354 |
+
# Tabs Studio
|
| 355 |
+
*.tss
|
| 356 |
+
|
| 357 |
+
# Telerik's JustMock configuration file
|
| 358 |
+
*.jmconfig
|
| 359 |
+
|
| 360 |
+
# BizTalk build output
|
| 361 |
+
*.btp.cs
|
| 362 |
+
*.btm.cs
|
| 363 |
+
*.odx.cs
|
| 364 |
+
*.xsd.cs
|
| 365 |
+
|
| 366 |
+
# OpenCover UI analysis results
|
| 367 |
+
OpenCover/
|
| 368 |
+
|
| 369 |
+
# Azure Stream Analytics local run output
|
| 370 |
+
ASALocalRun/
|
| 371 |
+
|
| 372 |
+
# MSBuild Binary and Structured Log
|
| 373 |
+
*.binlog
|
| 374 |
+
MSBuild_Logs/
|
| 375 |
+
|
| 376 |
+
# AWS SAM Build and Temporary Artifacts folder
|
| 377 |
+
.aws-sam
|
| 378 |
+
|
| 379 |
+
# NVidia Nsight GPU debugger configuration file
|
| 380 |
+
*.nvuser
|
| 381 |
+
|
| 382 |
+
# MFractors (Xamarin productivity tool) working folder
|
| 383 |
+
**/.mfractor/
|
| 384 |
+
|
| 385 |
+
# Local History for Visual Studio
|
| 386 |
+
**/.localhistory/
|
| 387 |
+
|
| 388 |
+
# Visual Studio History (VSHistory) files
|
| 389 |
+
.vshistory/
|
| 390 |
+
|
| 391 |
+
# BeatPulse healthcheck temp database
|
| 392 |
+
healthchecksdb
|
| 393 |
+
|
| 394 |
+
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
| 395 |
+
MigrationBackup/
|
| 396 |
+
|
| 397 |
+
# Ionide (cross platform F# VS Code tools) working folder
|
| 398 |
+
**/.ionide/
|
| 399 |
+
|
| 400 |
+
# Fody - auto-generated XML schema
|
| 401 |
+
FodyWeavers.xsd
|
| 402 |
+
|
| 403 |
+
# VS Code files for those working on multiple tools
|
| 404 |
+
.vscode/*
|
| 405 |
+
!.vscode/settings.json
|
| 406 |
+
!.vscode/tasks.json
|
| 407 |
+
!.vscode/launch.json
|
| 408 |
+
!.vscode/extensions.json
|
| 409 |
+
!.vscode/*.code-snippets
|
| 410 |
+
|
| 411 |
+
# Local History for Visual Studio Code
|
| 412 |
+
.history/
|
| 413 |
+
|
| 414 |
+
# Built Visual Studio Code Extensions
|
| 415 |
+
*.vsix
|
| 416 |
+
# Byte-compiled / optimized / DLL files
|
| 417 |
+
__pycache__/
|
| 418 |
+
*.py[cod]
|
| 419 |
+
*$py.class
|
| 420 |
+
|
| 421 |
+
# C extensions
|
| 422 |
+
*.so
|
| 423 |
+
|
| 424 |
+
# Distribution / packaging
|
| 425 |
+
.Python
|
| 426 |
+
build/
|
| 427 |
+
develop-eggs/
|
| 428 |
+
dist/
|
| 429 |
+
downloads/
|
| 430 |
+
eggs/
|
| 431 |
+
.eggs/
|
| 432 |
+
lib/
|
| 433 |
+
lib64/
|
| 434 |
+
parts/
|
| 435 |
+
sdist/
|
| 436 |
+
var/
|
| 437 |
+
*.egg-info/
|
| 438 |
+
.installed.cfg
|
| 439 |
+
*.egg
|
| 440 |
+
|
| 441 |
+
# Installer logs
|
| 442 |
+
pip-log.txt
|
| 443 |
+
pip-delete-this-directory.txt
|
| 444 |
+
|
| 445 |
+
# Unit test / coverage reports
|
| 446 |
+
htmlcov/
|
| 447 |
+
.tox/
|
| 448 |
+
.nox/
|
| 449 |
+
.coverage
|
| 450 |
+
.coverage.*
|
| 451 |
+
.cache
|
| 452 |
+
nosetests.xml
|
| 453 |
+
coverage.xml
|
| 454 |
+
*.cover
|
| 455 |
+
.hypothesis/
|
| 456 |
+
.pytest_cache/
|
| 457 |
+
|
| 458 |
+
# Jupyter Notebook
|
| 459 |
+
.ipynb_checkpoints
|
| 460 |
+
|
| 461 |
+
# pyenv
|
| 462 |
+
.python-version
|
| 463 |
+
|
| 464 |
+
# mypy
|
| 465 |
+
.mypy_cache/
|
| 466 |
+
.dmypy.json
|
| 467 |
+
dmypy.json
|
| 468 |
+
|
| 469 |
+
# VS Code
|
| 470 |
+
.vscode/
|
| 471 |
+
|
| 472 |
+
# Streamlit
|
| 473 |
+
#.streamlit/
|
| 474 |
+
|
| 475 |
+
# Docker
|
| 476 |
+
*.env
|
| 477 |
+
.env.*
|
| 478 |
+
|
| 479 |
+
# System files
|
| 480 |
+
.DS_Store
|
| 481 |
+
Thumbs.db
|
| 482 |
+
|
| 483 |
+
# Local words directory (if you want to ignore user-added wordlists)
|
| 484 |
+
# wrdler/words/*.txt
|
| 485 |
+
|
| 486 |
+
# Ignore secrets
|
| 487 |
+
secrets.*
|
| 488 |
+
/.vs
|
| 489 |
+
/wrdler/__pycache__/ui.cpython-311.pyc
|
| 490 |
+
/wrdler/__pycache__/__init__.cpython-311.pyc
|
| 491 |
+
/package.json
|
| 492 |
+
/package-lock.json
|
| 493 |
+
/.claude
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[server]
|
| 2 |
+
enableStaticServing = true
|
| 3 |
+
|
| 4 |
+
[theme]
|
| 5 |
+
base="dark"
|
| 6 |
+
primaryColor="#1d64c8"
|
| 7 |
+
backgroundColor="#1d64c8"
|
| 8 |
+
secondaryBackgroundColor="#262730"
|
| 9 |
+
textColor="#ffffff"
|
| 10 |
+
font="sans serif"
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wrdler - Project Context
|
| 2 |
+
|
| 3 |
+
## Project Overview
|
| 4 |
+
Wrdler is a simplified vocabulary puzzle game based on BattleWords, with these key differences:
|
| 5 |
+
- **8x6 grid** (instead of 12x12)
|
| 6 |
+
- **One word per row, horizontal only** (no vertical words)
|
| 7 |
+
- **No scope/radar visualization**
|
| 8 |
+
- **2 free letter guesses at game start** (all instances of chosen letters are revealed)
|
| 9 |
+
|
| 10 |
+
**Current Version:** 0.0.1 (Initial Wrdler release)
|
| 11 |
+
**Repository:** https://github.com/Oncorporation/Wrdler.git
|
| 12 |
+
**Live Demo:** [DEPLOYMENT_URL_HERE]
|
| 13 |
+
|
| 14 |
+
## Recent Changes
|
| 15 |
+
|
| 16 |
+
**Latest (v0.0.1):**
|
| 17 |
+
- Project renamed from BattleWords to Wrdler
|
| 18 |
+
- Grid resized from 12x12 to 8x6
|
| 19 |
+
- Removed vertical word placement (horizontal only)
|
| 20 |
+
- Removed scope/radar visualization
|
| 21 |
+
- Added 2 free letter guesses at game start
|
| 22 |
+
- Version reset to 0.0.1
|
| 23 |
+
- All documentation updated to reflect Wrdler specifications
|
| 24 |
+
|
| 25 |
+
## Core Gameplay
|
| 26 |
+
- 8x6 grid with 6 hidden words (one per row, horizontal only)
|
| 27 |
+
- No scope/radar visualization
|
| 28 |
+
- Players start by choosing 2 letters; all instances are revealed
|
| 29 |
+
- Players click cells to reveal letters or empty spaces
|
| 30 |
+
- After revealing a letter, players can guess words
|
| 31 |
+
- Scoring: word length + bonus for unrevealed letters
|
| 32 |
+
- Game ends when all words are guessed or all word letters are revealed
|
| 33 |
+
- Incorrect guess history with optional display (enabled by default)
|
| 34 |
+
- 10 incorrect guess limit per game
|
| 35 |
+
- **✅ IMPLEMENTED:** Challenge Mode with game sharing via short URLs
|
| 36 |
+
- **✅ IMPLEMENTED:** Remote storage via Hugging Face datasets
|
| 37 |
+
- **✅ IMPLEMENTED:** PWA install support
|
| 38 |
+
- **PLANNED:** Local persistent storage for game results and high scores
|
| 39 |
+
|
| 40 |
+
### Scoring Tiers
|
| 41 |
+
- **Fantastic:** 42+ points
|
| 42 |
+
- **Great:** 38-41 points
|
| 43 |
+
- **Good:** 34-37 points
|
| 44 |
+
- **Keep practicing:** < 34 points
|
| 45 |
+
|
| 46 |
+
## Technical Architecture
|
| 47 |
+
|
| 48 |
+
### Technology Stack
|
| 49 |
+
- **Framework:** Streamlit 1.51.0
|
| 50 |
+
- **Language:** Python 3.12.8
|
| 51 |
+
- **Visualization:** Matplotlib, NumPy
|
| 52 |
+
- **Data Processing:** Pandas, Altair
|
| 53 |
+
- **Storage:** JSON-based local persistence
|
| 54 |
+
- **Testing:** Pytest
|
| 55 |
+
- **Package Manager:** UV
|
| 56 |
+
|
| 57 |
+
### Project Structure
|
| 58 |
+
```
|
| 59 |
+
wrdler/
|
| 60 |
+
├── app.py # Streamlit entry point
|
| 61 |
+
├── wrdler/ # Main package
|
| 62 |
+
│ ├── __init__.py # Version: 0.0.1
|
| 63 |
+
│ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
|
| 64 |
+
│ ├── generator.py # Puzzle generation with deterministic seeding
|
| 65 |
+
│ ├── logic.py # Game mechanics (reveal, guess, scoring)
|
| 66 |
+
│ ├── ui.py # Streamlit UI
|
| 67 |
+
│ ├── word_loader.py # Word list management
|
| 68 |
+
│ ├── audio.py # Background music system
|
| 69 |
+
│ ├── sounds.py # Sound effects management
|
| 70 |
+
│ ├── generate_sounds.py # Sound generation utilities
|
| 71 |
+
│ ├── game_storage.py # HF game storage wrapper
|
| 72 |
+
│ ├── version_info.py # Version display
|
| 73 |
+
│ ├── modules/ # Shared utility modules (from OpenBadge)
|
| 74 |
+
│ │ ├── __init__.py # Module exports
|
| 75 |
+
│ │ ├── storage.py # HuggingFace storage & URL shortener
|
| 76 |
+
│ │ ├── storage.md # Storage module documentation
|
| 77 |
+
│ │ ├── constants.py # Storage-related constants (trimmed)
|
| 78 |
+
│ │ └── file_utils.py # File utility functions
|
| 79 |
+
│ └── words/ # Word list files
|
| 80 |
+
│ ├── classic.txt # Default word list
|
| 81 |
+
│ ├── fourth_grade.txt # Elementary word list
|
| 82 |
+
│ └── wordlist.txt # Full word list
|
| 83 |
+
├── tests/ # Unit tests
|
| 84 |
+
├── specs/ # Documentation
|
| 85 |
+
│ ├── specs.md # Game specifications
|
| 86 |
+
│ ├── requirements.md # Implementation requirements
|
| 87 |
+
│ └── history.md # Game history
|
| 88 |
+
├── .env # Environment variables
|
| 89 |
+
├── pyproject.toml # Project metadata
|
| 90 |
+
├── requirements.txt # Dependencies
|
| 91 |
+
├── uv.lock # UV lock file
|
| 92 |
+
├── Dockerfile # Container deployment
|
| 93 |
+
└── CLAUDE.md # This file - project context for Claude
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## Key Features
|
| 97 |
+
|
| 98 |
+
### Game Modes
|
| 99 |
+
1. **Classic Mode:** Allows consecutive guessing after correct answers
|
| 100 |
+
2. **Too Easy Mode:** Single guess per reveal
|
| 101 |
+
|
| 102 |
+
### Audio & Visual Effects
|
| 103 |
+
- **Background Music:** Toggleable ocean-themed background music with volume control
|
| 104 |
+
- **Sound Effects:** Hit/miss/correct/incorrect guess sounds with volume control
|
| 105 |
+
- **Animated Radar:** Pulsing rings showing word boundaries (last letter locations)
|
| 106 |
+
- **Ocean Theme:** Gradient animated background with wave effects
|
| 107 |
+
- **Incorrect Guess History:** Visual display of wrong guesses (toggleable in settings)
|
| 108 |
+
|
| 109 |
+
### ✅ Challenge Mode & Remote Storage (v0.2.20+)
|
| 110 |
+
- **Game ID System:** Short URL-based challenge sharing
|
| 111 |
+
- Format: `?game_id=<sid>` in URL (shortened URL reference)
|
| 112 |
+
- Each player gets different random words from the same wordlist
|
| 113 |
+
- Enables fair challenges between players
|
| 114 |
+
- Stored in Hugging Face dataset repository
|
| 115 |
+
- **Remote Storage via HuggingFace Hub:**
|
| 116 |
+
- Per-game settings JSON in `games/{uid}/settings.json`
|
| 117 |
+
- Shortened URL mapping in `shortener.json`
|
| 118 |
+
- Multi-user leaderboards with score, time, and difficulty tracking
|
| 119 |
+
- Results sorted by: highest score → fastest time → highest difficulty
|
| 120 |
+
- **Challenge Features:**
|
| 121 |
+
- Submit results to existing challenges
|
| 122 |
+
- Create new challenges from any completed game
|
| 123 |
+
- Top 5 leaderboard display in Challenge Mode banner
|
| 124 |
+
- Optional player names (defaults to "Anonymous")
|
| 125 |
+
- Word list difficulty calculation and display
|
| 126 |
+
- "Show Challenge Share Links" toggle (default OFF) to control URL visibility
|
| 127 |
+
|
| 128 |
+
### PLANNED: Local Player Storage (v0.3.0)
|
| 129 |
+
- **Local Storage:**
|
| 130 |
+
- Location: `~/.wrdler/data/`
|
| 131 |
+
- Files: `game_results.json`, `highscores.json`
|
| 132 |
+
- Privacy-first: no cloud dependency, offline-capable
|
| 133 |
+
- **Personal High Scores:**
|
| 134 |
+
- Top 100 scores tracked automatically on local machine
|
| 135 |
+
- Filterable by wordlist and game mode
|
| 136 |
+
- High score sidebar expander display
|
| 137 |
+
- **Player Statistics:**
|
| 138 |
+
- Games played, average score, best score
|
| 139 |
+
- Fastest completion time
|
| 140 |
+
- Per-player history on local device
|
| 141 |
+
|
| 142 |
+
### Puzzle Generation
|
| 143 |
+
- Deterministic seeding support for reproducible puzzles
|
| 144 |
+
- Configurable word spacing (spacer: 0-2)
|
| 145 |
+
- 0: Words may touch
|
| 146 |
+
- 1: At least 1 blank cell between words (default)
|
| 147 |
+
- 2: At least 2 blank cells between words
|
| 148 |
+
- Validation ensures no overlaps, proper bounds, correct word distribution
|
| 149 |
+
|
| 150 |
+
### UI Components (Current)
|
| 151 |
+
- **Game Grid:** Interactive 8x6 button grid with responsive layout
|
| 152 |
+
- **Score Panel:** Real-time scoring with client-side JavaScript timer
|
| 153 |
+
- **Settings Sidebar:**
|
| 154 |
+
- Word list picker (classic, fourth_grade, wordlist)
|
| 155 |
+
- Game mode selector
|
| 156 |
+
- Word spacing configuration (0-2)
|
| 157 |
+
- Audio volume controls (music and effects separate)
|
| 158 |
+
- Toggle for incorrect guess history display
|
| 159 |
+
- **Theme System:** Ocean gradient background with CSS animations
|
| 160 |
+
- **Game Over Dialog:** Final score display with tier ranking
|
| 161 |
+
- **Incorrect Guess Display:** Shows history of wrong guesses with count
|
| 162 |
+
- **✅ Challenge Mode UI (v0.2.20+):**
|
| 163 |
+
- Challenge Mode banner with leaderboard (top 5 players)
|
| 164 |
+
- Share challenge button in game over dialog
|
| 165 |
+
- Submit result or create new challenge options
|
| 166 |
+
- Word list difficulty display
|
| 167 |
+
- Conditional share URL visibility toggle
|
| 168 |
+
- **PLANNED (v0.3.0):** Local high scores expander in sidebar
|
| 169 |
+
- **PLANNED (v0.3.0):** Personal statistics display
|
| 170 |
+
|
| 171 |
+
### Recent Changes & Branch Status
|
| 172 |
+
**Branch:** cc-01 (Storage and sharing features - v0.3.0 development)
|
| 173 |
+
|
| 174 |
+
**Latest (v0.2.17):**
|
| 175 |
+
- Documentation updates and corrections
|
| 176 |
+
- Updated CLAUDE.md with accurate feature status
|
| 177 |
+
- Clarified v0.3.0 planned features vs current implementation
|
| 178 |
+
- Added comprehensive project structure details
|
| 179 |
+
- Improved version tracking and roadmap clarity
|
| 180 |
+
|
| 181 |
+
**Previously Fixed (v0.2.16):**
|
| 182 |
+
- Replace question marks with underscores in score panel
|
| 183 |
+
- Add toggle for incorrect guess history display (enabled by default)
|
| 184 |
+
- Game over popup positioning improvements
|
| 185 |
+
- Music playback after game end
|
| 186 |
+
- Sound effect and music volume issues
|
| 187 |
+
- Radar alignment inconsistencies
|
| 188 |
+
- Added `fig.subplots_adjust(left=0, right=0.9, top=0.9, bottom=0)`
|
| 189 |
+
- Set `fig.patch.set_alpha(0.0)` for transparent background
|
| 190 |
+
- Maintains 2% margin for tick visibility while ensuring consistent layer alignment
|
| 191 |
+
|
| 192 |
+
**Completed (v0.2.20-0.2.27 - Challenge Mode):**
|
| 193 |
+
- ✅ Imported storage modules from OpenBadge project:
|
| 194 |
+
- `wrdler/modules/storage.py` (v0.1.5) - HuggingFace storage & URL shortener
|
| 195 |
+
- `wrdler/modules/constants.py` (trimmed) - Storage-related constants
|
| 196 |
+
- `wrdler/modules/file_utils.py` - File utility functions
|
| 197 |
+
- `wrdler/modules/storage.md` - Documentation
|
| 198 |
+
- ✅ Created `wrdler/game_storage.py` (v0.1.0) - Wrdler storage wrapper:
|
| 199 |
+
- `save_game_to_hf()` - Save game to HF repo and generate short URL
|
| 200 |
+
- `load_game_from_sid()` - Load game from short ID
|
| 201 |
+
- `generate_uid()` - Generate unique game identifiers
|
| 202 |
+
- `serialize_game_settings()` - Convert game data to JSON
|
| 203 |
+
- `get_shareable_url()` - Generate shareable URLs
|
| 204 |
+
- `add_user_result_to_game()` - Append results to existing challenges
|
| 205 |
+
- ✅ UI integration complete (`wrdler/ui.py`):
|
| 206 |
+
- Query parameter parsing for `?game_id=<sid>` on app load
|
| 207 |
+
- Load shared game settings into session state
|
| 208 |
+
- Challenge Mode banner with leaderboard (top 5)
|
| 209 |
+
- Share button in game over dialog with "Generate Share Link" or "Submit Result"
|
| 210 |
+
- Conditional share URL display based on settings toggle
|
| 211 |
+
- Automatic save to HuggingFace on game completion
|
| 212 |
+
- Word list difficulty calculation and display
|
| 213 |
+
- ✅ Generator updates (`wrdler/generator.py`):
|
| 214 |
+
- Added `target_words` parameter for loading specific words
|
| 215 |
+
- Added `may_overlap` parameter (for future crossword mode)
|
| 216 |
+
- Support for shared game replay with randomized word positions
|
| 217 |
+
|
| 218 |
+
**In Progress (v0.3.0 - Local Player History):**
|
| 219 |
+
- ⏳ Local storage module (`wrdler/local_storage.py`)
|
| 220 |
+
- ⏳ Personal high score tracking (local JSON files)
|
| 221 |
+
- ⏳ High score sidebar UI display
|
| 222 |
+
- ⏳ Player statistics tracking and display
|
| 223 |
+
|
| 224 |
+
## Data Models
|
| 225 |
+
|
| 226 |
+
### Core Classes
|
| 227 |
+
```python
|
| 228 |
+
@dataclass
|
| 229 |
+
class Coord:
|
| 230 |
+
x: int # row, 0-based
|
| 231 |
+
y: int # col, 0-based
|
| 232 |
+
|
| 233 |
+
@dataclass
|
| 234 |
+
class Word:
|
| 235 |
+
text: str
|
| 236 |
+
start: Coord
|
| 237 |
+
direction: Direction # "H" or "V"
|
| 238 |
+
cells: List[Coord]
|
| 239 |
+
|
| 240 |
+
@dataclass
|
| 241 |
+
class Puzzle:
|
| 242 |
+
words: List[Word]
|
| 243 |
+
radar: List[Coord]
|
| 244 |
+
may_overlap: bool
|
| 245 |
+
spacer: int
|
| 246 |
+
uid: str # Unique identifier for caching
|
| 247 |
+
|
| 248 |
+
@dataclass
|
| 249 |
+
class GameState:
|
| 250 |
+
grid_size: int
|
| 251 |
+
puzzle: Puzzle
|
| 252 |
+
revealed: Set[Coord]
|
| 253 |
+
guessed: Set[str]
|
| 254 |
+
score: int
|
| 255 |
+
last_action: str
|
| 256 |
+
can_guess: bool
|
| 257 |
+
game_mode: str
|
| 258 |
+
points_by_word: Dict[str, int]
|
| 259 |
+
start_time: Optional[datetime]
|
| 260 |
+
end_time: Optional[datetime]
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
## Development Workflow
|
| 264 |
+
|
| 265 |
+
### Running Locally
|
| 266 |
+
```bash
|
| 267 |
+
# Install dependencies
|
| 268 |
+
uv pip install -r requirements.txt --link-mode=copy
|
| 269 |
+
|
| 270 |
+
# Run app
|
| 271 |
+
uv run streamlit run app.py
|
| 272 |
+
# or
|
| 273 |
+
streamlit run app.py
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### Docker Deployment
|
| 277 |
+
```bash
|
| 278 |
+
docker build -t wrdler .
|
| 279 |
+
docker run -p 8501:8501 wrdler
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
### Testing
|
| 283 |
+
```bash
|
| 284 |
+
pytest tests/
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
### Environment Variables (for Challenge Mode)
|
| 288 |
+
Challenge Mode requires HuggingFace Hub access for remote storage. Create a `.env` file in the project root:
|
| 289 |
+
|
| 290 |
+
```bash
|
| 291 |
+
# Required for Challenge Mode
|
| 292 |
+
HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN
|
| 293 |
+
HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo
|
| 294 |
+
SPACE_NAME=YourUsername/Wrdler # Your HF Space name
|
| 295 |
+
|
| 296 |
+
# Optional
|
| 297 |
+
CRYPTO_PK= # Reserved for future signing
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
**How to get your HF_API_TOKEN:**
|
| 301 |
+
1. Go to https://huggingface.co/settings/tokens
|
| 302 |
+
2. Create a new token with `write` access
|
| 303 |
+
3. Add to `.env` file as `HF_API_TOKEN=hf_...`
|
| 304 |
+
|
| 305 |
+
**HF_REPO_ID Structure:**
|
| 306 |
+
The dataset repository will contain:
|
| 307 |
+
- `shortener.json` - Short URL mappings
|
| 308 |
+
- `games/{uid}/settings.json` - Per-game challenge data
|
| 309 |
+
- `games/{uid}/result.json` - Optional detailed results
|
| 310 |
+
|
| 311 |
+
**Note:** The app will work without these variables but Challenge Mode features (sharing, leaderboards) will be disabled.
|
| 312 |
+
|
| 313 |
+
## Git Configuration & Deployment
|
| 314 |
+
**Current Branch:** main (or development branch)
|
| 315 |
+
**Purpose:** Wrdler - vocabulary puzzle game with simplified 8x6 grid
|
| 316 |
+
**Main Branch:** main
|
| 317 |
+
|
| 318 |
+
### Remotes
|
| 319 |
+
- **ONCORP (origin):** https://github.com/Oncorporation/Wrdler.git (main repository)
|
| 320 |
+
- **Hugging:** https://huggingface.co/spaces/[USERNAME]/Wrdler (live deployment)
|
| 321 |
+
|
| 322 |
+
## Known Issues
|
| 323 |
+
- Word list loading bug: App may not select proper word lists in some environments
|
| 324 |
+
- Investigation needed in `word_loader.get_wordlist_files()` and `load_word_list()`
|
| 325 |
+
- Sidebar selection persistence needs verification
|
| 326 |
+
|
| 327 |
+
## v0.0.1 Development Status
|
| 328 |
+
|
| 329 |
+
### Completed ✅
|
| 330 |
+
- Project renamed from BattleWords to Wrdler
|
| 331 |
+
- Grid resized from 12x12 to 8x6
|
| 332 |
+
- Removed vertical word placement (horizontal only)
|
| 333 |
+
- Removed scope/radar visualization
|
| 334 |
+
- Added 2 free letter guesses at game start
|
| 335 |
+
- Updated version to 0.0.1
|
| 336 |
+
- Updated all documentation
|
| 337 |
+
|
| 338 |
+
### In Progress ⏳
|
| 339 |
+
- Generator updates for 8x6 grid and horizontal-only placement
|
| 340 |
+
- UI adjustments for new grid size and free letter guesses
|
| 341 |
+
- Testing with new gameplay mechanics
|
| 342 |
+
|
| 343 |
+
### Planned 📋
|
| 344 |
+
- Local persistent storage module
|
| 345 |
+
- High score tracking and display
|
| 346 |
+
- Player statistics
|
| 347 |
+
- Share results functionality
|
| 348 |
+
|
| 349 |
+
## Future Roadmap
|
| 350 |
+
|
| 351 |
+
### Phase 1.0 (v0.0.1) - Current ✅
|
| 352 |
+
- 8x6 grid with horizontal words only
|
| 353 |
+
- Free letter guesses at start
|
| 354 |
+
- Challenge Mode with remote storage
|
| 355 |
+
- PWA support
|
| 356 |
+
|
| 357 |
+
### Phase 2.0 (v0.1.0)
|
| 358 |
+
- Local persistent storage (backend complete)
|
| 359 |
+
- High score tracking and display
|
| 360 |
+
- Player statistics
|
| 361 |
+
|
| 362 |
+
### Phase 3.0 (v1.0.0)
|
| 363 |
+
- Enhanced UX and animations
|
| 364 |
+
- Multiple difficulty levels
|
| 365 |
+
- Daily puzzle mode
|
| 366 |
+
- Internationalization (i18n) support
|
| 367 |
+
|
| 368 |
+
## Deployment Targets
|
| 369 |
+
- **Hugging Face Spaces:** Primary deployment platform
|
| 370 |
+
- **Docker:** Containerized deployment for any platform
|
| 371 |
+
- **Local:** Development and testing
|
| 372 |
+
|
| 373 |
+
### Privacy & Data
|
| 374 |
+
- All storage is local (no telemetry)
|
| 375 |
+
- Player names optional
|
| 376 |
+
- No data leaves user's machine
|
| 377 |
+
- Easy to delete: just remove `~/.wrdler/data/`
|
| 378 |
+
|
| 379 |
+
## Notes for Claude
|
| 380 |
+
- Project uses modern Python features (3.12+)
|
| 381 |
+
- Heavy use of Streamlit session state for game state management
|
| 382 |
+
- Client-side JavaScript for timer updates without page refresh
|
| 383 |
+
- CSS heavily customized for game aesthetics
|
| 384 |
+
- All file paths should be absolute when working in WSL environment
|
| 385 |
+
- Storage features are backward-compatible (game works without storage)
|
| 386 |
+
- Game IDs are deterministic for consistent sharing
|
| 387 |
+
- JSON storage chosen for simplicity and privacy
|
| 388 |
+
- Generator needs updating to handle 8x6 grid and horizontal-only placement
|
| 389 |
+
- Radar/scope visualization removed entirely
|
| 390 |
+
|
| 391 |
+
### WSL Environment Python Versions
|
| 392 |
+
The development environment is WSL (Windows Subsystem for Linux) with access to both native Linux and Windows Python installations:
|
| 393 |
+
|
| 394 |
+
**Native WSL (Linux):**
|
| 395 |
+
- `python3` → Python 3.10.12 (`/usr/bin/python3`)
|
| 396 |
+
- `python3.10` → Python 3.10.12
|
| 397 |
+
|
| 398 |
+
**Windows Python (accessible via WSL):**
|
| 399 |
+
- `python311.exe` → Python 3.11.9 (`/mnt/c/Users/cfettinger/AppData/Local/Programs/Python/Python311/`)
|
| 400 |
+
- `python3.13.exe` → Python 3.13.1 (`/mnt/c/ProgramData/chocolatey/bin/`)
|
| 401 |
+
|
| 402 |
+
**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+.
|
| 403 |
+
|
| 404 |
+
## Documentation Structure
|
| 405 |
+
|
| 406 |
+
This file (CLAUDE.md) serves as a **living context document** for AI-assisted development. It complements the formal specification documents:
|
| 407 |
+
|
| 408 |
+
- **[specs/specs.md](specs/specs.md)** - Game rules, requirements, and feature specifications
|
| 409 |
+
- **[specs/requirements.md](specs/requirements.md)** - Implementation phases, acceptance criteria, and technical tasks
|
| 410 |
+
- **[README.md](README.md)** - User-facing documentation, installation guide, and changelog
|
| 411 |
+
|
| 412 |
+
**When to use each:**
|
| 413 |
+
- **specs.md** - Understanding game rules, scoring, and player experience
|
| 414 |
+
- **requirements.md** - Planning implementation work, tracking phases, and defining done criteria
|
| 415 |
+
- **CLAUDE.md** - Quick reference for codebase structure, recent changes, and development context
|
| 416 |
+
- **README.md** - Public-facing information, setup instructions, and feature announcements
|
| 417 |
+
|
| 418 |
+
**Synchronization:**
|
| 419 |
+
Changes to game mechanics should update specs.md → requirements.md → CLAUDE.md → README.md in that order
|
| 420 |
+
|
| 421 |
+
## Challenge Mode & Remote Storage
|
| 422 |
+
|
| 423 |
+
- The app supports a Challenge Mode where games can be shared via a short link (`?game_id=<sid>`).
|
| 424 |
+
- Results are stored in a Hugging Face dataset repo using `game_storage.py`.
|
| 425 |
+
- The leaderboard for a challenge is sorted by highest score (descending), then by fastest time (ascending).
|
| 426 |
+
- Each user result is appended to the challenge's `users` array in the remote JSON.
|
Dockerfile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12.8-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System dependencies required for runtime
|
| 6 |
+
# - curl for debugging
|
| 7 |
+
# - git if needed by pip
|
| 8 |
+
# - libfreetype6 and libpng16-16 required by matplotlib (Agg backend)
|
| 9 |
+
# - fonts-dejavu-core for font rendering
|
| 10 |
+
# - libglib2.0-0, libsm6, libxext6, libxrender1 are safe image libs some backends use
|
| 11 |
+
# - ca-certificates to avoid TLS issues during pip installs and at runtime
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential \
|
| 14 |
+
curl \
|
| 15 |
+
git \
|
| 16 |
+
ca-certificates \
|
| 17 |
+
libfreetype6 \
|
| 18 |
+
libpng16-16 \
|
| 19 |
+
fonts-dejavu-core \
|
| 20 |
+
libglib2.0-0 \
|
| 21 |
+
libsm6 \
|
| 22 |
+
libxext6 \
|
| 23 |
+
libxrender1 \
|
| 24 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 25 |
+
|
| 26 |
+
# Environment optimizations and Streamlit defaults
|
| 27 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 28 |
+
PYTHONUNBUFFERED=1 \
|
| 29 |
+
PIP_NO_CACHE_DIR=1 \
|
| 30 |
+
STREAMLIT_SERVER_HEADLESS=true \
|
| 31 |
+
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
|
| 32 |
+
MPLBACKEND=Agg \
|
| 33 |
+
MPLCONFIGDIR=/tmp/matplotlib
|
| 34 |
+
|
| 35 |
+
# Upgrade pip tooling to avoid build failures
|
| 36 |
+
RUN python -m pip install --upgrade pip setuptools wheel
|
| 37 |
+
|
| 38 |
+
# Install Python dependencies first (layer caching)
|
| 39 |
+
COPY requirements.txt ./
|
| 40 |
+
RUN pip3 install -r requirements.txt
|
| 41 |
+
|
| 42 |
+
# Copy PWA injection files
|
| 43 |
+
COPY pwa-head-inject.html ./pwa-head-inject.html
|
| 44 |
+
COPY inject-pwa-head.sh ./inject-pwa-head.sh
|
| 45 |
+
RUN chmod +x ./inject-pwa-head.sh && ./inject-pwa-head.sh
|
| 46 |
+
|
| 47 |
+
# Copy application source
|
| 48 |
+
COPY app.py ./app.py
|
| 49 |
+
COPY wrdler ./wrdler
|
| 50 |
+
COPY static ./static
|
| 51 |
+
|
| 52 |
+
# Hugging Face Spaces sets $PORT (default 7860). Expose it for clarity. using 8501 for local consistency with Streamlit defaults
|
| 53 |
+
|
| 54 |
+
EXPOSE 8501
|
| 55 |
+
|
| 56 |
+
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
|
| 57 |
+
|
| 58 |
+
# Rely on Spaces health checking; do not add Docker HEALTHCHECK to avoid premature failures
|
| 59 |
+
|
| 60 |
+
# Use shell form so $PORT expands at runtime
|
| 61 |
+
ENTRYPOINT ["sh", "-c", "streamlit run app.py --server.port=${PORT:-8501} --server.address=0.0.0.0"]
|
LOCALHOST_PWA_README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PWA on Localhost - Important Information
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
|
| 5 |
+
**The PWA files were created successfully**, but they **won't work fully on `localhost:8501`** due to Streamlit's static file serving limitations.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## What You're Seeing (or Not Seeing)
|
| 10 |
+
|
| 11 |
+
### ✅ What DOES Work on Localhost:
|
| 12 |
+
|
| 13 |
+
1. **Game functionality**: Everything works normally
|
| 14 |
+
2. **Challenge Mode**: Loading `?game_id=...` works (if HF credentials configured)
|
| 15 |
+
3. **PWA meta tags**: Injected into HTML (check page source)
|
| 16 |
+
4. **Service worker registration attempt**: Runs in browser console
|
| 17 |
+
|
| 18 |
+
### ❌ What DOESN'T Work on Localhost:
|
| 19 |
+
|
| 20 |
+
1. **`manifest.json` not accessible**:
|
| 21 |
+
```
|
| 22 |
+
http://localhost:8501/app/static/manifest.json
|
| 23 |
+
→ Returns HTML instead of JSON (Streamlit doesn't serve /app/static/)
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
2. **Icons not accessible**:
|
| 27 |
+
```
|
| 28 |
+
http://localhost:8501/app/static/icon-192.png
|
| 29 |
+
→ Returns 404 or HTML
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
3. **Service worker fails to register**:
|
| 33 |
+
```javascript
|
| 34 |
+
// Browser console shows:
|
| 35 |
+
Failed to register service worker: 404 Not Found
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
4. **No PWA install prompt**:
|
| 39 |
+
- No banner at bottom of screen
|
| 40 |
+
- No install icon in address bar
|
| 41 |
+
- PWA features disabled
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Why This Happens
|
| 46 |
+
|
| 47 |
+
**Streamlit's Static File Serving:**
|
| 48 |
+
|
| 49 |
+
- Streamlit only serves files from:
|
| 50 |
+
- `/.streamlit/static/` (internal Streamlit assets)
|
| 51 |
+
- Component assets via `declare_component()`
|
| 52 |
+
- NOT from arbitrary `battlewords/static/` directories
|
| 53 |
+
|
| 54 |
+
- On HuggingFace Spaces:
|
| 55 |
+
- `/app/static/` is mapped by HF infrastructure
|
| 56 |
+
- Files in `battlewords/static/` are accessible at `/app/static/`
|
| 57 |
+
- ✅ PWA works perfectly
|
| 58 |
+
|
| 59 |
+
- On localhost:
|
| 60 |
+
- No `/app/static/` mapping exists
|
| 61 |
+
- Streamlit returns HTML for all unrecognized paths
|
| 62 |
+
- ❌ PWA files return 404
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## How to Test PWA Locally
|
| 67 |
+
|
| 68 |
+
### Option 1: Use ngrok (HTTPS Tunnel) ⭐ **RECOMMENDED**
|
| 69 |
+
|
| 70 |
+
This is the **best way** to test PWA locally with full functionality:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Terminal 1: Run Streamlit
|
| 74 |
+
streamlit run app.py
|
| 75 |
+
|
| 76 |
+
# Terminal 2: Expose with HTTPS
|
| 77 |
+
ngrok http 8501
|
| 78 |
+
|
| 79 |
+
# Output shows:
|
| 80 |
+
# Forwarding https://abc123.ngrok-free.app -> http://localhost:8501
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
**Then visit the HTTPS URL on your phone or desktop:**
|
| 84 |
+
- ✅ Full PWA functionality
|
| 85 |
+
- ✅ Install prompt appears
|
| 86 |
+
- ✅ manifest.json loads
|
| 87 |
+
- ✅ Service worker registers
|
| 88 |
+
- ✅ Icons display correctly
|
| 89 |
+
|
| 90 |
+
**ngrok Setup:**
|
| 91 |
+
1. Download: https://ngrok.com/download
|
| 92 |
+
2. Sign up for free account
|
| 93 |
+
3. Install: `unzip /path/to/ngrok.zip` (or chocolatey on Windows: `choco install ngrok`)
|
| 94 |
+
4. Authenticate: `ngrok config add-authtoken <your-token>`
|
| 95 |
+
5. Run: `ngrok http 8501`
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
### Option 2: Deploy to HuggingFace Spaces ⭐ **PRODUCTION**
|
| 100 |
+
|
| 101 |
+
PWA works out-of-the-box on HF Spaces:
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
git add wrdler/static/ wrdler/ui.py
|
| 105 |
+
git commit -m "Add PWA support"
|
| 106 |
+
git push
|
| 107 |
+
|
| 108 |
+
# HF Spaces auto-deploys
|
| 109 |
+
# Visit: https://[YourUsername]-wrdler.hf.space
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
**Then test PWA:**
|
| 113 |
+
- Android Chrome: "Add to Home Screen" prompt appears
|
| 114 |
+
- iOS Safari: Share → "Add to Home Screen"
|
| 115 |
+
- Desktop Chrome: Install icon in address bar
|
| 116 |
+
|
| 117 |
+
✅ **This is where PWA is meant to work!**
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
###Option 3: Manual Static File Server (Advanced)
|
| 122 |
+
|
| 123 |
+
You can serve the static files separately:
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
# Terminal 1: Run Streamlit
|
| 127 |
+
streamlit run app.py
|
| 128 |
+
|
| 129 |
+
# Terminal 2: Serve static files
|
| 130 |
+
cd wrdler/static
|
| 131 |
+
python3 -m http.server 8502
|
| 132 |
+
|
| 133 |
+
# Then access:
|
| 134 |
+
# Streamlit: http://localhost:8501
|
| 135 |
+
# Static files: http://localhost:8502/manifest.json
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
**Then modify the PWA paths in `ui.py`:**
|
| 139 |
+
```python
|
| 140 |
+
pwa_meta_tags = """
|
| 141 |
+
<link rel="manifest" href="http://localhost:8502/manifest.json">
|
| 142 |
+
<link rel="apple-touch-icon" href="http://localhost:8502/icon-192.png">
|
| 143 |
+
<!-- etc -->
|
| 144 |
+
"""
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
❌ **Not recommended**: Too complex, defeats the purpose
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## What About Challenge Mode?
|
| 152 |
+
|
| 153 |
+
**Question:** "I loaded `localhost:8501/?game_id=hDjsB_dl` but don't see anything"
|
| 154 |
+
|
| 155 |
+
**Answer:** Challenge Mode is **separate from PWA**. You should see a blue banner at the top if:
|
| 156 |
+
|
| 157 |
+
### ✅ Requirements for Challenge Mode to Work:
|
| 158 |
+
|
| 159 |
+
1. **Environment variables configured** (`.env` file):
|
| 160 |
+
```bash
|
| 161 |
+
HF_API_TOKEN=hf_xxxxxxxxxxxxx
|
| 162 |
+
HF_REPO_ID=Surn/Storage
|
| 163 |
+
SPACE_NAME=Surn/BattleWords
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
2. **Valid game_id exists** in the HF repo:
|
| 167 |
+
- `hDjsB_dl` must be a real challenge created previously
|
| 168 |
+
- Check HuggingFace dataset repo: https://huggingface.co/datasets/Surn/Storage
|
| 169 |
+
- Look for: `games/<uid>/settings.json`
|
| 170 |
+
- Verify `shortener.json` has entry for `hDjsB_dl`
|
| 171 |
+
|
| 172 |
+
3. **Internet connection** (to fetch challenge data)
|
| 173 |
+
|
| 174 |
+
### If Challenge Mode ISN'T Working:
|
| 175 |
+
|
| 176 |
+
**Check browser console (F12 → Console):**
|
| 177 |
+
```javascript
|
| 178 |
+
// Look for errors:
|
| 179 |
+
"[game_storage] Could not resolve sid: hDjsB_dl" ← Challenge not found
|
| 180 |
+
"Failed to load game from sid" ← HF API error
|
| 181 |
+
"HF_API_TOKEN not configured" ← Missing credentials
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
**If you see errors:**
|
| 185 |
+
1. Verify `.env` file exists with correct variables
|
| 186 |
+
2. Restart Streamlit (`Ctrl+C` and `streamlit run app.py` again)
|
| 187 |
+
3. Try a different `game_id` from a known challenge
|
| 188 |
+
4. Check HF repo has the challenge data
|
| 189 |
+
|
| 190 |
+
**Note:** Challenge Mode works the same in Wrdler as it did in BattleWords.
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## Summary Table
|
| 195 |
+
|
| 196 |
+
| Feature | Localhost | Localhost + ngrok | HF Spaces (Production) |
|
| 197 |
+
|---------|-----------|-------------------|------------------------|
|
| 198 |
+
| **Game works** | ✅ | ✅ | ✅ |
|
| 199 |
+
| **Challenge Mode** | ✅ (if .env configured) | ✅ | ✅ |
|
| 200 |
+
| **PWA manifest loads** | ❌ | ✅ | ✅ |
|
| 201 |
+
| **Service worker registers** | ❌ | ✅ | ✅ |
|
| 202 |
+
| **Install prompt** | ❌ | ✅ | ✅ |
|
| 203 |
+
| **Icons display** | ❌ | ✅ | ✅ |
|
| 204 |
+
| **Full-screen mode** | ❌ | ✅ | ✅ |
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## What You Should Do
|
| 209 |
+
|
| 210 |
+
### For Development:
|
| 211 |
+
✅ **Just develop normally on localhost**
|
| 212 |
+
- Game features work fine
|
| 213 |
+
- Challenge Mode works (if .env configured)
|
| 214 |
+
- PWA features won't work, but that's okay
|
| 215 |
+
- Test PWA when you deploy
|
| 216 |
+
|
| 217 |
+
### For PWA Testing:
|
| 218 |
+
✅ **Use ngrok for quick local PWA testing**
|
| 219 |
+
- 5 minutes to setup
|
| 220 |
+
- Full PWA functionality
|
| 221 |
+
- Test on real phone
|
| 222 |
+
|
| 223 |
+
### For Production:
|
| 224 |
+
✅ **Deploy to HuggingFace Spaces**
|
| 225 |
+
- PWA works automatically
|
| 226 |
+
- No configuration needed
|
| 227 |
+
- `/app/static/` path works out-of-the-box
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## Bottom Line
|
| 232 |
+
|
| 233 |
+
**Your question:** "Should I see something at the bottom of the screen?"
|
| 234 |
+
|
| 235 |
+
**Answer:**
|
| 236 |
+
|
| 237 |
+
1. **PWA install prompt**: ❌ Not on `localhost:8501` (Streamlit limitation)
|
| 238 |
+
- **Will work** on HF Spaces production deployment ✅
|
| 239 |
+
- **Will work** with ngrok HTTPS tunnel ✅
|
| 240 |
+
|
| 241 |
+
2. **Challenge Mode banner**: ✅ Should appear at TOP (not bottom)
|
| 242 |
+
- Check if `?game_id=hDjsB_dl` exists in your HF repo
|
| 243 |
+
- Check browser console for errors
|
| 244 |
+
- Verify `.env` has `HF_API_TOKEN` configured
|
| 245 |
+
|
| 246 |
+
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!
|
| 247 |
+
|
| 248 |
+
---
|
| 249 |
+
|
| 250 |
+
## Quick Test Command
|
| 251 |
+
|
| 252 |
+
```bash
|
| 253 |
+
# Check if .env is configured:
|
| 254 |
+
cat .env | grep HF_
|
| 255 |
+
|
| 256 |
+
# Should show:
|
| 257 |
+
# HF_API_TOKEN=hf_xxxxx
|
| 258 |
+
# HF_REPO_ID=YourUsername/Storage
|
| 259 |
+
# SPACE_NAME=YourUsername/Wrdler
|
| 260 |
+
|
| 261 |
+
# If missing, Challenge Mode won't work locally
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
**Next Steps:**
|
| 267 |
+
1. Test game functionality on localhost ✅
|
| 268 |
+
2. Deploy to HF Spaces for PWA testing ✅
|
| 269 |
+
3. Or install ngrok for local PWA testing ✅
|
MANIFEST.in
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
recursive-include wrdler/words *.txt
|
PWA_INSTALL_GUIDE.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wrdler PWA Installation Guide
|
| 2 |
+
|
| 3 |
+
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!
|
| 4 |
+
|
| 5 |
+
## What is a PWA?
|
| 6 |
+
|
| 7 |
+
A Progressive Web App allows you to:
|
| 8 |
+
- ✅ Install Wrdler on your home screen (Android/iOS)
|
| 9 |
+
- ✅ Run in full-screen mode without browser UI
|
| 10 |
+
- ✅ Access the app quickly from your app drawer
|
| 11 |
+
- ✅ Get automatic updates (always the latest version)
|
| 12 |
+
- ✅ Basic offline functionality (cached assets)
|
| 13 |
+
|
| 14 |
+
## Installation Instructions
|
| 15 |
+
|
| 16 |
+
### Android (Chrome, Edge, Samsung Internet)
|
| 17 |
+
|
| 18 |
+
1. **Visit the app**: Open https://[YourUsername]-wrdler.hf.space in Chrome
|
| 19 |
+
2. **Look for the install prompt**: A banner will appear at the bottom saying "Add Wrdler to Home screen"
|
| 20 |
+
3. **Tap "Add"** or **"Install"**
|
| 21 |
+
4. **Alternative method** (if no prompt):
|
| 22 |
+
- Tap the **three-dot menu** (⋮) in the top-right
|
| 23 |
+
- Select **"Install app"** or **"Add to Home screen"**
|
| 24 |
+
- Tap **"Install"**
|
| 25 |
+
5. **Launch**: Find the Wrdler icon on your home screen and tap to open!
|
| 26 |
+
|
| 27 |
+
**Result**: The app opens full-screen without the browser address bar, just like a native app.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
### iOS (Safari)
|
| 32 |
+
|
| 33 |
+
**Note**: iOS requires using Safari browser (Chrome/Firefox won't work for PWA installation)
|
| 34 |
+
|
| 35 |
+
1. **Visit the app**: Open https://[YourUsername]-wrdler.hf.space in Safari
|
| 36 |
+
2. **Tap the Share button**: The square with an arrow pointing up (at the bottom of the screen)
|
| 37 |
+
3. **Scroll down** and tap **"Add to Home Screen"**
|
| 38 |
+
4. **Edit the name** (optional): You can rename it from "Wrdler" if desired
|
| 39 |
+
5. **Tap "Add"** in the top-right corner
|
| 40 |
+
6. **Launch**: Find the Wrdler icon on your home screen and tap to open!
|
| 41 |
+
|
| 42 |
+
**Result**: The app opens in standalone mode, similar to a native iOS app.
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
### Desktop (Chrome, Edge, Brave)
|
| 47 |
+
|
| 48 |
+
1. **Visit the app**: Open https://[YourUsername]-wrdler.hf.space
|
| 49 |
+
2. **Look for the install icon**:
|
| 50 |
+
- Chrome/Edge: Click the **install icon** (⊕) in the address bar
|
| 51 |
+
- Or click the **three-dot menu** → **"Install Wrdler"**
|
| 52 |
+
3. **Click "Install"** in the confirmation dialog
|
| 53 |
+
4. **Launch**:
|
| 54 |
+
- Windows: Find Wrdler in Start Menu or Desktop
|
| 55 |
+
- Mac: Find Wrdler in Applications folder
|
| 56 |
+
- Linux: Find in application launcher
|
| 57 |
+
|
| 58 |
+
**Result**: Wrdler opens in its own window, separate from your browser.
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## Features of the PWA
|
| 63 |
+
|
| 64 |
+
### Works Immediately ✅
|
| 65 |
+
- Full game functionality (reveal cells, guess words, scoring)
|
| 66 |
+
- Challenge Mode (create and play shared challenges)
|
| 67 |
+
- Sound effects and background music
|
| 68 |
+
- Ocean-themed animated background
|
| 69 |
+
- All current features preserved
|
| 70 |
+
|
| 71 |
+
### Offline Support 🌐
|
| 72 |
+
- App shell cached for faster loading
|
| 73 |
+
- Icons and static assets available offline
|
| 74 |
+
- **Note**: Challenge Mode requires internet connection (needs to fetch/save from HuggingFace)
|
| 75 |
+
|
| 76 |
+
### Updates 🔄
|
| 77 |
+
- Automatic updates when you open the app
|
| 78 |
+
- Always get the latest features and bug fixes
|
| 79 |
+
- No manual update process needed
|
| 80 |
+
|
| 81 |
+
### Privacy & Security 🔒
|
| 82 |
+
- No new data collection (same as web version)
|
| 83 |
+
- Environment variables stay on server (never exposed to PWA)
|
| 84 |
+
- Service worker only caches public assets
|
| 85 |
+
- All game data in Challenge Mode handled server-side
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## Uninstalling the PWA
|
| 90 |
+
|
| 91 |
+
### Android
|
| 92 |
+
1. Long-press the Wrdler icon
|
| 93 |
+
2. Tap "Uninstall" or drag to "Remove"
|
| 94 |
+
|
| 95 |
+
### iOS
|
| 96 |
+
1. Long-press the Wrdler icon
|
| 97 |
+
2. Tap "Remove App"
|
| 98 |
+
3. Confirm "Delete App"
|
| 99 |
+
|
| 100 |
+
### Desktop
|
| 101 |
+
- **Chrome/Edge**: Go to `chrome://apps` or `edge://apps`, right-click Wrdler, select "Uninstall"
|
| 102 |
+
- **Windows**: Settings → Apps → Wrdler → Uninstall
|
| 103 |
+
- **Mac**: Delete from Applications folder
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Troubleshooting
|
| 108 |
+
|
| 109 |
+
### "Install" option doesn't appear
|
| 110 |
+
- **Android**: Make sure you're using Chrome, Edge, or Samsung Internet (not Firefox)
|
| 111 |
+
- **iOS**: Must use Safari browser
|
| 112 |
+
- **Desktop**: Check if you're using a supported browser (Chrome, Edge, Brave)
|
| 113 |
+
- Try refreshing the page (the install prompt may take a moment to appear)
|
| 114 |
+
|
| 115 |
+
### App won't open after installation
|
| 116 |
+
- Try uninstalling and reinstalling
|
| 117 |
+
- Clear browser cache and try again
|
| 118 |
+
- Make sure you have internet connection for first launch
|
| 119 |
+
|
| 120 |
+
### Service worker errors in console
|
| 121 |
+
- This is normal during development
|
| 122 |
+
- The app will still function without the service worker
|
| 123 |
+
- Full offline support requires the service worker to register successfully
|
| 124 |
+
|
| 125 |
+
### Icons don't show up correctly
|
| 126 |
+
- Wait a moment after installation (icons may take time to download)
|
| 127 |
+
- Try force-refreshing the PWA (close and reopen)
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## Technical Details
|
| 132 |
+
|
| 133 |
+
### Files Added for PWA Support
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
wrdler/
|
| 137 |
+
├── static/
|
| 138 |
+
│ ├── manifest.json # PWA configuration
|
| 139 |
+
│ ├── service-worker.js # Offline caching logic
|
| 140 |
+
│ ├── icon-192.png # App icon (small)
|
| 141 |
+
│ └── icon-512.png # App icon (large)
|
| 142 |
+
└── ui.py # Added PWA meta tags
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### What's Cached Offline
|
| 146 |
+
|
| 147 |
+
- App shell (HTML structure)
|
| 148 |
+
- Icons (192x192, 512x512)
|
| 149 |
+
- Manifest file
|
| 150 |
+
- Previous game states (if you were playing before going offline)
|
| 151 |
+
|
| 152 |
+
### What Requires Internet
|
| 153 |
+
|
| 154 |
+
- Creating new challenges
|
| 155 |
+
- Submitting results to leaderboards
|
| 156 |
+
- Loading shared challenges
|
| 157 |
+
- Downloading word lists (first time)
|
| 158 |
+
- Fetching game updates
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Comparison: PWA vs Native App
|
| 163 |
+
|
| 164 |
+
| Feature | PWA | Native App |
|
| 165 |
+
|---------|-----|------------|
|
| 166 |
+
| Installation | Quick (1 tap) | Slow (app store) |
|
| 167 |
+
| Size | ~5-10 MB | ~15-30 MB |
|
| 168 |
+
| Updates | Automatic | Manual |
|
| 169 |
+
| Platform support | Android, iOS, Desktop | Separate builds |
|
| 170 |
+
| Offline mode | Partial | Full |
|
| 171 |
+
| Performance | 90% of native | 100% |
|
| 172 |
+
| App store presence | No | Yes |
|
| 173 |
+
| Development time | 2-4 hours ✅ | 40-60 hours per platform |
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## Feedback
|
| 178 |
+
|
| 179 |
+
If you encounter issues installing or using the PWA, please:
|
| 180 |
+
1. Check the browser console for errors (F12 → Console tab)
|
| 181 |
+
2. Report issues at: https://github.com/Oncorporation/Wrdler/issues
|
| 182 |
+
3. Include: Device type, OS version, browser version, and error messages
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
|
| 186 |
+
## For Developers
|
| 187 |
+
|
| 188 |
+
To regenerate the PWA icons:
|
| 189 |
+
```bash
|
| 190 |
+
python3 generate_pwa_icons.py
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
To modify PWA behavior:
|
| 194 |
+
- Edit `wrdler/static/manifest.json` (app metadata)
|
| 195 |
+
- Edit `wrdler/static/service-worker.js` (caching logic)
|
| 196 |
+
- Edit `wrdler/ui.py` (PWA meta tags, lines 34-86)
|
| 197 |
+
|
| 198 |
+
To test PWA locally:
|
| 199 |
+
```bash
|
| 200 |
+
streamlit run app.py
|
| 201 |
+
# Open http://localhost:8501 in Chrome
|
| 202 |
+
# Chrome DevTools → Application → Manifest (verify manifest.json loads)
|
| 203 |
+
# Chrome DevTools → Application → Service Workers (verify registration)
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
**Enjoy Wrdler as a native-like app experience! 🎮🌊**
|
README.md
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Wrdler
|
| 3 |
+
emoji: 🎲
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.51.0
|
| 8 |
+
python_version: 3.12.8
|
| 9 |
+
app_port: 8501
|
| 10 |
+
app_file: app.py
|
| 11 |
+
tags:
|
| 12 |
+
- game
|
| 13 |
+
- vocabulary
|
| 14 |
+
- streamlit
|
| 15 |
+
- education
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# Wrdler
|
| 19 |
+
|
| 20 |
+
> **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.**
|
| 21 |
+
|
| 22 |
+
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.
|
| 23 |
+
|
| 24 |
+
## Key Differences from BattleWords
|
| 25 |
+
|
| 26 |
+
- **8x6 grid** (instead of 12x12) with **6 words total** (one per row)
|
| 27 |
+
- **Horizontal words only** (no vertical placement)
|
| 28 |
+
- **No scope/radar visualization**
|
| 29 |
+
- **2 free letter guesses** at the start - choose letters to reveal all instances in the grid
|
| 30 |
+
|
| 31 |
+
## Features
|
| 32 |
+
|
| 33 |
+
### Core Gameplay
|
| 34 |
+
- 8x6 grid with six hidden words (one per row, all horizontal)
|
| 35 |
+
- Game starts with 2 free letter guesses; all instances of chosen letters are revealed
|
| 36 |
+
- Reveal grid cells and guess words for points
|
| 37 |
+
- Scoring tiers: Good (34–37), Great (38–41), Fantastic (42+)
|
| 38 |
+
- Game ends when all words are guessed or all word letters are revealed
|
| 39 |
+
- Incorrect guess history with tooltip and optional display (enabled by default)
|
| 40 |
+
- 10 incorrect guess limit per game
|
| 41 |
+
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
| 42 |
+
|
| 43 |
+
### Audio & Visuals
|
| 44 |
+
- Ocean-themed gradient background with wave animations
|
| 45 |
+
- Background music system (toggleable with volume control)
|
| 46 |
+
- Sound effects for hits, misses, correct/incorrect guesses
|
| 47 |
+
- Responsive UI built with Streamlit
|
| 48 |
+
|
| 49 |
+
### Customization
|
| 50 |
+
- Multiple word lists (classic, fourth_grade, wordlist)
|
| 51 |
+
- Wordlist sidebar controls (picker + one-click sort)
|
| 52 |
+
- Audio volume controls (music and effects separate)
|
| 53 |
+
|
| 54 |
+
### ✅ Challenge Mode
|
| 55 |
+
- **Shareable challenge links** via short URLs (`?game_id=<sid>`)
|
| 56 |
+
- **Multi-user leaderboards** sorted by score and time
|
| 57 |
+
- **Remote storage** via Hugging Face datasets
|
| 58 |
+
- **Word list difficulty calculation** and display
|
| 59 |
+
- **Submit results** to existing challenges or create new ones
|
| 60 |
+
- **Top 5 leaderboard** display in Challenge Mode banner
|
| 61 |
+
- **"Show Challenge Share Links" toggle** (default OFF) to control URL visibility
|
| 62 |
+
- Each player gets different random words from the same wordlist
|
| 63 |
+
|
| 64 |
+
### Deployment & Technical
|
| 65 |
+
- **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
|
| 66 |
+
- **Environment variables** for Challenge Mode (HF_API_TOKEN, HF_REPO_ID, SPACE_NAME)
|
| 67 |
+
- Works offline without HF credentials (Challenge Mode features disabled gracefully)
|
| 68 |
+
|
| 69 |
+
### Progressive Web App (PWA)
|
| 70 |
+
- Installable on desktop and mobile from your browser
|
| 71 |
+
- Includes `service worker` and `manifest.json` with basic offline caching of static assets
|
| 72 |
+
- See `INSTALL_GUIDE.md` for platform-specific steps
|
| 73 |
+
|
| 74 |
+
### Planned
|
| 75 |
+
- Local persistent storage for personal game history
|
| 76 |
+
- Personal high scores sidebar (offline-capable)
|
| 77 |
+
- Player statistics tracking
|
| 78 |
+
- Deterministic seed UI for custom puzzles
|
| 79 |
+
|
| 80 |
+
## Challenge Mode & Leaderboard
|
| 81 |
+
|
| 82 |
+
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.
|
| 83 |
+
|
| 84 |
+
## Installation
|
| 85 |
+
1. Clone the repository:
|
| 86 |
+
```
|
| 87 |
+
git clone https://github.com/Oncorporation/Wrdler.git
|
| 88 |
+
cd wrdler
|
| 89 |
+
```
|
| 90 |
+
2. (Optional) Create and activate a virtual environment:
|
| 91 |
+
```
|
| 92 |
+
python -m venv venv
|
| 93 |
+
source venv/bin/activate # On Windows use `venv\Scripts\activate`
|
| 94 |
+
```
|
| 95 |
+
3. Install dependencies: ( add --system if not using a virutal environment)
|
| 96 |
+
```
|
| 97 |
+
uv pip install -r requirements.txt --link-mode=copy
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
## Running Wrdler
|
| 102 |
+
|
| 103 |
+
You can run the app locally using either [uv](https://github.com/astral-sh/uv) or Streamlit directly:
|
| 104 |
+
|
| 105 |
+
```
|
| 106 |
+
uv run streamlit run app.py
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
or
|
| 110 |
+
```
|
| 111 |
+
streamlit run app.py
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### Dockerfile Deployment (Hugging Face Spaces and more)
|
| 115 |
+
|
| 116 |
+
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.
|
| 117 |
+
|
| 118 |
+
To deploy on Hugging Face Spaces:
|
| 119 |
+
1. Add a `Dockerfile` to your repository root (see [Spaces Dockerfile guide](https://huggingface.co/docs/hub/spaces-sdks-docker)).
|
| 120 |
+
2. Push your code to your Hugging Face Space.
|
| 121 |
+
3. The platform will build and run your app automatically.
|
| 122 |
+
|
| 123 |
+
For local Docker runs:
|
| 124 |
+
```sh
|
| 125 |
+
docker build -t wrdler .
|
| 126 |
+
docker run -p8501:8501 wrdler
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### Environment Variables (for Challenge Mode)
|
| 130 |
+
|
| 131 |
+
Challenge Mode requires a `.env` file in the project root with HuggingFace Hub credentials:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
# Required for Challenge Mode
|
| 135 |
+
HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN
|
| 136 |
+
HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo
|
| 137 |
+
SPACE_NAME=YourUsername/Wrdler # Your HF Space name
|
| 138 |
+
|
| 139 |
+
# Optional
|
| 140 |
+
CRYPTO_PK= # Reserved for future signing
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**How to get your HF_API_TOKEN:**
|
| 144 |
+
1. Go to https://huggingface.co/settings/tokens
|
| 145 |
+
2. Create a new token with `write` access
|
| 146 |
+
3. Add to `.env` file as `HF_API_TOKEN=hf_...`
|
| 147 |
+
|
| 148 |
+
**Note:** The app works without these variables, but Challenge Mode features (sharing, leaderboards) will be disabled.
|
| 149 |
+
|
| 150 |
+
## Folder Structure
|
| 151 |
+
|
| 152 |
+
- `app.py` – Streamlit entry point
|
| 153 |
+
- `wrdler/` – Python package
|
| 154 |
+
- `models.py` – data models and types
|
| 155 |
+
- `word_loader.py` – word list loading and validation
|
| 156 |
+
- `generator.py` – word placement logic (8x6, horizontal only)
|
| 157 |
+
- `logic.py` – game mechanics (reveal, guess, scoring, free letters)
|
| 158 |
+
- `ui.py` – Streamlit UI composition
|
| 159 |
+
- `game_storage.py` – Hugging Face remote storage integration and challenge sharing
|
| 160 |
+
- `local_storage.py` – local JSON storage for results and high scores
|
| 161 |
+
- `storage.py` – (legacy) local storage and high scores
|
| 162 |
+
- `words/wordlist.txt` – candidate words
|
| 163 |
+
- `specs/` – documentation (`specs.md`, `requirements.md`)
|
| 164 |
+
- `tests/` – unit tests
|
| 165 |
+
|
| 166 |
+
## How to Play
|
| 167 |
+
|
| 168 |
+
1. **Start with 2 free letter guesses** - choose two letters to reveal all their instances in the grid.
|
| 169 |
+
2. Click grid squares to reveal letters or empty spaces.
|
| 170 |
+
3. After revealing a letter, enter a guess for a word in the text box.
|
| 171 |
+
4. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 172 |
+
5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 173 |
+
6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
| 174 |
+
|
| 175 |
+
## Changelog
|
| 176 |
+
|
| 177 |
+
### v0.0.1 (Initial Wrdler Release)
|
| 178 |
+
- Project renamed from BattleWords to Wrdler
|
| 179 |
+
- Grid resized from 12x12 to 8x6
|
| 180 |
+
- Changed to one word per row (6 total), horizontal only
|
| 181 |
+
- Removed vertical word placement
|
| 182 |
+
- Removed scope/radar visualization
|
| 183 |
+
- Added 2 free letter guesses at game start
|
| 184 |
+
- Version reset to 0.0.1
|
| 185 |
+
|
| 186 |
+
### v0.3.0 (planned - post-launch)
|
| 187 |
+
- Local persistent storage for personal game history (offline-capable)
|
| 188 |
+
- Personal high scores sidebar with filtering
|
| 189 |
+
- Player statistics tracking (games played, averages, bests)
|
| 190 |
+
|
| 191 |
+
### Previous BattleWords Versions (v0.2.x - before Wrdler fork)
|
| 192 |
+
|
| 193 |
+
-0.2.29
|
| 194 |
+
- change difficulty calculation
|
| 195 |
+
- add test_compare_difficulty_functions
|
| 196 |
+
- streamlit version update to 1.51.0
|
| 197 |
+
|
| 198 |
+
-0.2.28
|
| 199 |
+
- PWA INSTALL_GUIDE.md added
|
| 200 |
+
- PWA implementation with service worker and manifest.json added
|
| 201 |
+
|
| 202 |
+
-0.2.27
|
| 203 |
+
- Add "Show Challenge Share Links" setting (default: off)
|
| 204 |
+
- When disabled:
|
| 205 |
+
- Header Challenge Mode: hides the Share Challenge link
|
| 206 |
+
- Game Over: allows submitting results but suppresses displaying the generated share URL
|
| 207 |
+
- The setting is saved in session state and preserved across "New Game"
|
| 208 |
+
- No changes to game logic or storage; only UI visibility behavior
|
| 209 |
+
|
| 210 |
+
-0.2.26
|
| 211 |
+
- fix copy/share link button
|
| 212 |
+
|
| 213 |
+
-0.2.25
|
| 214 |
+
- Share challenge from expander
|
| 215 |
+
- fix incorrect guess overlap of guess box
|
| 216 |
+
|
| 217 |
+
-0.2.24
|
| 218 |
+
- compress height
|
| 219 |
+
- change incorrect guess tooltip location
|
| 220 |
+
- update final screen layout
|
| 221 |
+
- add word difficulty formula
|
| 222 |
+
- update documentation
|
| 223 |
+
|
| 224 |
+
-0.2.23
|
| 225 |
+
- Update miss and correct guess sound effects to new versions
|
| 226 |
+
- 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.
|
| 227 |
+
- minimal security added to prevent users from changing the options in a challenge.
|
| 228 |
+
|
| 229 |
+
-0.2.22
|
| 230 |
+
- fix challenge mode link
|
| 231 |
+
- challenge mode UI improvements
|
| 232 |
+
|
| 233 |
+
-0.2.21
|
| 234 |
+
- fix tests
|
| 235 |
+
|
| 236 |
+
-0.2.20
|
| 237 |
+
- Remote Storage game_id:
|
| 238 |
+
- Per-game JSON settings uploaded to a storage server (Hugging Face repo) under unique `games/{uid}/settings.json`
|
| 239 |
+
- A shortened URL id (sid) is generated; shareable link: `?game_id=<sid>`
|
| 240 |
+
- On load with `game_id`, the app resolves sid to the JSON and applies word_list, game_mode, grid_size, puzzle options
|
| 241 |
+
- High Scores: add remote `highscores/highscores.json` (repo) alongside local highscores
|
| 242 |
+
- Dependencies: add `huggingface_hub` and `python-dotenv`
|
| 243 |
+
- Env: `.env` should include `HF_API_TOKEN` (or `HF_TOKEN`), `CRYPTO_PK`, `HF_REPO_ID`, `SPACE_NAME`
|
| 244 |
+
|
| 245 |
+
### Environment Variables
|
| 246 |
+
- HF_API_TOKEN or HF_TOKEN: HF Hub access token
|
| 247 |
+
- CRYPTO_PK: reserved for signing (optional)
|
| 248 |
+
- HF_REPO_ID: e.g., Surn/Storage
|
| 249 |
+
- SPACE_NAME: e.g., Surn/BattleWords
|
| 250 |
+
|
| 251 |
+
### Remote Storage Structure
|
| 252 |
+
- shortener.json
|
| 253 |
+
- games/{uid}/settings.json
|
| 254 |
+
- highscores/highscores.json
|
| 255 |
+
|
| 256 |
+
Note
|
| 257 |
+
- `battlewords/storage.py` remains local-only storage; a separate HF integration wrapper is provided as `game_storage.py` for remote challenge mode.
|
| 258 |
+
|
| 259 |
+
-0.2.19
|
| 260 |
+
- Fix music and sound effect volume issues
|
| 261 |
+
- Update documentation for proposed new features
|
| 262 |
+
|
| 263 |
+
-0.2.18
|
| 264 |
+
- Fix sound effect volume wiring and apply volume to all effects (hit/miss/correct/incorrect)
|
| 265 |
+
- Respect "Enable music" and "Volume" when playing congratulations music and when resuming background music (uses selected track)
|
| 266 |
+
- Add "Enable Sound Effects" checkbox (on by default) and honor it across the app
|
| 267 |
+
- Save generated effects to `assets/audio/effects/` so they are picked up by the app
|
| 268 |
+
- Add `requests` dependency for sound effect generation
|
| 269 |
+
|
| 270 |
+
-0.2.17
|
| 271 |
+
- documentation updates and corrections
|
| 272 |
+
- updated CLAUDE.md with accurate feature status and project structure
|
| 273 |
+
- clarified v0.3.0 planned features vs current implementation
|
| 274 |
+
|
| 275 |
+
-0.2.16
|
| 276 |
+
- replace question marks in score panel with underscores
|
| 277 |
+
- add option to toggle incorrect guess history display in settings (enabled by default)
|
| 278 |
+
- game over popup updated to ensure it is fully visible on screen
|
| 279 |
+
|
| 280 |
+
-0.2.15
|
| 281 |
+
- fix music playing after game end
|
| 282 |
+
- change incorrect guesses icon
|
| 283 |
+
- fix sound effect and music volume issues
|
| 284 |
+
|
| 285 |
+
-0.2.14
|
| 286 |
+
- bug fix on final score popup
|
| 287 |
+
- score panel alignment centered
|
| 288 |
+
- change incorrect guess history UI
|
| 289 |
+
|
| 290 |
+
-0.2.13
|
| 291 |
+
- upgrade background ocean view
|
| 292 |
+
- apply volume control to sound effects
|
| 293 |
+
|
| 294 |
+
-0.2.12
|
| 295 |
+
- fix music looping on congratulations screen
|
| 296 |
+
|
| 297 |
+
-0.2.11
|
| 298 |
+
- update timer to be live during gameplay, but reset with each action
|
| 299 |
+
- compact design
|
| 300 |
+
- remove fullscreen image tooltip
|
| 301 |
+
|
| 302 |
+
-0.2.10
|
| 303 |
+
- reduce sonar graphic size
|
| 304 |
+
- update music and special effects file locations
|
| 305 |
+
- remove some music and sound effects
|
| 306 |
+
- change Guess Text input color
|
| 307 |
+
- incorrect guess UI update
|
| 308 |
+
- scoreboard update
|
| 309 |
+
|
| 310 |
+
-0.2.9
|
| 311 |
+
- fix sonar grid alignment issue on some browsers
|
| 312 |
+
- When all letters of a word are revealed, it is automatically marked as found.
|
| 313 |
+
|
| 314 |
+
-0.2.8
|
| 315 |
+
- Add10 incorrect guess limit per game
|
| 316 |
+
|
| 317 |
+
-0.2.7
|
| 318 |
+
- fix background music playback issue on some browsers
|
| 319 |
+
- add sound effects
|
| 320 |
+
- enhance sonar grid visualization
|
| 321 |
+
- add claude.md documentation
|
| 322 |
+
|
| 323 |
+
-0.2.6
|
| 324 |
+
- fix sonar grid alignment
|
| 325 |
+
- improve score summary layout and styling
|
| 326 |
+
- Add timer to game display in sidebar
|
| 327 |
+
|
| 328 |
+
-0.2.5
|
| 329 |
+
- fix finale pop up issue
|
| 330 |
+
- make grid cells square on wider devices
|
| 331 |
+
|
| 332 |
+
-0.2.4
|
| 333 |
+
- Add music files to repo
|
| 334 |
+
- disable music by default
|
| 335 |
+
|
| 336 |
+
-0.2.3
|
| 337 |
+
- Update version information display
|
| 338 |
+
- adjust sonar grid alignment
|
| 339 |
+
- fix settings scroll issue
|
| 340 |
+
|
| 341 |
+
-0.2.2
|
| 342 |
+
- Add Musical background and settings to toggle sound on/off.
|
| 343 |
+
|
| 344 |
+
-0.2.1
|
| 345 |
+
- Add Theme toggle (light/dark/custom) in sidebar.
|
| 346 |
+
|
| 347 |
+
-0.2.0
|
| 348 |
+
- Added a loading screen when starting a new game.
|
| 349 |
+
- Added a congratulations screen with your final score and tier when the game ends.
|
| 350 |
+
|
| 351 |
+
-0.1.13
|
| 352 |
+
- Improved score summary layout for clarity and style.
|
| 353 |
+
|
| 354 |
+
-0.1.12
|
| 355 |
+
- Improved score summary layout and styling.
|
| 356 |
+
- Enhanced overall appearance and readability.
|
| 357 |
+
|
| 358 |
+
-0.1.11
|
| 359 |
+
- Game now ends when all words are found or revealed.
|
| 360 |
+
- Added word spacing logic and improved settings.
|
| 361 |
+
|
| 362 |
+
-0.1.10
|
| 363 |
+
- Added game mode selector and improved UI feedback.
|
| 364 |
+
|
| 365 |
+
-0.1.9
|
| 366 |
+
- Improved background and mobile layout.
|
| 367 |
+
|
| 368 |
+
-0.1.8
|
| 369 |
+
- Updated to Python3.12.
|
| 370 |
+
|
| 371 |
+
-0.1.5
|
| 372 |
+
- Added hit/miss indicator and improved grid feedback.
|
| 373 |
+
|
| 374 |
+
-0.1.4
|
| 375 |
+
- Radar visualization improved and mobile layout enhanced.
|
| 376 |
+
|
| 377 |
+
-0.1.3
|
| 378 |
+
- Added wordlist picker and sort feature.
|
| 379 |
+
- Improved score panel and final score display.
|
| 380 |
+
|
| 381 |
+
## Known Issues / TODO
|
| 382 |
+
|
| 383 |
+
- 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.
|
| 384 |
+
|
| 385 |
+
## Development Phases
|
| 386 |
+
|
| 387 |
+
- **Proof of Concept (0.1.0):** No overlaps, basic UI, single session.
|
| 388 |
+
- **Beta (0.5.0):** Overlaps allowed on shared letters, responsive layout, keyboard support, deterministic seed.
|
| 389 |
+
- **Full (1.0.0):** Enhanced UX, persistence, leaderboards, daily/practice modes, advanced features.
|
| 390 |
+
|
| 391 |
+
See `specs/requirements.md` and `specs/specs.md` for full details and roadmap.
|
| 392 |
+
|
| 393 |
+
## License
|
| 394 |
+
|
| 395 |
+
Wrdler is based on BattleWords. BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
|
| 396 |
+
|
| 397 |
+
## Hugging Face Spaces Configuration
|
| 398 |
+
|
| 399 |
+
Wrdler is deployable as a Hugging Face Space. You can use either the YAML config block or a Dockerfile for advanced/custom deployments.
|
| 400 |
+
|
| 401 |
+
To configure your Space with the YAML block, add it at the top of your `README.md`:
|
| 402 |
+
|
| 403 |
+
```yaml
|
| 404 |
+
---
|
| 405 |
+
title: Wrdler
|
| 406 |
+
emoji: 🎲
|
| 407 |
+
colorFrom: blue
|
| 408 |
+
colorTo: indigo
|
| 409 |
+
sdk: streamlit
|
| 410 |
+
sdk_version: 1.51.0
|
| 411 |
+
python_version: 3.12.8
|
| 412 |
+
app_file: app.py
|
| 413 |
+
tags:
|
| 414 |
+
- game
|
| 415 |
+
- vocabulary
|
| 416 |
+
- streamlit
|
| 417 |
+
- education
|
| 418 |
+
---
|
| 419 |
+
```
|
| 420 |
+
|
| 421 |
+
**Key parameters:**
|
| 422 |
+
- `title`, `emoji`, `colorFrom`, `colorTo`: Visuals for your Space.
|
| 423 |
+
- `sdk`: Use `streamlit` for Streamlit apps.
|
| 424 |
+
- `sdk_version`: Latest supported Streamlit version.
|
| 425 |
+
- `python_version`: Python version (default is3.10).
|
| 426 |
+
- `app_file`: Entry point for your app.
|
| 427 |
+
- `tags`: List of descriptive tags.
|
| 428 |
+
|
| 429 |
+
**Dependencies:**
|
| 430 |
+
Add a `requirements.txt` with your Python dependencies (e.g., `streamlit`, etc.).
|
| 431 |
+
|
| 432 |
+
**Port:**
|
| 433 |
+
Streamlit Spaces use port `8501` by default.
|
| 434 |
+
|
| 435 |
+
**Embedding:**
|
| 436 |
+
Spaces can be embedded in other sites using an `<iframe>`:
|
| 437 |
+
|
| 438 |
+
```html
|
| 439 |
+
<iframe src="https://[YourUsername]-Wrdler.hf.space?embed=true" title="Wrdler"></iframe>
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
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).
|
| 443 |
+
|
| 444 |
+
# Assets Setup
|
| 445 |
+
|
| 446 |
+
To fully experience Wrdler, especially the audio elements, ensure you set up the following assets:
|
| 447 |
+
|
| 448 |
+
- Place your background music `.mp3` files in `wrdler/assets/audio/music/` to enable music.
|
| 449 |
+
- Place your sound effect files (`.mp3` or `.wav`) in `wrdler/assets/audio/effects/` for sound effects.
|
| 450 |
+
|
| 451 |
+
Refer to the documentation for guidance on compatible audio formats and common troubleshooting tips.
|
| 452 |
+
|
| 453 |
+
# Sound Asset Generation
|
| 454 |
+
|
| 455 |
+
To generate and save custom sound effects for Wrdler, you can use the `generate_sound_effect` function.
|
| 456 |
+
|
| 457 |
+
## Function: `generate_sound_effect`
|
| 458 |
+
|
| 459 |
+
```python
|
| 460 |
+
def generate_sound_effect(effect: str, save_to_assets: bool = False, use_api: str = "huggingface") -> str:
|
| 461 |
+
"""
|
| 462 |
+
Generate a sound effect and save it as a file.
|
| 463 |
+
|
| 464 |
+
Parameters:
|
| 465 |
+
- `effect`: Name of the effect to generate.
|
| 466 |
+
- `save_to_assets`: If `True`, saves the effect to the assets directory;
|
| 467 |
+
if `False`, saves to a temporary location. Default is `False`.
|
| 468 |
+
- `use_api`: API to use for generation. Options are "huggingface" or "replicate". Default is "huggingface".
|
| 469 |
+
|
| 470 |
+
Returns:
|
| 471 |
+
- File path to the saved sound effect.
|
| 472 |
+
```
|
| 473 |
+
|
| 474 |
+
## Parameters
|
| 475 |
+
|
| 476 |
+
- `effect`: The name of the sound effect you want to generate (e.g., "explosion", "powerup").
|
| 477 |
+
- `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`.
|
| 478 |
+
- `use_api` (optional): The API to use for generating the sound. Options are `"huggingface"` or `"replicate"`. Default is `"huggingface"`.
|
| 479 |
+
|
| 480 |
+
## Returns
|
| 481 |
+
|
| 482 |
+
- The function returns the file path to the saved sound effect, whether it's in the assets directory or a temporary location.
|
| 483 |
+
|
| 484 |
+
## Usage Example
|
| 485 |
+
|
| 486 |
+
To generate a sound effect and save it to the assets directory:
|
| 487 |
+
|
| 488 |
+
```python
|
| 489 |
+
generate_sound_effect("your_effect_name", save_to_assets=True)
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
To generate a sound effect and keep it in a temporary location:
|
| 493 |
+
|
| 494 |
+
```python
|
| 495 |
+
temp_path = generate_sound_effect("your_effect_name", save_to_assets=False)
|
| 496 |
+
```
|
| 497 |
+
|
| 498 |
+
## Note
|
| 499 |
+
|
| 500 |
+
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.
|
| 501 |
+
|
| 502 |
+
For any issues or enhancements, please refer to the project documentation or contact the project maintainer.
|
| 503 |
+
|
| 504 |
+
Happy gaming and sound designing!
|
| 505 |
+
|
| 506 |
+
## What's New in v0.2.20-0.2.27: Challenge Mode 🎯
|
| 507 |
+
|
| 508 |
+
### Remote Challenge Sharing 🔗
|
| 509 |
+
- Share challenges with friends via short URLs (`?game_id=<sid>`)
|
| 510 |
+
- Each player gets different random words from the same wordlist
|
| 511 |
+
- Multi-user leaderboards sorted by score and time
|
| 512 |
+
- Word list difficulty calculation and display
|
| 513 |
+
- Compare your performance against others!
|
| 514 |
+
|
| 515 |
+
### Leaderboards 🏆
|
| 516 |
+
- Top 5 players displayed in Challenge Mode banner
|
| 517 |
+
- Results sorted by: highest score → fastest time → highest difficulty
|
| 518 |
+
- Submit results to existing challenges or create new ones
|
| 519 |
+
- Player names supported (optional, defaults to "Anonymous")
|
| 520 |
+
|
| 521 |
+
### Remote Storage 💾
|
| 522 |
+
- Challenge data stored in Hugging Face dataset repositories
|
| 523 |
+
- Automatic save on game completion (with user consent)
|
| 524 |
+
- "Show Challenge Share Links" toggle for privacy control (default OFF)
|
| 525 |
+
- Works offline when HF credentials not configured
|
| 526 |
+
|
| 527 |
+
## What's Planned for v0.3.0
|
| 528 |
+
|
| 529 |
+
### Local Player History (Coming Soon)
|
| 530 |
+
- Personal game results saved locally in `~/.wrdler/data/`
|
| 531 |
+
- Offline-capable high score tracking
|
| 532 |
+
- Player statistics (games played, averages, bests)
|
| 533 |
+
- Privacy-first: no cloud dependency for personal data
|
| 534 |
+
- Easy data management (delete `~/.wrdler/data/` to reset)
|
| 535 |
+
|
app.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
from wrdler.ui import run_app, _init_session
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _new_game() -> None:
|
| 7 |
+
st.session_state.clear()
|
| 8 |
+
_init_session()
|
| 9 |
+
st.rerun()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main(opened=False):
|
| 13 |
+
st.set_page_config(
|
| 14 |
+
page_title="Wrdler",
|
| 15 |
+
layout="wide",
|
| 16 |
+
initial_sidebar_state="expanded" if opened else "collapsed"
|
| 17 |
+
)
|
| 18 |
+
run_app()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
main()
|
generate_pwa_icons.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Generate PWA icons for BattleWords.
|
| 4 |
+
Creates 192x192 and 512x512 icons with ocean theme and 'BW' text.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
def create_icon(size, filename):
|
| 11 |
+
"""Create a square icon with ocean gradient background and 'BW' text."""
|
| 12 |
+
|
| 13 |
+
# Create image with ocean blue gradient
|
| 14 |
+
img = Image.new('RGB', (size, size))
|
| 15 |
+
draw = ImageDraw.Draw(img)
|
| 16 |
+
|
| 17 |
+
# Draw vertical gradient (ocean theme)
|
| 18 |
+
water_sky = (29, 100, 200) # #1d64c8
|
| 19 |
+
water_deep = (11, 42, 74) # #0b2a4a
|
| 20 |
+
|
| 21 |
+
for y in range(size):
|
| 22 |
+
# Interpolate between sky and deep
|
| 23 |
+
ratio = y / size
|
| 24 |
+
r = int(water_sky[0] * (1 - ratio) + water_deep[0] * ratio)
|
| 25 |
+
g = int(water_sky[1] * (1 - ratio) + water_deep[1] * ratio)
|
| 26 |
+
b = int(water_sky[2] * (1 - ratio) + water_deep[2] * ratio)
|
| 27 |
+
draw.rectangle([(0, y), (size, y + 1)], fill=(r, g, b))
|
| 28 |
+
|
| 29 |
+
# Draw circular background for better icon appearance
|
| 30 |
+
circle_margin = size // 10
|
| 31 |
+
circle_bbox = [circle_margin, circle_margin, size - circle_margin, size - circle_margin]
|
| 32 |
+
|
| 33 |
+
# Draw white circle with transparency
|
| 34 |
+
overlay = Image.new('RGBA', (size, size), (255, 255, 255, 0))
|
| 35 |
+
overlay_draw = ImageDraw.Draw(overlay)
|
| 36 |
+
overlay_draw.ellipse(circle_bbox, fill=(255, 255, 255, 40))
|
| 37 |
+
|
| 38 |
+
# Composite the overlay
|
| 39 |
+
img = img.convert('RGBA')
|
| 40 |
+
img = Image.alpha_composite(img, overlay)
|
| 41 |
+
|
| 42 |
+
# Draw 'BW' text
|
| 43 |
+
font_size = size // 3
|
| 44 |
+
try:
|
| 45 |
+
# Try to load a nice bold font
|
| 46 |
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
|
| 47 |
+
except Exception:
|
| 48 |
+
try:
|
| 49 |
+
# Fallback for Windows
|
| 50 |
+
font = ImageFont.truetype("C:/Windows/Fonts/arialbd.ttf", font_size)
|
| 51 |
+
except Exception:
|
| 52 |
+
# Ultimate fallback
|
| 53 |
+
font = ImageFont.load_default()
|
| 54 |
+
|
| 55 |
+
draw = ImageDraw.Draw(img)
|
| 56 |
+
text = "BW"
|
| 57 |
+
|
| 58 |
+
# Get text bounding box for centering
|
| 59 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 60 |
+
text_width = bbox[2] - bbox[0]
|
| 61 |
+
text_height = bbox[3] - bbox[1]
|
| 62 |
+
|
| 63 |
+
# Center the text
|
| 64 |
+
x = (size - text_width) // 2
|
| 65 |
+
y = (size - text_height) // 2 - (bbox[1] // 2)
|
| 66 |
+
|
| 67 |
+
# Draw text with shadow for depth
|
| 68 |
+
shadow_offset = size // 50
|
| 69 |
+
draw.text((x + shadow_offset, y + shadow_offset), text, fill=(0, 0, 0, 100), font=font)
|
| 70 |
+
draw.text((x, y), text, fill='white', font=font)
|
| 71 |
+
|
| 72 |
+
# Convert back to RGB for saving as PNG
|
| 73 |
+
if img.mode == 'RGBA':
|
| 74 |
+
background = Image.new('RGB', img.size, (11, 42, 74))
|
| 75 |
+
background.paste(img, mask=img.split()[3]) # Use alpha channel as mask
|
| 76 |
+
img = background
|
| 77 |
+
|
| 78 |
+
# Save
|
| 79 |
+
img.save(filename, 'PNG', optimize=True)
|
| 80 |
+
print(f"[OK] Created {filename} ({size}x{size})")
|
| 81 |
+
|
| 82 |
+
def main():
|
| 83 |
+
"""Generate both icon sizes."""
|
| 84 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 85 |
+
static_dir = os.path.join(script_dir, 'battlewords', 'static')
|
| 86 |
+
|
| 87 |
+
# Ensure directory exists
|
| 88 |
+
os.makedirs(static_dir, exist_ok=True)
|
| 89 |
+
|
| 90 |
+
# Generate icons
|
| 91 |
+
print("Generating PWA icons for BattleWords...")
|
| 92 |
+
create_icon(192, os.path.join(static_dir, 'icon-192.png'))
|
| 93 |
+
create_icon(512, os.path.join(static_dir, 'icon-512.png'))
|
| 94 |
+
print("\n[SUCCESS] PWA icons generated successfully!")
|
| 95 |
+
print(f" Location: {static_dir}")
|
| 96 |
+
|
| 97 |
+
if __name__ == '__main__':
|
| 98 |
+
main()
|
inject-pwa-head.sh
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Inject PWA meta tags into Streamlit's index.html head section
|
| 3 |
+
# This script modifies the Streamlit index.html during Docker build
|
| 4 |
+
|
| 5 |
+
set -e
|
| 6 |
+
|
| 7 |
+
echo "[PWA] Injecting PWA meta tags into Streamlit's index.html..."
|
| 8 |
+
|
| 9 |
+
# Find Streamlit's index.html
|
| 10 |
+
STREAMLIT_INDEX=$(python3 -c "import streamlit; import os; print(os.path.join(os.path.dirname(streamlit.__file__), 'static', 'index.html'))")
|
| 11 |
+
|
| 12 |
+
if [ ! -f "$STREAMLIT_INDEX" ]; then
|
| 13 |
+
echo "[PWA] ERROR: Streamlit index.html not found at: $STREAMLIT_INDEX"
|
| 14 |
+
exit 1
|
| 15 |
+
fi
|
| 16 |
+
|
| 17 |
+
echo "[PWA] Found Streamlit index.html at: $STREAMLIT_INDEX"
|
| 18 |
+
|
| 19 |
+
# Check if already injected (to make script idempotent)
|
| 20 |
+
if grep -q "PWA (Progressive Web App) Meta Tags" "$STREAMLIT_INDEX"; then
|
| 21 |
+
echo "[PWA] PWA tags already injected, skipping..."
|
| 22 |
+
exit 0
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
# Read the injection content
|
| 26 |
+
INJECT_FILE="/app/pwa-head-inject.html"
|
| 27 |
+
if [ ! -f "$INJECT_FILE" ]; then
|
| 28 |
+
echo "[PWA] ERROR: Injection file not found at: $INJECT_FILE"
|
| 29 |
+
exit 1
|
| 30 |
+
fi
|
| 31 |
+
|
| 32 |
+
# Create backup
|
| 33 |
+
cp "$STREAMLIT_INDEX" "${STREAMLIT_INDEX}.backup"
|
| 34 |
+
|
| 35 |
+
# Use awk to inject after <head> tag
|
| 36 |
+
awk -v inject_file="$INJECT_FILE" '
|
| 37 |
+
/<head>/ {
|
| 38 |
+
print
|
| 39 |
+
while ((getline line < inject_file) > 0) {
|
| 40 |
+
print line
|
| 41 |
+
}
|
| 42 |
+
close(inject_file)
|
| 43 |
+
next
|
| 44 |
+
}
|
| 45 |
+
{ print }
|
| 46 |
+
' "${STREAMLIT_INDEX}.backup" > "$STREAMLIT_INDEX"
|
| 47 |
+
|
| 48 |
+
echo "[PWA] PWA meta tags successfully injected!"
|
| 49 |
+
echo "[PWA] Backup saved as: ${STREAMLIT_INDEX}.backup"
|
pwa-head-inject.html
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- PWA (Progressive Web App) Meta Tags -->
|
| 2 |
+
<link rel="manifest" href="/app/static/manifest.json">
|
| 3 |
+
<meta name="theme-color" content="#165ba8">
|
| 4 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 5 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 6 |
+
<meta name="apple-mobile-web-app-title" content="Wrdler">
|
| 7 |
+
<link rel="apple-touch-icon" href="/app/static/icon-192.png">
|
| 8 |
+
<meta name="mobile-web-app-capable" content="yes">
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "wrdler"
|
| 3 |
+
version = "0.0.1"
|
| 4 |
+
description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12,<3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"streamlit>=1.51.0",
|
| 9 |
+
"matplotlib>=3.8",
|
| 10 |
+
"requests>=2.31.0",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
[build-system]
|
| 14 |
+
requires = ["setuptools>=61.0", "wheel"]
|
| 15 |
+
build-backend = "setuptools.build_meta"
|
| 16 |
+
|
| 17 |
+
[tool.setuptools]
|
| 18 |
+
include-package-data = true
|
| 19 |
+
|
| 20 |
+
[tool.setuptools.packages.find]
|
| 21 |
+
where = [""]
|
| 22 |
+
include = ["wrdler*"]
|
| 23 |
+
|
| 24 |
+
[tool.setuptools.package-data]
|
| 25 |
+
"wrdler.words" = ["*.txt"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
altair
|
| 2 |
+
pandas
|
| 3 |
+
typing
|
| 4 |
+
pathlib
|
| 5 |
+
streamlit
|
| 6 |
+
matplotlib
|
| 7 |
+
numpy
|
| 8 |
+
Pillow
|
| 9 |
+
pytest
|
| 10 |
+
flake8
|
| 11 |
+
mypy
|
| 12 |
+
requests
|
| 13 |
+
huggingface_hub
|
| 14 |
+
python-dotenv
|
| 15 |
+
google-api-core
|
specs/requirements.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wrdler: Implementation Requirements
|
| 2 |
+
|
| 3 |
+
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.
|
| 4 |
+
|
| 5 |
+
## Key Differences from BattleWords
|
| 6 |
+
- 8x6 grid instead of 12x12
|
| 7 |
+
- One word per row (6 total) instead of flexible placement
|
| 8 |
+
- Horizontal words only (no vertical)
|
| 9 |
+
- No radar/scope visualization
|
| 10 |
+
- 2 free letter guesses at game start
|
| 11 |
+
|
| 12 |
+
## Assumptions
|
| 13 |
+
- Tech stack: Python 3.10+, Streamlit for UI, numpy, Pillow for animations
|
| 14 |
+
- Single-player, local state stored in Streamlit session state
|
| 15 |
+
- Grid is always 8x6 with exactly six words (one per row)
|
| 16 |
+
- All words placed horizontally only
|
| 17 |
+
- No word overlaps
|
| 18 |
+
- Entry point is `app.py`
|
| 19 |
+
|
| 20 |
+
## Streamlit Components (API Usage Plan)
|
| 21 |
+
- State & caching
|
| 22 |
+
- `st.session_state` for `puzzle`, `grid_size`, `revealed`, `guessed`, `score`, `last_action`, `can_guess`
|
| 23 |
+
- `st.session_state.points_by_word` for per-word score breakdown
|
| 24 |
+
- `st.session_state.letter_map` derived from puzzle
|
| 25 |
+
- `st.session_state.selected_wordlist` for sidebar picker
|
| 26 |
+
- `st.session_state.show_incorrect_guesses` toggle
|
| 27 |
+
- `st.session_state.show_challenge_share_links` toggle (v0.0.1, default OFF)
|
| 28 |
+
|
| 29 |
+
- Layout & structure
|
| 30 |
+
- `st.title`, `st.subheader`, `st.markdown` for headers
|
| 31 |
+
- `st.columns(8)` to render the 8×6 grid
|
| 32 |
+
- `st.sidebar` for secondary controls
|
| 33 |
+
- `st.expander` for help/stats
|
| 34 |
+
|
| 35 |
+
- Widgets (interaction)
|
| 36 |
+
- `st.button` for each grid cell (48 total) with unique `key`
|
| 37 |
+
- Free letter choice buttons (2) at game start
|
| 38 |
+
- `st.form` + `st.text_input` + `st.form_submit_button("OK")` for word guessing
|
| 39 |
+
- `st.button("New Game")` to reset state
|
| 40 |
+
- Sidebar `selectbox` for wordlist selection
|
| 41 |
+
|
| 42 |
+
- Visualization
|
| 43 |
+
- Ocean-themed gradient background
|
| 44 |
+
- No animated radar (unlike BattleWords)
|
| 45 |
+
- Responsive grid layout
|
| 46 |
+
|
| 47 |
+
- Control flow
|
| 48 |
+
- App reruns on interaction using `st.rerun()`
|
| 49 |
+
- `st.stop()` after game over to freeze UI
|
| 50 |
+
|
| 51 |
+
## Folder Structure
|
| 52 |
+
- `app.py` – Streamlit entry point
|
| 53 |
+
- `wrdler/` – Python package
|
| 54 |
+
- `__init__.py` (version 0.0.1)
|
| 55 |
+
- `models.py` – data models and types
|
| 56 |
+
- `word_loader.py` – load/validate/cached word lists
|
| 57 |
+
- `generator.py` – word placement (8x6, horizontal only)
|
| 58 |
+
- `logic.py` – game mechanics (reveal, guess, scoring, tiers, free letters)
|
| 59 |
+
- `ui.py` – Streamlit UI composition
|
| 60 |
+
- `words/wordlist.txt` – candidate words
|
| 61 |
+
- `specs/` – documentation (this file and `specs.md`)
|
| 62 |
+
- `tests/` – unit tests
|
| 63 |
+
|
| 64 |
+
## Phase 1: Wrdler v0.0.1 (Initial Release)
|
| 65 |
+
|
| 66 |
+
Goal: A playable 8x6 grid game with free letter guesses, horizontal-only words, and Challenge Mode support.
|
| 67 |
+
|
| 68 |
+
### 1) Data Models
|
| 69 |
+
- Define `Coord(x:int, y:int)`
|
| 70 |
+
- Define `Word(text:str, start:Coord, direction:str{"H"}, cells:list[Coord])` (H only)
|
| 71 |
+
- Define `Puzzle(words:list[Word], uid:str)` (no radar, no spacing config)
|
| 72 |
+
- Define `GameState(grid_size:int=48, puzzle:Puzzle, revealed:set[Coord], guessed:set[str], score:int, free_letters_used:int=0, ...)`
|
| 73 |
+
|
| 74 |
+
Acceptance: Types exist and are consumed by generator/logic.
|
| 75 |
+
|
| 76 |
+
### 2) Word List
|
| 77 |
+
- English word list filtered to alphabetic uppercase, lengths in {4,5,6}
|
| 78 |
+
- Loader centralized in `word_loader.py`
|
| 79 |
+
|
| 80 |
+
Acceptance: Loading function returns lists by length with >= 25 words per length.
|
| 81 |
+
|
| 82 |
+
### 3) Puzzle Generation (8x6 Horizontal)
|
| 83 |
+
- Randomly place 6 words (mix of 4, 5, 6-letter) on 8x6 grid, one per row
|
| 84 |
+
- Constraints:
|
| 85 |
+
- Horizontal (left→right) only
|
| 86 |
+
- One word per row (no stacking)
|
| 87 |
+
- No overlapping letters
|
| 88 |
+
- Retry strategy with max attempts
|
| 89 |
+
|
| 90 |
+
Acceptance: Generator returns valid `Puzzle` with 6 words, no collisions, in-bounds.
|
| 91 |
+
|
| 92 |
+
### 4) Free Letter Guesses
|
| 93 |
+
- At game start, show 2 buttons for letter selection
|
| 94 |
+
- On selection, reveal all instances of that letter in the grid
|
| 95 |
+
- Mark as used; disable buttons after 2 uses
|
| 96 |
+
- Set `can_guess=True` after free letters chosen
|
| 97 |
+
|
| 98 |
+
Acceptance: Both free letters properly reveal all matching cells; buttons disabled appropriately.
|
| 99 |
+
|
| 100 |
+
### 5) Game Mechanics
|
| 101 |
+
- Reveal: Click a covered cell to reveal letter or mark empty
|
| 102 |
+
- Guess: After revealing, guess word (4-6 letters) or use free letters
|
| 103 |
+
- Scoring: Base + bonus for unrevealed cells
|
| 104 |
+
- End: All words guessed or all word letters revealed
|
| 105 |
+
- Incorrect guess limit: 10 per game
|
| 106 |
+
|
| 107 |
+
Acceptance: Unit tests cover reveal, guess gating, scoring, tiers.
|
| 108 |
+
|
| 109 |
+
### 6) UI (Streamlit)
|
| 110 |
+
- Layout:
|
| 111 |
+
- Title and instructions
|
| 112 |
+
- Left: 8×6 grid using `st.columns(8)`
|
| 113 |
+
- Right: Score panel, guess form, incorrect guess history
|
| 114 |
+
- Sidebar: New Game, wordlist select, game mode, settings
|
| 115 |
+
- Visuals:
|
| 116 |
+
- Ocean gradient background
|
| 117 |
+
- Covered vs revealed cell styles
|
| 118 |
+
- Completed word highlighting
|
| 119 |
+
|
| 120 |
+
Acceptance: Users can play end-to-end; all features functional.
|
| 121 |
+
|
| 122 |
+
### 7) Challenge Mode (v0.0.1)
|
| 123 |
+
- Parse `game_id` from query params
|
| 124 |
+
- Load game settings from HF repo
|
| 125 |
+
- Share button generates shareable URL
|
| 126 |
+
- Display top 5 leaderboard in Challenge Mode banner
|
| 127 |
+
- "Show Challenge Share Links" toggle
|
| 128 |
+
|
| 129 |
+
Acceptance:
|
| 130 |
+
- URL with `game_id` loads correctly
|
| 131 |
+
- Share button works
|
| 132 |
+
- Leaderboard displays properly
|
| 133 |
+
|
| 134 |
+
### 8) Basic Tests
|
| 135 |
+
- Placement validity (bounds, no overlaps, correct counts)
|
| 136 |
+
- Scoring logic and bonuses
|
| 137 |
+
- Free letter reveal behavior
|
| 138 |
+
- Guess gating
|
| 139 |
+
- Challenge Mode load/share
|
| 140 |
+
|
| 141 |
+
## Known Issues / TODO
|
| 142 |
+
- Generator needs validation for 8x6 horizontal-only placement
|
| 143 |
+
- UI needs adjustment for new grid size (48 cells vs 144)
|
| 144 |
+
- Radar visualization should be removed entirely
|
| 145 |
+
- Free letter buttons UI needs design
|
| 146 |
+
- Game logic needs update for free letters
|
| 147 |
+
|
| 148 |
+
## Future Roadmap
|
| 149 |
+
|
| 150 |
+
### v0.1.0
|
| 151 |
+
- Local persistent storage in `~/.wrdler/data/`
|
| 152 |
+
- High score tracking and display
|
| 153 |
+
- Player statistics
|
| 154 |
+
|
| 155 |
+
### v1.0.0
|
| 156 |
+
- Enhanced UX and animations
|
| 157 |
+
- Multiple difficulty levels
|
| 158 |
+
- Daily puzzle mode
|
| 159 |
+
- Internationalization
|
| 160 |
+
|
| 161 |
+
## Deployment Targets
|
| 162 |
+
- Hugging Face Spaces (primary)
|
| 163 |
+
- Docker containerization
|
| 164 |
+
- Local development
|
specs/specs.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wrdler Game Requirements (specs.md)
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
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.
|
| 5 |
+
|
| 6 |
+
## Key Differences from BattleWords
|
| 7 |
+
- **8x6 grid** (instead of 12x12)
|
| 8 |
+
- **One word per row** (instead of 6 words placed anywhere)
|
| 9 |
+
- **Horizontal words only** (no vertical placement)
|
| 10 |
+
- **No scope/radar visualization**
|
| 11 |
+
- **2 free letter guesses at game start** (all instances of chosen letters are revealed)
|
| 12 |
+
|
| 13 |
+
## Game Board
|
| 14 |
+
- 8 x 6 grid
|
| 15 |
+
- Six hidden words:
|
| 16 |
+
- One word per row (row 0-5)
|
| 17 |
+
- All placed horizontally (left-right)
|
| 18 |
+
- No vertical placement
|
| 19 |
+
- No diagonal placement
|
| 20 |
+
- Words do not overlap
|
| 21 |
+
- Entry point is `app.py`
|
| 22 |
+
- **Supports Dockerfile-based deployment for Hugging Face Spaces and other container platforms**
|
| 23 |
+
|
| 24 |
+
## Gameplay (Core)
|
| 25 |
+
- Players start by choosing 2 letters; all instances of those letters are revealed in the grid
|
| 26 |
+
- Players click grid squares to reveal letters or empty spaces
|
| 27 |
+
- Empty revealed squares are styled with CSS class `empty`
|
| 28 |
+
- After any reveal, the app immediately reruns (`st.rerun`) to show the change
|
| 29 |
+
- After revealing a letter, players may guess a word by entering it in a text box
|
| 30 |
+
- Guess submission triggers an immediate rerun to reflect results
|
| 31 |
+
- Only one guess per letter reveal; must uncover another letter before guessing again
|
| 32 |
+
- In the default mode, a correct guess allows chaining an additional guess without another reveal
|
| 33 |
+
- **The game ends when all six words are guessed or all word letters are revealed**
|
| 34 |
+
|
| 35 |
+
## Scoring
|
| 36 |
+
- Each correct word guess awards points:
|
| 37 |
+
- 1 point per letter in the word
|
| 38 |
+
- Bonus points for each hidden letter at the time of guessing
|
| 39 |
+
- Score tiers:
|
| 40 |
+
- Good: 34-37
|
| 41 |
+
- Great: 38-41
|
| 42 |
+
- Fantastic: 42+
|
| 43 |
+
- **Game over is triggered by either all words being guessed or all word letters being revealed**
|
| 44 |
+
|
| 45 |
+
## Core Rules (v0.0.1)
|
| 46 |
+
- 8x6 grid with one word per row
|
| 47 |
+
- Horizontal words only; no vertical placement
|
| 48 |
+
- No overlaps: words do not overlap or share letters
|
| 49 |
+
- No radar/scope visualization
|
| 50 |
+
- 2 free letter guesses at game start
|
| 51 |
+
- Incorrect guess history with optional display
|
| 52 |
+
- 10 incorrect guess limit per game
|
| 53 |
+
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
| 54 |
+
|
| 55 |
+
## New Features (Challenge Mode)
|
| 56 |
+
- **Game ID Sharing:** Each puzzle generates a shareable link with `?game_id=<sid>` to challenge others with the same word list
|
| 57 |
+
- **Remote Storage:** Game results and leaderboards stored in Hugging Face dataset repos
|
| 58 |
+
- **Leaderboards:** Multi-user leaderboards sorted by score (descending) then time (ascending)
|
| 59 |
+
- **Word List Difficulty:** Calculated and displayed for each challenge
|
| 60 |
+
- **Top 5 Display:** Leaderboard banner shows top 5 players
|
| 61 |
+
- **Optional Sharing:** "Show Challenge Share Links" toggle (default OFF) controls URL visibility
|
| 62 |
+
|
| 63 |
+
## New Features (PWA Support)
|
| 64 |
+
- **PWA Installation:** App is installable as a Progressive Web App on desktop and mobile
|
| 65 |
+
- Added `service worker` and `manifest.json`
|
| 66 |
+
- Basic offline caching of static assets
|
| 67 |
+
- INSTALL_GUIDE.md added with platform-specific install steps
|
| 68 |
+
- No gameplay logic changes
|
| 69 |
+
|
| 70 |
+
## Storage
|
| 71 |
+
- Game results and high scores are stored in JSON files for privacy and offline access (planned for v0.3.0)
|
| 72 |
+
- Game ID is generated from the word list for replay/sharing
|
| 73 |
+
- Local storage location: `~/.wrdler/data/` (planned for v0.3.0)
|
| 74 |
+
- Challenge Mode uses remote storage via Hugging Face datasets (implemented in v0.0.1)
|
| 75 |
+
|
| 76 |
+
## UI Elements
|
| 77 |
+
- 8x6 grid (48 cells total)
|
| 78 |
+
- Free letter guess buttons (2 at game start)
|
| 79 |
+
- Text box for word guesses
|
| 80 |
+
- Score display (shows word, base points, bonus points, total score)
|
| 81 |
+
- Guess status indicator (Correct/Try Again)
|
| 82 |
+
- Incorrect guess history display (toggleable)
|
| 83 |
+
- Game ID display and share button in game over dialog
|
| 84 |
+
- Challenge Mode banner with leaderboard (top 5)
|
| 85 |
+
- High score expander in sidebar
|
| 86 |
+
- Player name input in sidebar
|
| 87 |
+
- Checkbox: "Show Challenge Share Links" (default OFF)
|
| 88 |
+
- When OFF:
|
| 89 |
+
- Challenge Mode header hides the Share Challenge link
|
| 90 |
+
- Game Over dialog still supports submitting/creating challenges, but does not display the generated share URL
|
| 91 |
+
- Persisted in session state and preserved across "New Game"
|
| 92 |
+
|
| 93 |
+
## Word List
|
| 94 |
+
- External list at `wrdler/words/wordlist.txt`
|
| 95 |
+
- Loaded by `wrdler.word_loader.load_word_list()` with caching
|
| 96 |
+
- Filtered to uppercase A-Z, lengths in {4,5,6}; falls back if < 25 per length
|
| 97 |
+
|
| 98 |
+
## Generator
|
| 99 |
+
- Centralized word loader
|
| 100 |
+
- No duplicate word texts are selected
|
| 101 |
+
- Horizontal-only word placement
|
| 102 |
+
- One word per row in 8x6 grid
|
| 103 |
+
- No word spacing configuration (fixed one word per row)
|
| 104 |
+
|
| 105 |
+
## Entry Point
|
| 106 |
+
- The Streamlit entry point is `app.py`
|
| 107 |
+
- **A `Dockerfile` can be used for containerized deployment (recommended for Hugging Face Spaces)**
|
| 108 |
+
|
| 109 |
+
## Deployment Requirements
|
| 110 |
+
|
| 111 |
+
### Basic Deployment (Offline Mode)
|
| 112 |
+
No special configuration needed. The app will run with all core gameplay features.
|
| 113 |
+
Optional: Install as PWA from the browser menu (Add to Home Screen/Install app).
|
| 114 |
+
|
| 115 |
+
### Challenge Mode Deployment (Remote Storage)
|
| 116 |
+
Requires HuggingFace Hub integration for challenge sharing and leaderboards.
|
| 117 |
+
|
| 118 |
+
**Required Environment Variables:**
|
| 119 |
+
```bash
|
| 120 |
+
HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN (write access required)
|
| 121 |
+
HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repository
|
| 122 |
+
SPACE_NAME=YourUsername/Wrdler # Your HF Space name for URL generation
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
**Optional Environment Variables:**
|
| 126 |
+
```bash
|
| 127 |
+
CRYPTO_PK= # Reserved for future challenge signing
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
**Setup Steps:**
|
| 131 |
+
1. Create a HuggingFace account at https://huggingface.co
|
| 132 |
+
2. Create a dataset repository (e.g., `YourUsername/WrdlerStorage`)
|
| 133 |
+
3. Generate an access token with `write` permissions:
|
| 134 |
+
- Go to https://huggingface.co/settings/tokens
|
| 135 |
+
- Click "New token"
|
| 136 |
+
- Select "Write" access
|
| 137 |
+
- Copy the token (starts with `hf_`)
|
| 138 |
+
4. Create a `.env` file in project root with the variables above
|
| 139 |
+
5. For Hugging Face Spaces deployment, add these as Space secrets
|
| 140 |
+
|
| 141 |
+
**Repository Structure (automatically created):**
|
| 142 |
+
```
|
| 143 |
+
HF_REPO_ID/
|
| 144 |
+
├── shortener.json # Short URL mappings (sid -> full URL)
|
| 145 |
+
└── games/
|
| 146 |
+
└── {uid}/
|
| 147 |
+
└── settings.json # Challenge data with users array
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
**Data Privacy:**
|
| 151 |
+
- Challenge Mode stores: word lists, scores, times, game modes, player names
|
| 152 |
+
- No PII beyond optional player name (defaults to "Anonymous")
|
| 153 |
+
- Players control URL visibility via "Show Challenge Share Links" setting
|
| 154 |
+
- App functions fully offline when HF credentials not configured
|
| 155 |
+
|
| 156 |
+
**Deployment Platforms:**
|
| 157 |
+
- Local development: Run with `streamlit run app.py`
|
| 158 |
+
- Docker: Use provided `Dockerfile`
|
| 159 |
+
- Hugging Face Spaces: Dockerfile deployment (recommended)
|
| 160 |
+
- Any Python 3.10+ hosting with Streamlit support
|
| 161 |
+
|
| 162 |
+
## Copyright
|
| 163 |
+
Wrdler is based on BattleWords. BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
|
| 164 |
+
|
| 165 |
+
## v0.2.20: Remote Storage and Shortened game_id URL
|
| 166 |
+
|
| 167 |
+
Game Sharing
|
| 168 |
+
- Each puzzle can be shared via a link containing a `game_id` querystring (short id / sid)
|
| 169 |
+
- `game_id` resolves to a settings JSON on the storage server (HF repo)
|
| 170 |
+
- JSON fields:
|
| 171 |
+
- word_list (list of 6 uppercase words)
|
| 172 |
+
- score (int), time (int seconds) [metadata only]
|
| 173 |
+
- game_mode (e.g., classic, too easy)
|
| 174 |
+
- grid_size (e.g., 12)
|
| 175 |
+
- puzzle_options (e.g., { spacer, may_overlap })
|
| 176 |
+
- On load with `game_id`, fetch and apply: word_list, game_mode, grid_size, puzzle_options
|
| 177 |
+
|
| 178 |
+
High Scores
|
| 179 |
+
- Repository maintains `highscores/highscores.json` for top scores
|
| 180 |
+
- Local highscores remain supported for offline use
|
| 181 |
+
|
| 182 |
+
UI/UX
|
| 183 |
+
- Show the current `game_id` (sid) and a �Share Challenge� link
|
| 184 |
+
- When loading with a `game_id`, indicate the puzzle is a shared challenge
|
| 185 |
+
|
| 186 |
+
Security/Privacy
|
| 187 |
+
- Only game configuration and scores are stored; no personal data is required
|
| 188 |
+
- `game_id` is a short reference; full URL is stored in a repo JSON shortener index
|
| 189 |
+
|
| 190 |
+
## Challenge Mode & Leaderboard
|
| 191 |
+
|
| 192 |
+
- When loading a shared challenge via `game_id`, the leaderboard displays all user results for that challenge.
|
| 193 |
+
- **Sorting:** The leaderboard is sorted by highest score (descending), then by fastest time (ascending).
|
| 194 |
+
- **Difficulty:** Each result now displays a computed word list difficulty value.
|
| 195 |
+
- Results are stored remotely in a Hugging Face dataset repo and updated via the app.
|
specs/wrdler_implementation_plan.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wrdler Implementation Plan
|
| 2 |
+
**Version:** 0.0.1
|
| 3 |
+
**Status:** Planning Phase
|
| 4 |
+
**Last Updated:** 2025-10-31
|
| 5 |
+
|
| 6 |
+
## Overview
|
| 7 |
+
This document outlines the step-by-step implementation plan for converting BattleWords to Wrdler, focusing on the core gameplay differences:
|
| 8 |
+
- 8×6 rectangular grid (8 columns, 6 rows)
|
| 9 |
+
- Horizontal words only (one per row)
|
| 10 |
+
- No radar/scope visualization
|
| 11 |
+
- 2 free letter guesses at game start
|
| 12 |
+
|
| 13 |
+
## Current State Analysis
|
| 14 |
+
|
| 15 |
+
### What Works (Inheritance from BattleWords)
|
| 16 |
+
- ✅ Word loading and validation
|
| 17 |
+
- ✅ Challenge mode and remote storage
|
| 18 |
+
- ✅ Audio system (music and sound effects)
|
| 19 |
+
- ✅ PWA support
|
| 20 |
+
- ✅ Scoring system (can be reused)
|
| 21 |
+
- ✅ Incorrect guess tracking
|
| 22 |
+
- ✅ Timer functionality
|
| 23 |
+
|
| 24 |
+
### What Needs Changes
|
| 25 |
+
- ❌ Square grid assumption (12×12)
|
| 26 |
+
- ❌ Vertical word placement
|
| 27 |
+
- ❌ Radar/scope visualization throughout UI
|
| 28 |
+
- ❌ Game initialization (needs free letter selection)
|
| 29 |
+
- ❌ Word count (currently 6 words of mixed lengths)
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## Phase 1: Data Model Updates
|
| 34 |
+
|
| 35 |
+
### 1.1 Coordinate System (models.py)
|
| 36 |
+
**Current:** Square grid with single `grid_size` parameter
|
| 37 |
+
**Target:** Rectangular grid with separate width and height
|
| 38 |
+
|
| 39 |
+
**Files to Modify:**
|
| 40 |
+
- `wrdler/models.py`
|
| 41 |
+
|
| 42 |
+
**Changes:**
|
| 43 |
+
```python
|
| 44 |
+
# Current
|
| 45 |
+
@dataclass(frozen=True, order=True)
|
| 46 |
+
class Coord:
|
| 47 |
+
x: int # row, 0-based
|
| 48 |
+
y: int # col, 0-based
|
| 49 |
+
|
| 50 |
+
def in_bounds(self, size: int) -> bool:
|
| 51 |
+
return 0 <= self.x < size and 0 <= self.y < size
|
| 52 |
+
|
| 53 |
+
# Proposed
|
| 54 |
+
@dataclass(frozen=True, order=True)
|
| 55 |
+
class Coord:
|
| 56 |
+
x: int # row, 0-based
|
| 57 |
+
y: int # col, 0-based
|
| 58 |
+
|
| 59 |
+
def in_bounds(self, size: int) -> bool:
|
| 60 |
+
"""Legacy square grid check (deprecated)"""
|
| 61 |
+
return 0 <= self.x < size and 0 <= self.y < size
|
| 62 |
+
|
| 63 |
+
def in_bounds_rect(self, rows: int, cols: int) -> bool:
|
| 64 |
+
"""Rectangular grid boundary check"""
|
| 65 |
+
return 0 <= self.x < rows and 0 <= self.y < cols
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**Testing:**
|
| 69 |
+
- Unit tests for `in_bounds_rect()` with 6×8 grid
|
| 70 |
+
- Verify backward compatibility with square grids
|
| 71 |
+
|
| 72 |
+
### 1.2 Game State Model (models.py)
|
| 73 |
+
**Current:** Single `grid_size` field
|
| 74 |
+
**Target:** Separate `grid_rows` and `grid_cols` fields
|
| 75 |
+
|
| 76 |
+
**Changes:**
|
| 77 |
+
```python
|
| 78 |
+
# Current
|
| 79 |
+
@dataclass
|
| 80 |
+
class GameState:
|
| 81 |
+
grid_size: int
|
| 82 |
+
# ... other fields
|
| 83 |
+
|
| 84 |
+
# Proposed
|
| 85 |
+
@dataclass
|
| 86 |
+
class GameState:
|
| 87 |
+
grid_rows: int = 6
|
| 88 |
+
grid_cols: int = 8
|
| 89 |
+
# Add backward compatibility property
|
| 90 |
+
@property
|
| 91 |
+
def grid_size(self) -> int:
|
| 92 |
+
"""Legacy property for square grids"""
|
| 93 |
+
if self.grid_rows == self.grid_cols:
|
| 94 |
+
return self.grid_rows
|
| 95 |
+
raise ValueError("grid_size not applicable for rectangular grids")
|
| 96 |
+
# ... other fields
|
| 97 |
+
free_letters: Set[str] = field(default_factory=set) # NEW: Track free letter guesses
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
**Migration Strategy:**
|
| 101 |
+
- Add default values for smooth transition
|
| 102 |
+
- Keep `grid_size` as computed property for backward compatibility
|
| 103 |
+
- Add `free_letters` field to track initial letter reveals
|
| 104 |
+
|
| 105 |
+
### 1.3 Puzzle Model (models.py)
|
| 106 |
+
**Current:** Includes radar visualization data
|
| 107 |
+
**Target:** Remove radar, simplify to word list only
|
| 108 |
+
|
| 109 |
+
**Changes:**
|
| 110 |
+
```python
|
| 111 |
+
# Current
|
| 112 |
+
@dataclass
|
| 113 |
+
class Puzzle:
|
| 114 |
+
words: List[Word]
|
| 115 |
+
radar: List[Coord] = field(default_factory=list) # TO BE REMOVED
|
| 116 |
+
may_overlap: bool = False
|
| 117 |
+
spacer: int = 1
|
| 118 |
+
uid: str = field(default_factory=lambda: uuid.uuid4().hex)
|
| 119 |
+
|
| 120 |
+
# Proposed
|
| 121 |
+
@dataclass
|
| 122 |
+
class Puzzle:
|
| 123 |
+
words: List[Word]
|
| 124 |
+
# radar field removed entirely
|
| 125 |
+
spacer: int = 1 # Still relevant for word spacing
|
| 126 |
+
uid: str = field(default_factory=lambda: uuid.uuid4().hex)
|
| 127 |
+
grid_rows: int = 6 # NEW: Track grid dimensions
|
| 128 |
+
grid_cols: int = 8 # NEW
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## Phase 2: Puzzle Generator Updates
|
| 134 |
+
|
| 135 |
+
### 2.1 Horizontal-Only Word Placement (generator.py)
|
| 136 |
+
**Current:** Places words horizontally or vertically
|
| 137 |
+
**Target:** Horizontal only, one word per row
|
| 138 |
+
|
| 139 |
+
**Files to Modify:**
|
| 140 |
+
- `wrdler/generator.py`
|
| 141 |
+
|
| 142 |
+
**Key Changes:**
|
| 143 |
+
1. Remove vertical placement logic
|
| 144 |
+
2. Implement row-based placement (each word on a different row)
|
| 145 |
+
3. Update word length requirements for 8-column grid
|
| 146 |
+
|
| 147 |
+
**Algorithm:**
|
| 148 |
+
```python
|
| 149 |
+
def generate_puzzle(
|
| 150 |
+
grid_rows: int = 6,
|
| 151 |
+
grid_cols: int = 8,
|
| 152 |
+
words_by_len: Optional[Dict[int, List[str]]] = None,
|
| 153 |
+
seed: Optional[Union[int, str]] = None,
|
| 154 |
+
spacer: int = 1,
|
| 155 |
+
target_words: Optional[List[str]] = None,
|
| 156 |
+
) -> Puzzle:
|
| 157 |
+
"""
|
| 158 |
+
Generate 6 horizontal words (one per row) for 8-column grid.
|
| 159 |
+
|
| 160 |
+
Word length constraints:
|
| 161 |
+
- Max length: 8 letters (full row)
|
| 162 |
+
- Min length: 3 letters (reasonable minimum)
|
| 163 |
+
- Distribution: Mix of lengths (e.g., 2×4, 2×5, 2×6 or 2×5, 2×6, 2×7)
|
| 164 |
+
"""
|
| 165 |
+
# 1. Select 6 words (target lengths TBD based on difficulty)
|
| 166 |
+
# 2. Shuffle row order for variety
|
| 167 |
+
# 3. For each row, randomly position word within bounds
|
| 168 |
+
# 4. Ensure words don't touch if spacer > 0
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
**Word Selection Strategy:**
|
| 172 |
+
- **Easy:** Shorter words (4-5 letters)
|
| 173 |
+
- **Medium:** Mix of 4-6 letters
|
| 174 |
+
- **Hard:** Longer words (6-8 letters)
|
| 175 |
+
|
| 176 |
+
**Placement Logic:**
|
| 177 |
+
```python
|
| 178 |
+
for row_idx, word_text in enumerate(selected_words):
|
| 179 |
+
# Word must fit in row with padding
|
| 180 |
+
max_start_col = grid_cols - len(word_text)
|
| 181 |
+
if max_start_col < 0:
|
| 182 |
+
raise ValueError(f"Word '{word_text}' too long for {grid_cols} columns")
|
| 183 |
+
|
| 184 |
+
# Randomly position within valid range
|
| 185 |
+
start_col = rng.randint(0, max_start_col)
|
| 186 |
+
|
| 187 |
+
# Create word with direction="H"
|
| 188 |
+
word = Word(
|
| 189 |
+
text=word_text,
|
| 190 |
+
start=Coord(row_idx, start_col),
|
| 191 |
+
direction="H"
|
| 192 |
+
)
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### 2.2 Validation Updates (generator.py)
|
| 196 |
+
**Current:** Validates for square grid and no overlaps
|
| 197 |
+
**Target:** Validate for rectangular grid, horizontal only
|
| 198 |
+
|
| 199 |
+
**Changes:**
|
| 200 |
+
```python
|
| 201 |
+
def validate_puzzle(puzzle: Puzzle, grid_rows: int = 6, grid_cols: int = 8) -> None:
|
| 202 |
+
"""Validate Wrdler puzzle constraints."""
|
| 203 |
+
# 1. Exactly 6 words
|
| 204 |
+
assert len(puzzle.words) == 6, f"Expected 6 words, got {len(puzzle.words)}"
|
| 205 |
+
|
| 206 |
+
# 2. All horizontal
|
| 207 |
+
for w in puzzle.words:
|
| 208 |
+
assert w.direction == "H", f"Word {w.text} is not horizontal"
|
| 209 |
+
|
| 210 |
+
# 3. One word per row
|
| 211 |
+
rows_used = [w.start.x for w in puzzle.words]
|
| 212 |
+
assert len(set(rows_used)) == 6, "Must have one word per row"
|
| 213 |
+
|
| 214 |
+
# 4. All cells in bounds
|
| 215 |
+
for w in puzzle.words:
|
| 216 |
+
for c in w.cells:
|
| 217 |
+
assert c.in_bounds_rect(grid_rows, grid_cols), \
|
| 218 |
+
f"Word {w.text} cell {c} out of bounds"
|
| 219 |
+
|
| 220 |
+
# 5. No overlaps (should be impossible with one word per row, but verify)
|
| 221 |
+
all_cells = set()
|
| 222 |
+
for w in puzzle.words:
|
| 223 |
+
for c in w.cells:
|
| 224 |
+
assert c not in all_cells, f"Cell {c} used twice"
|
| 225 |
+
all_cells.add(c)
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
|
| 230 |
+
## Phase 3: Remove Radar/Scope Visualization
|
| 231 |
+
|
| 232 |
+
### 3.1 UI Code Cleanup (ui.py)
|
| 233 |
+
**Files to Modify:**
|
| 234 |
+
- `wrdler/ui.py`
|
| 235 |
+
|
| 236 |
+
**Functions to Remove:**
|
| 237 |
+
- Radar rendering functions (matplotlib-based animations)
|
| 238 |
+
- Scope overlay generation
|
| 239 |
+
- Radar caching logic
|
| 240 |
+
|
| 241 |
+
**CSS/JavaScript to Remove:**
|
| 242 |
+
- Radar container styling
|
| 243 |
+
- Pulsing animation keyframes
|
| 244 |
+
- Scope positioning logic
|
| 245 |
+
|
| 246 |
+
**Search Terms for Cleanup:**
|
| 247 |
+
```bash
|
| 248 |
+
grep -n "radar" wrdler/ui.py
|
| 249 |
+
grep -n "scope" wrdler/ui.py
|
| 250 |
+
grep -n "pulse" wrdler/ui.py
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
### 3.2 Session State Cleanup
|
| 254 |
+
**Remove:**
|
| 255 |
+
- `st.session_state.radar_*` variables
|
| 256 |
+
- Radar cache keys
|
| 257 |
+
- Scope image references
|
| 258 |
+
|
| 259 |
+
---
|
| 260 |
+
|
| 261 |
+
## Phase 4: Free Letter Guesses Feature
|
| 262 |
+
|
| 263 |
+
### 4.1 Game Initialization Flow (ui.py)
|
| 264 |
+
**Current:** Game starts immediately with blank grid
|
| 265 |
+
**Target:** User selects 2 letters, then game begins with those letters revealed
|
| 266 |
+
|
| 267 |
+
**New Flow:**
|
| 268 |
+
1. User arrives at game
|
| 269 |
+
2. Show "Choose 2 free letters" interface
|
| 270 |
+
3. User selects 2 letters (A-Z)
|
| 271 |
+
4. Reveal all instances of those letters in the grid
|
| 272 |
+
5. Game proceeds normally
|
| 273 |
+
|
| 274 |
+
**UI Design:**
|
| 275 |
+
```python
|
| 276 |
+
def render_free_letter_selection():
|
| 277 |
+
"""Render letter selection interface at game start."""
|
| 278 |
+
st.markdown("### Choose 2 Free Letters")
|
| 279 |
+
st.markdown("Select any 2 letters to reveal all instances in the puzzle.")
|
| 280 |
+
|
| 281 |
+
# Letter grid (A-Z in rows of 7-8)
|
| 282 |
+
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
| 283 |
+
cols = st.columns(7)
|
| 284 |
+
|
| 285 |
+
selected_letters = st.session_state.get('free_letters_selected', [])
|
| 286 |
+
|
| 287 |
+
for i, letter in enumerate(alphabet):
|
| 288 |
+
col_idx = i % 7
|
| 289 |
+
with cols[col_idx]:
|
| 290 |
+
if st.button(
|
| 291 |
+
letter,
|
| 292 |
+
key=f"free_letter_{letter}",
|
| 293 |
+
disabled=len(selected_letters) >= 2 and letter not in selected_letters,
|
| 294 |
+
type="primary" if letter in selected_letters else "secondary"
|
| 295 |
+
):
|
| 296 |
+
if letter in selected_letters:
|
| 297 |
+
selected_letters.remove(letter)
|
| 298 |
+
else:
|
| 299 |
+
selected_letters.append(letter)
|
| 300 |
+
st.session_state.free_letters_selected = selected_letters
|
| 301 |
+
st.rerun()
|
| 302 |
+
|
| 303 |
+
# Confirm button
|
| 304 |
+
if len(selected_letters) == 2:
|
| 305 |
+
if st.button("Start Game with These Letters", type="primary"):
|
| 306 |
+
_initialize_game_with_free_letters(selected_letters)
|
| 307 |
+
st.rerun()
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
### 4.2 Letter Reveal Logic (logic.py)
|
| 311 |
+
**New Function:**
|
| 312 |
+
```python
|
| 313 |
+
def reveal_free_letters(
|
| 314 |
+
state: GameState,
|
| 315 |
+
letters: List[str],
|
| 316 |
+
letter_map: Dict[Coord, str]
|
| 317 |
+
) -> GameState:
|
| 318 |
+
"""
|
| 319 |
+
Reveal all instances of the given letters in the puzzle.
|
| 320 |
+
|
| 321 |
+
Args:
|
| 322 |
+
state: Current game state
|
| 323 |
+
letters: List of 2 letters to reveal
|
| 324 |
+
letter_map: Mapping of coordinates to letters
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Updated game state with letters revealed
|
| 328 |
+
"""
|
| 329 |
+
new_revealed = set(state.revealed)
|
| 330 |
+
|
| 331 |
+
for coord, letter in letter_map.items():
|
| 332 |
+
if letter.upper() in [l.upper() for l in letters]:
|
| 333 |
+
new_revealed.add(coord)
|
| 334 |
+
|
| 335 |
+
# Update state
|
| 336 |
+
state.revealed = new_revealed
|
| 337 |
+
state.free_letters = set(letters)
|
| 338 |
+
state.last_action = f"Revealed free letters: {', '.join(letters)}"
|
| 339 |
+
|
| 340 |
+
return state
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
### 4.3 Session State Management
|
| 344 |
+
**New Fields:**
|
| 345 |
+
```python
|
| 346 |
+
# In _init_session()
|
| 347 |
+
st.session_state.free_letters_selected = [] # Letters chosen by user
|
| 348 |
+
st.session_state.free_letters_revealed = False # Whether free letters have been applied
|
| 349 |
+
st.session_state.game_phase = "select_letters" # "select_letters" | "playing" | "game_over"
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## Phase 5: UI Grid Updates
|
| 355 |
+
|
| 356 |
+
### 5.1 Grid Rendering (ui.py)
|
| 357 |
+
**Current:** Square grid (12×12) with equal width/height
|
| 358 |
+
**Target:** Rectangular grid (6 rows × 8 columns)
|
| 359 |
+
|
| 360 |
+
**Changes:**
|
| 361 |
+
```python
|
| 362 |
+
# Current
|
| 363 |
+
def render_grid(state: GameState, letter_map):
|
| 364 |
+
size = state.grid_size
|
| 365 |
+
for row in range(size):
|
| 366 |
+
cols = st.columns(size)
|
| 367 |
+
for col in range(size):
|
| 368 |
+
# ...
|
| 369 |
+
|
| 370 |
+
# Proposed
|
| 371 |
+
def render_grid(state: GameState, letter_map):
|
| 372 |
+
rows = state.grid_rows
|
| 373 |
+
cols = state.grid_cols
|
| 374 |
+
for row in range(rows):
|
| 375 |
+
col_widgets = st.columns(cols)
|
| 376 |
+
for col in range(cols):
|
| 377 |
+
# ...
|
| 378 |
+
```
|
| 379 |
+
|
| 380 |
+
### 5.2 CSS Grid Styling
|
| 381 |
+
**Update:**
|
| 382 |
+
- Grid container max-width/height ratios
|
| 383 |
+
- Cell sizing for 8:6 aspect ratio
|
| 384 |
+
- Responsive breakpoints
|
| 385 |
+
|
| 386 |
+
---
|
| 387 |
+
|
| 388 |
+
## Phase 6: Testing Strategy
|
| 389 |
+
|
| 390 |
+
### 6.1 Unit Tests
|
| 391 |
+
**New Tests Needed:**
|
| 392 |
+
- `test_rectangular_grid()` - Verify 8×6 grid creation
|
| 393 |
+
- `test_horizontal_only_placement()` - Ensure no vertical words
|
| 394 |
+
- `test_one_word_per_row()` - Validate row distribution
|
| 395 |
+
- `test_free_letter_reveal()` - Verify letter reveal logic
|
| 396 |
+
- `test_grid_bounds_rect()` - Test `in_bounds_rect()`
|
| 397 |
+
|
| 398 |
+
### 6.2 Integration Tests
|
| 399 |
+
- Complete game flow with free letters
|
| 400 |
+
- Challenge mode with 8×6 grid
|
| 401 |
+
- Scoring with new grid size
|
| 402 |
+
|
| 403 |
+
### 6.3 Manual Testing Checklist
|
| 404 |
+
- [ ] Game loads with letter selection screen
|
| 405 |
+
- [ ] Can select exactly 2 letters
|
| 406 |
+
- [ ] Selected letters are revealed in grid
|
| 407 |
+
- [ ] Grid displays as 8 columns × 6 rows
|
| 408 |
+
- [ ] All words are horizontal
|
| 409 |
+
- [ ] One word per row
|
| 410 |
+
- [ ] Game over conditions work correctly
|
| 411 |
+
- [ ] Scoring system functions properly
|
| 412 |
+
- [ ] Challenge mode creates/loads games correctly
|
| 413 |
+
- [ ] No radar/scope elements visible
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## Phase 7: Migration Path
|
| 418 |
+
|
| 419 |
+
### 7.1 Backward Compatibility
|
| 420 |
+
**Decision:** Wrdler v0.0.1 is a breaking change from BattleWords
|
| 421 |
+
- Old challenge URLs will not work with new game
|
| 422 |
+
- Fresh start with new grid system
|
| 423 |
+
- Document migration in README
|
| 424 |
+
|
| 425 |
+
### 7.2 Database/Storage
|
| 426 |
+
**Challenge Mode:**
|
| 427 |
+
- Update `serialize_game_settings()` to include `grid_rows` and `grid_cols`
|
| 428 |
+
- Update `load_game_from_sid()` to handle new format
|
| 429 |
+
- Add version check for format compatibility
|
| 430 |
+
|
| 431 |
+
---
|
| 432 |
+
|
| 433 |
+
## Implementation Order (Recommended)
|
| 434 |
+
|
| 435 |
+
### Sprint 1: Core Data Models (2-3 hours)
|
| 436 |
+
1. Update `Coord.in_bounds_rect()`
|
| 437 |
+
2. Update `GameState` with `grid_rows`, `grid_cols`, `free_letters`
|
| 438 |
+
3. Remove radar from `Puzzle` model
|
| 439 |
+
4. Update all tests
|
| 440 |
+
|
| 441 |
+
### Sprint 2: Generator (3-4 hours)
|
| 442 |
+
1. Modify `generate_puzzle()` for horizontal-only placement
|
| 443 |
+
2. Implement one-word-per-row logic
|
| 444 |
+
3. Update `validate_puzzle()`
|
| 445 |
+
4. Test with various word lists
|
| 446 |
+
|
| 447 |
+
### Sprint 3: Remove Radar (1-2 hours)
|
| 448 |
+
1. Delete radar rendering functions
|
| 449 |
+
2. Clean up CSS/JavaScript
|
| 450 |
+
3. Remove session state variables
|
| 451 |
+
4. Update UI layout
|
| 452 |
+
|
| 453 |
+
### Sprint 4: Free Letters UI (2-3 hours)
|
| 454 |
+
1. Create letter selection interface
|
| 455 |
+
2. Implement reveal logic
|
| 456 |
+
3. Update game initialization flow
|
| 457 |
+
4. Test user experience
|
| 458 |
+
|
| 459 |
+
### Sprint 5: Grid UI Updates (2-3 hours)
|
| 460 |
+
1. Update grid rendering for 8×6
|
| 461 |
+
2. Adjust CSS styling
|
| 462 |
+
3. Test responsive layout
|
| 463 |
+
4. Update score panel
|
| 464 |
+
|
| 465 |
+
### Sprint 6: Integration & Testing (2-3 hours)
|
| 466 |
+
1. End-to-end game flow testing
|
| 467 |
+
2. Challenge mode compatibility
|
| 468 |
+
3. Fix any bugs
|
| 469 |
+
4. Performance optimization
|
| 470 |
+
|
| 471 |
+
### Sprint 7: Documentation (1 hour)
|
| 472 |
+
1. Update README with new gameplay
|
| 473 |
+
2. Create migration guide
|
| 474 |
+
3. Update screenshots/GIFs
|
| 475 |
+
4. Announce v0.0.1 release
|
| 476 |
+
|
| 477 |
+
---
|
| 478 |
+
|
| 479 |
+
## Risk Assessment
|
| 480 |
+
|
| 481 |
+
### High Risk
|
| 482 |
+
- **Grid rendering performance** - 8×6 may need optimization
|
| 483 |
+
- **Challenge mode compatibility** - Breaking changes to storage format
|
| 484 |
+
|
| 485 |
+
### Medium Risk
|
| 486 |
+
- **Word list compatibility** - Need sufficient 3-8 letter words
|
| 487 |
+
- **User confusion** - Free letter selection might need tutorial
|
| 488 |
+
|
| 489 |
+
### Low Risk
|
| 490 |
+
- **CSS layout** - Rectangular grid is simpler than square
|
| 491 |
+
- **Scoring system** - Logic remains mostly unchanged
|
| 492 |
+
|
| 493 |
+
---
|
| 494 |
+
|
| 495 |
+
## Success Criteria
|
| 496 |
+
|
| 497 |
+
### Must Have (v0.0.1)
|
| 498 |
+
- ✅ 8×6 rectangular grid displays correctly
|
| 499 |
+
- ✅ Only horizontal words (6 total, one per row)
|
| 500 |
+
- ✅ No radar/scope visualization
|
| 501 |
+
- ✅ 2 free letter guesses at game start
|
| 502 |
+
- ✅ Game completes and scores correctly
|
| 503 |
+
- ✅ Challenge mode works with new format
|
| 504 |
+
|
| 505 |
+
### Nice to Have (v0.1.0+)
|
| 506 |
+
- Difficulty levels (word length variations)
|
| 507 |
+
- Tutorial/onboarding for new users
|
| 508 |
+
- Animated letter reveal for free letters
|
| 509 |
+
- Statistics tracking for free letter choices
|
| 510 |
+
|
| 511 |
+
---
|
| 512 |
+
|
| 513 |
+
## Next Steps
|
| 514 |
+
|
| 515 |
+
1. **Review this plan** with stakeholders
|
| 516 |
+
2. **Set up development branch** (`wrdler-v0.0.1-implementation`)
|
| 517 |
+
3. **Begin Sprint 1** (Core Data Models)
|
| 518 |
+
4. **Iterate and adjust** based on findings
|
| 519 |
+
|
| 520 |
+
---
|
| 521 |
+
|
| 522 |
+
## Notes
|
| 523 |
+
- Keep BattleWords code in git history for reference
|
| 524 |
+
- Consider feature flags for gradual rollout
|
| 525 |
+
- Monitor user feedback closely after launch
|
src/streamlit_app.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import altair as alt
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
"""
|
| 7 |
+
# Welcome to Streamlit!
|
| 8 |
+
|
| 9 |
+
Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
|
| 10 |
+
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 11 |
+
forums](https://discuss.streamlit.io).
|
| 12 |
+
|
| 13 |
+
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
+
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
+
|
| 19 |
+
indices = np.linspace(0, 1, num_points)
|
| 20 |
+
theta = 2 * np.pi * num_turns * indices
|
| 21 |
+
radius = indices
|
| 22 |
+
|
| 23 |
+
x = radius * np.cos(theta)
|
| 24 |
+
y = radius * np.sin(theta)
|
| 25 |
+
|
| 26 |
+
df = pd.DataFrame({
|
| 27 |
+
"x": x,
|
| 28 |
+
"y": y,
|
| 29 |
+
"idx": indices,
|
| 30 |
+
"rand": np.random.randn(num_points),
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
+
.mark_point(filled=True)
|
| 35 |
+
.encode(
|
| 36 |
+
x=alt.X("x", axis=None),
|
| 37 |
+
y=alt.Y("y", axis=None),
|
| 38 |
+
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
+
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
+
))
|
static/icon-192.png
ADDED
|
|
static/icon-512.png
ADDED
|
|
static/manifest.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Wrdler",
|
| 3 |
+
"short_name": "Wrdler",
|
| 4 |
+
"description": "Simplified vocabulary puzzle game based on BattleWords. Discover hidden words on an 8x6 grid with 2 free letter guesses.",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"scope": "/",
|
| 7 |
+
"display": "standalone",
|
| 8 |
+
"orientation": "portrait",
|
| 9 |
+
"background_color": "#0b2a4a",
|
| 10 |
+
"theme_color": "#165ba8",
|
| 11 |
+
"icons": [
|
| 12 |
+
{
|
| 13 |
+
"src": "/app/static/icon-192.png",
|
| 14 |
+
"sizes": "192x192",
|
| 15 |
+
"type": "image/png",
|
| 16 |
+
"purpose": "any maskable"
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"src": "/app/static/icon-512.png",
|
| 20 |
+
"sizes": "512x512",
|
| 21 |
+
"type": "image/png",
|
| 22 |
+
"purpose": "any maskable"
|
| 23 |
+
}
|
| 24 |
+
],
|
| 25 |
+
"categories": ["games", "education"],
|
| 26 |
+
"screenshots": []
|
| 27 |
+
}
|
static/service-worker.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Wrdler Service Worker
|
| 3 |
+
* Enables PWA functionality: offline caching, install prompt, etc.
|
| 4 |
+
*
|
| 5 |
+
* Security Note: This file contains no secrets or sensitive data.
|
| 6 |
+
* It only caches public assets for offline access.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
const CACHE_NAME = 'wrdler-v0.0.1';
|
| 10 |
+
const RUNTIME_CACHE = 'wrdler-runtime';
|
| 11 |
+
|
| 12 |
+
// Assets to cache on install (minimal for faster install)
|
| 13 |
+
const PRECACHE_URLS = [
|
| 14 |
+
'/',
|
| 15 |
+
'/app/static/manifest.json',
|
| 16 |
+
'/app/static/icon-192.png',
|
| 17 |
+
'/app/static/icon-512.png'
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
// Install event - cache essential files
|
| 21 |
+
self.addEventListener('install', event => {
|
| 22 |
+
console.log('[ServiceWorker] Installing...');
|
| 23 |
+
event.waitUntil(
|
| 24 |
+
caches.open(CACHE_NAME)
|
| 25 |
+
.then(cache => {
|
| 26 |
+
console.log('[ServiceWorker] Precaching app shell');
|
| 27 |
+
return cache.addAll(PRECACHE_URLS);
|
| 28 |
+
})
|
| 29 |
+
.then(() => self.skipWaiting()) // Activate immediately
|
| 30 |
+
);
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
// Activate event - clean up old caches
|
| 34 |
+
self.addEventListener('activate', event => {
|
| 35 |
+
console.log('[ServiceWorker] Activating...');
|
| 36 |
+
event.waitUntil(
|
| 37 |
+
caches.keys().then(cacheNames => {
|
| 38 |
+
return Promise.all(
|
| 39 |
+
cacheNames.map(cacheName => {
|
| 40 |
+
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
|
| 41 |
+
console.log('[ServiceWorker] Deleting old cache:', cacheName);
|
| 42 |
+
return caches.delete(cacheName);
|
| 43 |
+
}
|
| 44 |
+
})
|
| 45 |
+
);
|
| 46 |
+
}).then(() => self.clients.claim()) // Take control immediately
|
| 47 |
+
);
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
// Fetch event - network first, fall back to cache
|
| 51 |
+
self.addEventListener('fetch', event => {
|
| 52 |
+
// Skip non-GET requests
|
| 53 |
+
if (event.request.method !== 'GET') {
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Skip chrome-extension and other non-http requests
|
| 58 |
+
if (!event.request.url.startsWith('http')) {
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
event.respondWith(
|
| 63 |
+
caches.open(RUNTIME_CACHE).then(cache => {
|
| 64 |
+
return fetch(event.request)
|
| 65 |
+
.then(response => {
|
| 66 |
+
// Cache successful responses for future offline access
|
| 67 |
+
if (response.status === 200) {
|
| 68 |
+
cache.put(event.request, response.clone());
|
| 69 |
+
}
|
| 70 |
+
return response;
|
| 71 |
+
})
|
| 72 |
+
.catch(() => {
|
| 73 |
+
// Network failed, try cache
|
| 74 |
+
return caches.match(event.request).then(cachedResponse => {
|
| 75 |
+
if (cachedResponse) {
|
| 76 |
+
console.log('[ServiceWorker] Serving from cache:', event.request.url);
|
| 77 |
+
return cachedResponse;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// No cache available, return offline page or error
|
| 81 |
+
return new Response('Offline - Please check your connection', {
|
| 82 |
+
status: 503,
|
| 83 |
+
statusText: 'Service Unavailable',
|
| 84 |
+
headers: new Headers({
|
| 85 |
+
'Content-Type': 'text/plain'
|
| 86 |
+
})
|
| 87 |
+
});
|
| 88 |
+
});
|
| 89 |
+
});
|
| 90 |
+
})
|
| 91 |
+
);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
// Message event - handle commands from the app
|
| 95 |
+
self.addEventListener('message', event => {
|
| 96 |
+
if (event.data.action === 'skipWaiting') {
|
| 97 |
+
self.skipWaiting();
|
| 98 |
+
}
|
| 99 |
+
});
|
tests/test_apptest.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: D:/Projects/Battlewords/tests/test_apptest.py
|
| 2 |
+
from streamlit.testing.v1 import AppTest
|
| 3 |
+
|
| 4 |
+
def test_app_runs():
|
| 5 |
+
at = AppTest.from_file("app.py")
|
| 6 |
+
at.run()
|
| 7 |
+
assert not at.exception
|
tests/test_compare_difficulty_functions.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: tests/test_compare_difficulty_functions.py
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
# Ensure the modules path is available
|
| 7 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
+
|
| 9 |
+
from wrdler.modules.constants import HF_API_TOKEN
|
| 10 |
+
from wrdler.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 11 |
+
from wrdler.word_loader import compute_word_difficulties, compute_word_difficulties2, compute_word_difficulties3
|
| 12 |
+
|
| 13 |
+
# Ensure the token is set for Hugging Face Hub
|
| 14 |
+
if HF_API_TOKEN:
|
| 15 |
+
os.environ["HF_API_TOKEN"] = HF_API_TOKEN
|
| 16 |
+
|
| 17 |
+
# Define sample_words as a global variable
|
| 18 |
+
sample_words = []
|
| 19 |
+
|
| 20 |
+
def test_compare_difficulty_functions_for_challenge(capsys):
|
| 21 |
+
"""
|
| 22 |
+
Compare compute_word_difficulties, compute_word_difficulties2, and compute_word_difficulties3
|
| 23 |
+
for all users in a challenge identified by short_id.
|
| 24 |
+
"""
|
| 25 |
+
global sample_words # Ensure we modify the global variable
|
| 26 |
+
|
| 27 |
+
# Use a fixed short id for testing
|
| 28 |
+
short_id = "hDjsB_dl"
|
| 29 |
+
|
| 30 |
+
# Step 1: Resolve short ID to full URL
|
| 31 |
+
status, full_url = gen_full_url(
|
| 32 |
+
short_url=short_id,
|
| 33 |
+
repo_id=HF_REPO_ID,
|
| 34 |
+
json_file=SHORTENER_JSON_FILE
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
if status != "success_retrieved_full" or not full_url:
|
| 38 |
+
print(
|
| 39 |
+
f"Could not resolve short id '{short_id}'. "
|
| 40 |
+
f"Status: {status}. "
|
| 41 |
+
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 42 |
+
)
|
| 43 |
+
captured = capsys.readouterr()
|
| 44 |
+
assert "Could not resolve short id" in captured.out
|
| 45 |
+
assert not full_url, "full_url should be empty/None on failure"
|
| 46 |
+
print("settings.json was not found or could not be resolved.")
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
print(f"✓ Resolved short id '{short_id}' to full URL: {full_url}")
|
| 50 |
+
|
| 51 |
+
# Step 2: Extract file path from full URL
|
| 52 |
+
url_parts = full_url.split("/resolve/main/")
|
| 53 |
+
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 54 |
+
file_path = url_parts[1]
|
| 55 |
+
|
| 56 |
+
# Step 3: Download and parse settings.json
|
| 57 |
+
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 58 |
+
assert settings, "Failed to download or parse settings.json"
|
| 59 |
+
print(f"✓ Downloaded settings.json")
|
| 60 |
+
|
| 61 |
+
# Validate settings structure
|
| 62 |
+
assert "challenge_id" in settings
|
| 63 |
+
assert "wordlist_source" in settings
|
| 64 |
+
assert "users" in settings
|
| 65 |
+
|
| 66 |
+
wordlist_source = settings.get("wordlist_source", "wordlist.txt")
|
| 67 |
+
users = settings.get("users", [])
|
| 68 |
+
|
| 69 |
+
print(f"\nChallenge ID: {settings['challenge_id']}")
|
| 70 |
+
print(f"Wordlist Source: {wordlist_source}")
|
| 71 |
+
print(f"Number of Users: {len(users)}")
|
| 72 |
+
|
| 73 |
+
# Step 4: Determine wordlist file path
|
| 74 |
+
# Assuming the wordlist is in battlewords/words/ directory
|
| 75 |
+
words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
|
| 76 |
+
wordlist_path = os.path.join(words_dir, wordlist_source)
|
| 77 |
+
|
| 78 |
+
# If wordlist doesn't exist, try classic.txt as fallback
|
| 79 |
+
if not os.path.exists(wordlist_path):
|
| 80 |
+
print(f"⚠ Wordlist '{wordlist_source}' not found, using 'classic.txt' as fallback")
|
| 81 |
+
wordlist_path = os.path.join(words_dir, "classic.txt")
|
| 82 |
+
|
| 83 |
+
assert os.path.exists(wordlist_path), f"Wordlist file not found: {wordlist_path}"
|
| 84 |
+
print(f"✓ Using wordlist: {wordlist_path}")
|
| 85 |
+
|
| 86 |
+
# Step 5: Compare difficulty functions for each user
|
| 87 |
+
print("\n" + "="*80)
|
| 88 |
+
print("DIFFICULTY COMPARISON BY USER")
|
| 89 |
+
print("="*80)
|
| 90 |
+
|
| 91 |
+
all_results = []
|
| 92 |
+
|
| 93 |
+
for user_idx, user in enumerate(users, 1):
|
| 94 |
+
user_name = user.get("name", f"User {user_idx}")
|
| 95 |
+
word_list = user.get("word_list", [])
|
| 96 |
+
sample_words += word_list # Update the global variable with the latest word list
|
| 97 |
+
|
| 98 |
+
if not word_list:
|
| 99 |
+
print(f"\n[{user_idx}] {user_name}: No words assigned, skipping")
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
print(f"\n[{user_idx}] {user_name}")
|
| 103 |
+
print(f" Words: {len(word_list)} words")
|
| 104 |
+
print(f" Sample: {', '.join(word_list[:5])}{'...' if len(word_list) > 5 else ''}")
|
| 105 |
+
|
| 106 |
+
# Compute difficulties using all three functions
|
| 107 |
+
total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, word_list)
|
| 108 |
+
total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, word_list)
|
| 109 |
+
total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, word_list)
|
| 110 |
+
|
| 111 |
+
print(f"\n Function 1 (compute_word_difficulties):")
|
| 112 |
+
print(f" Total Difficulty: {total_diff1:.4f}")
|
| 113 |
+
print(f" Words Processed: {len(difficulties1)}")
|
| 114 |
+
|
| 115 |
+
print(f"\n Function 2 (compute_word_difficulties2):")
|
| 116 |
+
print(f" Total Difficulty: {total_diff2:.4f}")
|
| 117 |
+
print(f" Words Processed: {len(difficulties2)}")
|
| 118 |
+
|
| 119 |
+
print(f"\n Function 3 (compute_word_difficulties3):")
|
| 120 |
+
print(f" Total Difficulty: {total_diff3:.4f}")
|
| 121 |
+
print(f" Words Processed: {len(difficulties3)}")
|
| 122 |
+
|
| 123 |
+
# Calculate statistics
|
| 124 |
+
if difficulties1 and difficulties2 and difficulties3:
|
| 125 |
+
avg_diff1 = total_diff1 / len(difficulties1)
|
| 126 |
+
avg_diff2 = total_diff2 / len(difficulties2)
|
| 127 |
+
avg_diff3 = total_diff3 / len(difficulties3)
|
| 128 |
+
|
| 129 |
+
print(f"\n Comparison:")
|
| 130 |
+
print(f" Average Difficulty (Func1): {avg_diff1:.4f}")
|
| 131 |
+
print(f" Average Difficulty (Func2): {avg_diff2:.4f}")
|
| 132 |
+
print(f" Average Difficulty (Func3): {avg_diff3:.4f}")
|
| 133 |
+
print(f" Difference (Func1 vs Func2): {abs(avg_diff1 - avg_diff2):.4f}")
|
| 134 |
+
print(f" Difference (Func1 vs Func3): {abs(avg_diff1 - avg_diff3):.4f}")
|
| 135 |
+
print(f" Difference (Func2 vs Func3): {abs(avg_diff2 - avg_diff3):.4f}")
|
| 136 |
+
|
| 137 |
+
# Store results for final summary
|
| 138 |
+
all_results.append({
|
| 139 |
+
"user_name": user_name,
|
| 140 |
+
"word_count": len(word_list),
|
| 141 |
+
"total_diff1": total_diff1,
|
| 142 |
+
"total_diff2": total_diff2,
|
| 143 |
+
"total_diff3": total_diff3,
|
| 144 |
+
"difficulties1": difficulties1,
|
| 145 |
+
"difficulties2": difficulties2,
|
| 146 |
+
"difficulties3": difficulties3,
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
# Step 6: Print summary comparison
|
| 150 |
+
print("\n" + "="*80)
|
| 151 |
+
print("OVERALL SUMMARY")
|
| 152 |
+
print("="*80)
|
| 153 |
+
|
| 154 |
+
if all_results:
|
| 155 |
+
total1_sum = sum(r["total_diff1"] for r in all_results)
|
| 156 |
+
total2_sum = sum(r["total_diff2"] for r in all_results)
|
| 157 |
+
total3_sum = sum(r["total_diff3"] for r in all_results)
|
| 158 |
+
total_words = sum(r["word_count"] for r in all_results)
|
| 159 |
+
|
| 160 |
+
print(f"\nTotal Users Analyzed: {len(all_results)}")
|
| 161 |
+
print(f"Total Words Across All Users: {total_words}")
|
| 162 |
+
print(f"\nAggregate Total Difficulty:")
|
| 163 |
+
print(f" Function 1: {total1_sum:.4f}")
|
| 164 |
+
print(f" Function 2: {total2_sum:.4f}")
|
| 165 |
+
print(f" Function 3: {total3_sum:.4f}")
|
| 166 |
+
print(f" Difference (Func1 vs Func2): {abs(total1_sum - total2_sum):.4f}")
|
| 167 |
+
print(f" Difference (Func1 vs Func3): {abs(total1_sum - total3_sum):.4f}")
|
| 168 |
+
print(f" Difference (Func2 vs Func3): {abs(total2_sum - total3_sum):.4f}")
|
| 169 |
+
|
| 170 |
+
# Validate that all functions returned results for all users
|
| 171 |
+
assert all(r["difficulties1"] for r in all_results), "Function 1 failed for some users"
|
| 172 |
+
assert all(r["difficulties2"] for r in all_results), "Function 2 failed for some users"
|
| 173 |
+
assert all(r["difficulties3"] for r in all_results), "Function 3 failed for some users"
|
| 174 |
+
|
| 175 |
+
print("\n✓ All tests passed!")
|
| 176 |
+
else:
|
| 177 |
+
print("\n⚠ No users with words found in this challenge")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_compare_difficulty_functions_with_classic_wordlist():
|
| 181 |
+
"""
|
| 182 |
+
Test all three difficulty functions using the classic.txt wordlist
|
| 183 |
+
with a sample set of words.
|
| 184 |
+
"""
|
| 185 |
+
global sample_words # Use the global variable
|
| 186 |
+
|
| 187 |
+
words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
|
| 188 |
+
wordlist_path = os.path.join(words_dir, "classic.txt")
|
| 189 |
+
|
| 190 |
+
if not os.path.exists(wordlist_path):
|
| 191 |
+
pytest.skip(f"classic.txt not found at {wordlist_path}")
|
| 192 |
+
|
| 193 |
+
# Use the global sample_words if already populated, otherwise set a default
|
| 194 |
+
if not sample_words:
|
| 195 |
+
sample_words = ["ABLE", "ACID", "AREA", "ARMY", "BEAR", "BOWL", "CAVE", "COIN", "ECHO", "GOLD"]
|
| 196 |
+
|
| 197 |
+
print("\n" + "="*80)
|
| 198 |
+
print("TESTING WITH CLASSIC.TXT WORDLIST")
|
| 199 |
+
print("="*80)
|
| 200 |
+
print(f"Sample Words: {', '.join(sample_words)}")
|
| 201 |
+
|
| 202 |
+
# Compute difficulties
|
| 203 |
+
total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, sample_words)
|
| 204 |
+
total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, sample_words)
|
| 205 |
+
total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, sample_words)
|
| 206 |
+
|
| 207 |
+
print(f"\nFunction compute_word_difficulties Results:")
|
| 208 |
+
print(f" Total Difficulty: {total_diff1:.4f}")
|
| 209 |
+
for word in sample_words:
|
| 210 |
+
if word in difficulties1:
|
| 211 |
+
print(f" {word}: {difficulties1[word]:.4f}")
|
| 212 |
+
|
| 213 |
+
print(f"\nFunction compute_word_difficulties2 Results:")
|
| 214 |
+
print(f" Total Difficulty: {total_diff2:.4f}")
|
| 215 |
+
for word in sample_words:
|
| 216 |
+
if word in difficulties2:
|
| 217 |
+
print(f" {word}: {difficulties2[word]:.4f}")
|
| 218 |
+
|
| 219 |
+
print(f"\nFunction compute_word_difficulties3 Results:")
|
| 220 |
+
print(f" Total Difficulty: {total_diff3:.4f}")
|
| 221 |
+
for word in sample_words:
|
| 222 |
+
if word in difficulties3:
|
| 223 |
+
print(f" {word}: {difficulties3[word]:.4f}")
|
| 224 |
+
|
| 225 |
+
# Assertions
|
| 226 |
+
assert len(difficulties1) == len(set(sample_words)), "Function 1 didn't process all words"
|
| 227 |
+
assert len(difficulties2) == len(set(sample_words)), "Function 2 didn't process all words"
|
| 228 |
+
assert len(difficulties3) == len(set(sample_words)), "Function 3 didn't process all words"
|
| 229 |
+
assert total_diff1 > 0, "Function 1 total difficulty should be positive"
|
| 230 |
+
assert total_diff2 > 0, "Function 2 total difficulty should be positive"
|
| 231 |
+
assert total_diff3 > 0, "Function 3 total difficulty should be positive"
|
| 232 |
+
|
| 233 |
+
print("\n✓ Classic wordlist test passed!")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
pytest.main(["-s", "-v", __file__])
|
tests/test_download_game_settings.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: tests/test_download_game_settings.py
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
# Ensure the modules path is available
|
| 7 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
+
|
| 9 |
+
from wrdler.modules.constants import HF_API_TOKEN # <-- Import the token
|
| 10 |
+
from wrdler.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 11 |
+
|
| 12 |
+
# Ensure the token is set for Hugging Face Hub
|
| 13 |
+
if HF_API_TOKEN:
|
| 14 |
+
os.environ["HF_API_TOKEN"] = HF_API_TOKEN
|
| 15 |
+
|
| 16 |
+
def test_download_settings_by_short_id_handles_both(capsys):
|
| 17 |
+
# Use a fixed short id for testing
|
| 18 |
+
short_id = "hDjsB_dl"
|
| 19 |
+
|
| 20 |
+
# Step 1: Resolve short ID to full URL
|
| 21 |
+
status, full_url = gen_full_url(
|
| 22 |
+
short_url=short_id,
|
| 23 |
+
repo_id=HF_REPO_ID,
|
| 24 |
+
json_file=SHORTENER_JSON_FILE
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Failure branch: provide a helpful message and assert expected failure shape
|
| 28 |
+
if status != "success_retrieved_full" or not full_url:
|
| 29 |
+
print(
|
| 30 |
+
f"Could not resolve short id '{short_id}'. "
|
| 31 |
+
f"Status: {status}. "
|
| 32 |
+
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 33 |
+
)
|
| 34 |
+
captured = capsys.readouterr()
|
| 35 |
+
assert "Could not resolve short id" in captured.out
|
| 36 |
+
# Ensure failure shape is consistent
|
| 37 |
+
assert not full_url, "full_url should be empty/None on failure"
|
| 38 |
+
print("settings.json was not found or could not be resolved.")
|
| 39 |
+
return
|
| 40 |
+
else:
|
| 41 |
+
print(f"Resolved short id '{short_id}' to full URL: {full_url}")
|
| 42 |
+
|
| 43 |
+
# Success branch
|
| 44 |
+
assert status == "success_retrieved_full", f"Failed to resolve short ID: {status}"
|
| 45 |
+
assert full_url, "No full URL returned"
|
| 46 |
+
|
| 47 |
+
# Step 2: Extract file path from full URL
|
| 48 |
+
url_parts = full_url.split("/resolve/main/")
|
| 49 |
+
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 50 |
+
file_path = url_parts[1]
|
| 51 |
+
|
| 52 |
+
# Step 3: Download and parse settings.json
|
| 53 |
+
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 54 |
+
assert settings, "Failed to download or parse settings.json"
|
| 55 |
+
|
| 56 |
+
print("\nDownloaded settings.json contents:", settings)
|
| 57 |
+
# Optionally, add more assertions about the settings structure
|
| 58 |
+
assert "challenge_id" in settings
|
| 59 |
+
assert "wordlist_source" in settings
|
| 60 |
+
assert "users" in settings
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
pytest.main(["-s", __file__])
|
tests/test_generator.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from wrdler.generator import generate_puzzle, validate_puzzle
|
| 4 |
+
from wrdler.models import Coord
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestGenerator(unittest.TestCase):
|
| 8 |
+
def test_generate_valid_puzzle(self):
|
| 9 |
+
# Provide a minimal word pool for deterministic testing
|
| 10 |
+
words_by_len = {
|
| 11 |
+
4: ["TREE", "BOAT"],
|
| 12 |
+
5: ["APPLE", "RIVER"],
|
| 13 |
+
6: ["ORANGE", "PYTHON"],
|
| 14 |
+
}
|
| 15 |
+
p = generate_puzzle(grid_size=12, words_by_len=words_by_len, seed=1234)
|
| 16 |
+
validate_puzzle(p, grid_size=12)
|
| 17 |
+
# Ensure 6 words and 6 radar pulses
|
| 18 |
+
self.assertEqual(len(p.words), 6)
|
| 19 |
+
self.assertEqual(len(p.radar), 6)
|
| 20 |
+
# Ensure no overlaps
|
| 21 |
+
seen = set()
|
| 22 |
+
for w in p.words:
|
| 23 |
+
for c in w.cells:
|
| 24 |
+
self.assertNotIn(c, seen)
|
| 25 |
+
seen.add(c)
|
| 26 |
+
self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
unittest.main()
|
tests/test_logic.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from wrdler.logic import build_letter_map, reveal_cell, guess_word, is_game_over
|
| 4 |
+
from wrdler.models import Coord, Word, Puzzle, GameState
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestLogic(unittest.TestCase):
|
| 8 |
+
def make_state(self):
|
| 9 |
+
w1 = Word("TREE", Coord(0, 0), "H")
|
| 10 |
+
w2 = Word("APPLE", Coord(2, 0), "H")
|
| 11 |
+
w3 = Word("ORANGE", Coord(4, 0), "H")
|
| 12 |
+
w4 = Word("WIND", Coord(0, 6), "V")
|
| 13 |
+
w5 = Word("MOUSE", Coord(0, 8), "V")
|
| 14 |
+
w6 = Word("PYTHON", Coord(0, 10), "V")
|
| 15 |
+
p = Puzzle([w1, w2, w3, w4, w5, w6])
|
| 16 |
+
state = GameState(
|
| 17 |
+
grid_size=12,
|
| 18 |
+
puzzle=p,
|
| 19 |
+
revealed=set(),
|
| 20 |
+
guessed=set(),
|
| 21 |
+
score=0,
|
| 22 |
+
last_action="",
|
| 23 |
+
can_guess=False,
|
| 24 |
+
)
|
| 25 |
+
return state, p
|
| 26 |
+
|
| 27 |
+
def test_reveal_and_guess_gating(self):
|
| 28 |
+
state, puzzle = self.make_state()
|
| 29 |
+
letter_map = build_letter_map(puzzle)
|
| 30 |
+
# Can't guess before reveal
|
| 31 |
+
ok, pts = guess_word(state, "TREE")
|
| 32 |
+
self.assertFalse(ok)
|
| 33 |
+
self.assertEqual(pts, 0)
|
| 34 |
+
# Reveal one cell then guess
|
| 35 |
+
reveal_cell(state, letter_map, Coord(0, 0))
|
| 36 |
+
self.assertTrue(state.can_guess)
|
| 37 |
+
ok, pts = guess_word(state, "TREE")
|
| 38 |
+
self.assertTrue(ok)
|
| 39 |
+
self.assertGreater(pts, 0)
|
| 40 |
+
self.assertIn("TREE", state.guessed)
|
| 41 |
+
self.assertFalse(state.can_guess)
|
| 42 |
+
|
| 43 |
+
def test_game_over(self):
|
| 44 |
+
state, puzzle = self.make_state()
|
| 45 |
+
letter_map = build_letter_map(puzzle)
|
| 46 |
+
# Guess all words after a reveal each time
|
| 47 |
+
for w in puzzle.words:
|
| 48 |
+
reveal_cell(state, letter_map, w.start)
|
| 49 |
+
ok, _ = guess_word(state, w.text)
|
| 50 |
+
self.assertTrue(ok)
|
| 51 |
+
self.assertTrue(is_game_over(state))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
unittest.main()
|
uv.lock
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version = 1
|
| 2 |
+
revision = 3
|
| 3 |
+
requires-python = "==3.12.*"
|
| 4 |
+
|
| 5 |
+
[[package]]
|
| 6 |
+
name = "altair"
|
| 7 |
+
version = "5.5.0"
|
| 8 |
+
source = { registry = "https://pypi.org/simple" }
|
| 9 |
+
dependencies = [
|
| 10 |
+
{ name = "jinja2" },
|
| 11 |
+
{ name = "jsonschema" },
|
| 12 |
+
{ name = "narwhals" },
|
| 13 |
+
{ name = "packaging" },
|
| 14 |
+
{ name = "typing-extensions" },
|
| 15 |
+
]
|
| 16 |
+
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" }
|
| 17 |
+
wheels = [
|
| 18 |
+
{ 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" },
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
[[package]]
|
| 22 |
+
name = "attrs"
|
| 23 |
+
version = "25.3.0"
|
| 24 |
+
source = { registry = "https://pypi.org/simple" }
|
| 25 |
+
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" }
|
| 26 |
+
wheels = [
|
| 27 |
+
{ 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" },
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[[package]]
|
| 31 |
+
name = "battlewords"
|
| 32 |
+
version = "0.1.0"
|
| 33 |
+
source = { editable = "." }
|
| 34 |
+
dependencies = [
|
| 35 |
+
{ name = "matplotlib" },
|
| 36 |
+
{ name = "streamlit" },
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
[package.metadata]
|
| 40 |
+
requires-dist = [
|
| 41 |
+
{ name = "matplotlib", specifier = ">=3.8" },
|
| 42 |
+
{ name = "streamlit", specifier = ">=1.50.0" },
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
[[package]]
|
| 46 |
+
name = "blinker"
|
| 47 |
+
version = "1.9.0"
|
| 48 |
+
source = { registry = "https://pypi.org/simple" }
|
| 49 |
+
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" }
|
| 50 |
+
wheels = [
|
| 51 |
+
{ 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" },
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
[[package]]
|
| 55 |
+
name = "cachetools"
|
| 56 |
+
version = "6.2.0"
|
| 57 |
+
source = { registry = "https://pypi.org/simple" }
|
| 58 |
+
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" }
|
| 59 |
+
wheels = [
|
| 60 |
+
{ 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" },
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
[[package]]
|
| 64 |
+
name = "certifi"
|
| 65 |
+
version = "2025.8.3"
|
| 66 |
+
source = { registry = "https://pypi.org/simple" }
|
| 67 |
+
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" }
|
| 68 |
+
wheels = [
|
| 69 |
+
{ 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" },
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
[[package]]
|
| 73 |
+
name = "charset-normalizer"
|
| 74 |
+
version = "3.4.3"
|
| 75 |
+
source = { registry = "https://pypi.org/simple" }
|
| 76 |
+
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" }
|
| 77 |
+
wheels = [
|
| 78 |
+
{ 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" },
|
| 79 |
+
{ 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" },
|
| 80 |
+
{ 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" },
|
| 81 |
+
{ 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" },
|
| 82 |
+
{ 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" },
|
| 83 |
+
{ 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" },
|
| 84 |
+
{ 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" },
|
| 85 |
+
{ 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" },
|
| 86 |
+
{ 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" },
|
| 87 |
+
{ 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" },
|
| 88 |
+
{ 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" },
|
| 89 |
+
{ 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" },
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
[[package]]
|
| 93 |
+
name = "click"
|
| 94 |
+
version = "8.3.0"
|
| 95 |
+
source = { registry = "https://pypi.org/simple" }
|
| 96 |
+
dependencies = [
|
| 97 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 98 |
+
]
|
| 99 |
+
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" }
|
| 100 |
+
wheels = [
|
| 101 |
+
{ 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" },
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
[[package]]
|
| 105 |
+
name = "colorama"
|
| 106 |
+
version = "0.4.6"
|
| 107 |
+
source = { registry = "https://pypi.org/simple" }
|
| 108 |
+
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" }
|
| 109 |
+
wheels = [
|
| 110 |
+
{ 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" },
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
[[package]]
|
| 114 |
+
name = "contourpy"
|
| 115 |
+
version = "1.3.3"
|
| 116 |
+
source = { registry = "https://pypi.org/simple" }
|
| 117 |
+
dependencies = [
|
| 118 |
+
{ name = "numpy" },
|
| 119 |
+
]
|
| 120 |
+
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" }
|
| 121 |
+
wheels = [
|
| 122 |
+
{ 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" },
|
| 123 |
+
{ 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" },
|
| 124 |
+
{ 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" },
|
| 125 |
+
{ 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" },
|
| 126 |
+
{ 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" },
|
| 127 |
+
{ 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" },
|
| 128 |
+
{ 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" },
|
| 129 |
+
{ 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" },
|
| 130 |
+
{ 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" },
|
| 131 |
+
{ 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" },
|
| 132 |
+
{ 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" },
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
[[package]]
|
| 136 |
+
name = "cycler"
|
| 137 |
+
version = "0.12.1"
|
| 138 |
+
source = { registry = "https://pypi.org/simple" }
|
| 139 |
+
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" }
|
| 140 |
+
wheels = [
|
| 141 |
+
{ 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" },
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
[[package]]
|
| 145 |
+
name = "fonttools"
|
| 146 |
+
version = "4.60.1"
|
| 147 |
+
source = { registry = "https://pypi.org/simple" }
|
| 148 |
+
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" }
|
| 149 |
+
wheels = [
|
| 150 |
+
{ 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" },
|
| 151 |
+
{ 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" },
|
| 152 |
+
{ 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" },
|
| 153 |
+
{ 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" },
|
| 154 |
+
{ 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" },
|
| 155 |
+
{ 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" },
|
| 156 |
+
{ 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" },
|
| 157 |
+
{ 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" },
|
| 158 |
+
{ 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" },
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
[[package]]
|
| 162 |
+
name = "gitdb"
|
| 163 |
+
version = "4.0.12"
|
| 164 |
+
source = { registry = "https://pypi.org/simple" }
|
| 165 |
+
dependencies = [
|
| 166 |
+
{ name = "smmap" },
|
| 167 |
+
]
|
| 168 |
+
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" }
|
| 169 |
+
wheels = [
|
| 170 |
+
{ 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" },
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
[[package]]
|
| 174 |
+
name = "gitpython"
|
| 175 |
+
version = "3.1.45"
|
| 176 |
+
source = { registry = "https://pypi.org/simple" }
|
| 177 |
+
dependencies = [
|
| 178 |
+
{ name = "gitdb" },
|
| 179 |
+
]
|
| 180 |
+
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" }
|
| 181 |
+
wheels = [
|
| 182 |
+
{ 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" },
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
[[package]]
|
| 186 |
+
name = "idna"
|
| 187 |
+
version = "3.10"
|
| 188 |
+
source = { registry = "https://pypi.org/simple" }
|
| 189 |
+
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" }
|
| 190 |
+
wheels = [
|
| 191 |
+
{ 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" },
|
| 192 |
+
]
|
| 193 |
+
|
| 194 |
+
[[package]]
|
| 195 |
+
name = "jinja2"
|
| 196 |
+
version = "3.1.6"
|
| 197 |
+
source = { registry = "https://pypi.org/simple" }
|
| 198 |
+
dependencies = [
|
| 199 |
+
{ name = "markupsafe" },
|
| 200 |
+
]
|
| 201 |
+
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" }
|
| 202 |
+
wheels = [
|
| 203 |
+
{ 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" },
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
[[package]]
|
| 207 |
+
name = "jsonschema"
|
| 208 |
+
version = "4.25.1"
|
| 209 |
+
source = { registry = "https://pypi.org/simple" }
|
| 210 |
+
dependencies = [
|
| 211 |
+
{ name = "attrs" },
|
| 212 |
+
{ name = "jsonschema-specifications" },
|
| 213 |
+
{ name = "referencing" },
|
| 214 |
+
{ name = "rpds-py" },
|
| 215 |
+
]
|
| 216 |
+
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" }
|
| 217 |
+
wheels = [
|
| 218 |
+
{ 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" },
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
[[package]]
|
| 222 |
+
name = "jsonschema-specifications"
|
| 223 |
+
version = "2025.9.1"
|
| 224 |
+
source = { registry = "https://pypi.org/simple" }
|
| 225 |
+
dependencies = [
|
| 226 |
+
{ name = "referencing" },
|
| 227 |
+
]
|
| 228 |
+
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" }
|
| 229 |
+
wheels = [
|
| 230 |
+
{ 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" },
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
[[package]]
|
| 234 |
+
name = "kiwisolver"
|
| 235 |
+
version = "1.4.9"
|
| 236 |
+
source = { registry = "https://pypi.org/simple" }
|
| 237 |
+
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" }
|
| 238 |
+
wheels = [
|
| 239 |
+
{ 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" },
|
| 240 |
+
{ 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" },
|
| 241 |
+
{ 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" },
|
| 242 |
+
{ 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" },
|
| 243 |
+
{ 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" },
|
| 244 |
+
{ 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" },
|
| 245 |
+
{ 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" },
|
| 246 |
+
{ 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" },
|
| 247 |
+
{ 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" },
|
| 248 |
+
{ 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" },
|
| 249 |
+
{ 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" },
|
| 250 |
+
{ 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" },
|
| 251 |
+
{ 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" },
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
[[package]]
|
| 255 |
+
name = "markupsafe"
|
| 256 |
+
version = "3.0.2"
|
| 257 |
+
source = { registry = "https://pypi.org/simple" }
|
| 258 |
+
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" }
|
| 259 |
+
wheels = [
|
| 260 |
+
{ 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" },
|
| 261 |
+
{ 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" },
|
| 262 |
+
{ 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" },
|
| 263 |
+
{ 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" },
|
| 264 |
+
{ 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" },
|
| 265 |
+
{ 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" },
|
| 266 |
+
{ 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" },
|
| 267 |
+
{ 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" },
|
| 268 |
+
{ 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" },
|
| 269 |
+
{ 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" },
|
| 270 |
+
]
|
| 271 |
+
|
| 272 |
+
[[package]]
|
| 273 |
+
name = "matplotlib"
|
| 274 |
+
version = "3.10.6"
|
| 275 |
+
source = { registry = "https://pypi.org/simple" }
|
| 276 |
+
dependencies = [
|
| 277 |
+
{ name = "contourpy" },
|
| 278 |
+
{ name = "cycler" },
|
| 279 |
+
{ name = "fonttools" },
|
| 280 |
+
{ name = "kiwisolver" },
|
| 281 |
+
{ name = "numpy" },
|
| 282 |
+
{ name = "packaging" },
|
| 283 |
+
{ name = "pillow" },
|
| 284 |
+
{ name = "pyparsing" },
|
| 285 |
+
{ name = "python-dateutil" },
|
| 286 |
+
]
|
| 287 |
+
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" }
|
| 288 |
+
wheels = [
|
| 289 |
+
{ 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" },
|
| 290 |
+
{ 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" },
|
| 291 |
+
{ 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" },
|
| 292 |
+
{ 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" },
|
| 293 |
+
{ 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" },
|
| 294 |
+
{ 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" },
|
| 295 |
+
{ 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" },
|
| 296 |
+
]
|
| 297 |
+
|
| 298 |
+
[[package]]
|
| 299 |
+
name = "narwhals"
|
| 300 |
+
version = "2.5.0"
|
| 301 |
+
source = { registry = "https://pypi.org/simple" }
|
| 302 |
+
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" }
|
| 303 |
+
wheels = [
|
| 304 |
+
{ 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" },
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
[[package]]
|
| 308 |
+
name = "numpy"
|
| 309 |
+
version = "2.3.3"
|
| 310 |
+
source = { registry = "https://pypi.org/simple" }
|
| 311 |
+
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" }
|
| 312 |
+
wheels = [
|
| 313 |
+
{ 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" },
|
| 314 |
+
{ 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" },
|
| 315 |
+
{ 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" },
|
| 316 |
+
{ 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" },
|
| 317 |
+
{ 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" },
|
| 318 |
+
{ 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" },
|
| 319 |
+
{ 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" },
|
| 320 |
+
{ 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" },
|
| 321 |
+
{ 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" },
|
| 322 |
+
{ 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" },
|
| 323 |
+
{ 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" },
|
| 324 |
+
]
|
| 325 |
+
|
| 326 |
+
[[package]]
|
| 327 |
+
name = "packaging"
|
| 328 |
+
version = "25.0"
|
| 329 |
+
source = { registry = "https://pypi.org/simple" }
|
| 330 |
+
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" }
|
| 331 |
+
wheels = [
|
| 332 |
+
{ 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" },
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
[[package]]
|
| 336 |
+
name = "pandas"
|
| 337 |
+
version = "2.3.2"
|
| 338 |
+
source = { registry = "https://pypi.org/simple" }
|
| 339 |
+
dependencies = [
|
| 340 |
+
{ name = "numpy" },
|
| 341 |
+
{ name = "python-dateutil" },
|
| 342 |
+
{ name = "pytz" },
|
| 343 |
+
{ name = "tzdata" },
|
| 344 |
+
]
|
| 345 |
+
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" }
|
| 346 |
+
wheels = [
|
| 347 |
+
{ 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" },
|
| 348 |
+
{ 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" },
|
| 349 |
+
{ 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" },
|
| 350 |
+
{ 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" },
|
| 351 |
+
{ 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" },
|
| 352 |
+
{ 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" },
|
| 353 |
+
{ 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" },
|
| 354 |
+
]
|
| 355 |
+
|
| 356 |
+
[[package]]
|
| 357 |
+
name = "pillow"
|
| 358 |
+
version = "11.3.0"
|
| 359 |
+
source = { registry = "https://pypi.org/simple" }
|
| 360 |
+
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" }
|
| 361 |
+
wheels = [
|
| 362 |
+
{ 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" },
|
| 363 |
+
{ 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" },
|
| 364 |
+
{ 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" },
|
| 365 |
+
{ 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" },
|
| 366 |
+
{ 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" },
|
| 367 |
+
{ 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" },
|
| 368 |
+
{ 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" },
|
| 369 |
+
{ 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" },
|
| 370 |
+
{ 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" },
|
| 371 |
+
{ 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" },
|
| 372 |
+
{ 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" },
|
| 373 |
+
]
|
| 374 |
+
|
| 375 |
+
[[package]]
|
| 376 |
+
name = "protobuf"
|
| 377 |
+
version = "6.32.1"
|
| 378 |
+
source = { registry = "https://pypi.org/simple" }
|
| 379 |
+
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" }
|
| 380 |
+
wheels = [
|
| 381 |
+
{ 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" },
|
| 382 |
+
{ 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" },
|
| 383 |
+
{ 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" },
|
| 384 |
+
{ 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" },
|
| 385 |
+
{ 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" },
|
| 386 |
+
{ 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" },
|
| 387 |
+
]
|
| 388 |
+
|
| 389 |
+
[[package]]
|
| 390 |
+
name = "pyarrow"
|
| 391 |
+
version = "21.0.0"
|
| 392 |
+
source = { registry = "https://pypi.org/simple" }
|
| 393 |
+
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" }
|
| 394 |
+
wheels = [
|
| 395 |
+
{ 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" },
|
| 396 |
+
{ 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" },
|
| 397 |
+
{ 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" },
|
| 398 |
+
{ 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" },
|
| 399 |
+
{ 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" },
|
| 400 |
+
{ 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" },
|
| 401 |
+
{ 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" },
|
| 402 |
+
]
|
| 403 |
+
|
| 404 |
+
[[package]]
|
| 405 |
+
name = "pydeck"
|
| 406 |
+
version = "0.9.1"
|
| 407 |
+
source = { registry = "https://pypi.org/simple" }
|
| 408 |
+
dependencies = [
|
| 409 |
+
{ name = "jinja2" },
|
| 410 |
+
{ name = "numpy" },
|
| 411 |
+
]
|
| 412 |
+
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" }
|
| 413 |
+
wheels = [
|
| 414 |
+
{ 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" },
|
| 415 |
+
]
|
| 416 |
+
|
| 417 |
+
[[package]]
|
| 418 |
+
name = "pyparsing"
|
| 419 |
+
version = "3.2.5"
|
| 420 |
+
source = { registry = "https://pypi.org/simple" }
|
| 421 |
+
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" }
|
| 422 |
+
wheels = [
|
| 423 |
+
{ 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" },
|
| 424 |
+
]
|
| 425 |
+
|
| 426 |
+
[[package]]
|
| 427 |
+
name = "python-dateutil"
|
| 428 |
+
version = "2.9.0.post0"
|
| 429 |
+
source = { registry = "https://pypi.org/simple" }
|
| 430 |
+
dependencies = [
|
| 431 |
+
{ name = "six" },
|
| 432 |
+
]
|
| 433 |
+
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" }
|
| 434 |
+
wheels = [
|
| 435 |
+
{ 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" },
|
| 436 |
+
]
|
| 437 |
+
|
| 438 |
+
[[package]]
|
| 439 |
+
name = "pytz"
|
| 440 |
+
version = "2025.2"
|
| 441 |
+
source = { registry = "https://pypi.org/simple" }
|
| 442 |
+
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" }
|
| 443 |
+
wheels = [
|
| 444 |
+
{ 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" },
|
| 445 |
+
]
|
| 446 |
+
|
| 447 |
+
[[package]]
|
| 448 |
+
name = "referencing"
|
| 449 |
+
version = "0.36.2"
|
| 450 |
+
source = { registry = "https://pypi.org/simple" }
|
| 451 |
+
dependencies = [
|
| 452 |
+
{ name = "attrs" },
|
| 453 |
+
{ name = "rpds-py" },
|
| 454 |
+
{ name = "typing-extensions" },
|
| 455 |
+
]
|
| 456 |
+
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" }
|
| 457 |
+
wheels = [
|
| 458 |
+
{ 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" },
|
| 459 |
+
]
|
| 460 |
+
|
| 461 |
+
[[package]]
|
| 462 |
+
name = "requests"
|
| 463 |
+
version = "2.32.5"
|
| 464 |
+
source = { registry = "https://pypi.org/simple" }
|
| 465 |
+
dependencies = [
|
| 466 |
+
{ name = "certifi" },
|
| 467 |
+
{ name = "charset-normalizer" },
|
| 468 |
+
{ name = "idna" },
|
| 469 |
+
{ name = "urllib3" },
|
| 470 |
+
]
|
| 471 |
+
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" }
|
| 472 |
+
wheels = [
|
| 473 |
+
{ 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" },
|
| 474 |
+
]
|
| 475 |
+
|
| 476 |
+
[[package]]
|
| 477 |
+
name = "rpds-py"
|
| 478 |
+
version = "0.27.1"
|
| 479 |
+
source = { registry = "https://pypi.org/simple" }
|
| 480 |
+
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" }
|
| 481 |
+
wheels = [
|
| 482 |
+
{ 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" },
|
| 483 |
+
{ 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" },
|
| 484 |
+
{ 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" },
|
| 485 |
+
{ 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" },
|
| 486 |
+
{ 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" },
|
| 487 |
+
{ 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" },
|
| 488 |
+
{ 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" },
|
| 489 |
+
{ 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" },
|
| 490 |
+
{ 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" },
|
| 491 |
+
{ 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" },
|
| 492 |
+
{ 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" },
|
| 493 |
+
{ 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" },
|
| 494 |
+
{ 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" },
|
| 495 |
+
{ 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" },
|
| 496 |
+
{ 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" },
|
| 497 |
+
]
|
| 498 |
+
|
| 499 |
+
[[package]]
|
| 500 |
+
name = "six"
|
| 501 |
+
version = "1.17.0"
|
| 502 |
+
source = { registry = "https://pypi.org/simple" }
|
| 503 |
+
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" }
|
| 504 |
+
wheels = [
|
| 505 |
+
{ 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" },
|
| 506 |
+
]
|
| 507 |
+
|
| 508 |
+
[[package]]
|
| 509 |
+
name = "smmap"
|
| 510 |
+
version = "5.0.2"
|
| 511 |
+
source = { registry = "https://pypi.org/simple" }
|
| 512 |
+
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" }
|
| 513 |
+
wheels = [
|
| 514 |
+
{ 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" },
|
| 515 |
+
]
|
| 516 |
+
|
| 517 |
+
[[package]]
|
| 518 |
+
name = "streamlit"
|
| 519 |
+
version = "1.50.0"
|
| 520 |
+
source = { registry = "https://pypi.org/simple" }
|
| 521 |
+
dependencies = [
|
| 522 |
+
{ name = "altair" },
|
| 523 |
+
{ name = "blinker" },
|
| 524 |
+
{ name = "cachetools" },
|
| 525 |
+
{ name = "click" },
|
| 526 |
+
{ name = "gitpython" },
|
| 527 |
+
{ name = "numpy" },
|
| 528 |
+
{ name = "packaging" },
|
| 529 |
+
{ name = "pandas" },
|
| 530 |
+
{ name = "pillow" },
|
| 531 |
+
{ name = "protobuf" },
|
| 532 |
+
{ name = "pyarrow" },
|
| 533 |
+
{ name = "pydeck" },
|
| 534 |
+
{ name = "requests" },
|
| 535 |
+
{ name = "tenacity" },
|
| 536 |
+
{ name = "toml" },
|
| 537 |
+
{ name = "tornado" },
|
| 538 |
+
{ name = "typing-extensions" },
|
| 539 |
+
{ name = "watchdog", marker = "sys_platform != 'darwin'" },
|
| 540 |
+
]
|
| 541 |
+
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" }
|
| 542 |
+
wheels = [
|
| 543 |
+
{ 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" },
|
| 544 |
+
]
|
| 545 |
+
|
| 546 |
+
[[package]]
|
| 547 |
+
name = "tenacity"
|
| 548 |
+
version = "9.1.2"
|
| 549 |
+
source = { registry = "https://pypi.org/simple" }
|
| 550 |
+
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" }
|
| 551 |
+
wheels = [
|
| 552 |
+
{ 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" },
|
| 553 |
+
]
|
| 554 |
+
|
| 555 |
+
[[package]]
|
| 556 |
+
name = "toml"
|
| 557 |
+
version = "0.10.2"
|
| 558 |
+
source = { registry = "https://pypi.org/simple" }
|
| 559 |
+
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" }
|
| 560 |
+
wheels = [
|
| 561 |
+
{ 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" },
|
| 562 |
+
]
|
| 563 |
+
|
| 564 |
+
[[package]]
|
| 565 |
+
name = "tornado"
|
| 566 |
+
version = "6.5.2"
|
| 567 |
+
source = { registry = "https://pypi.org/simple" }
|
| 568 |
+
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" }
|
| 569 |
+
wheels = [
|
| 570 |
+
{ 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" },
|
| 571 |
+
{ 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" },
|
| 572 |
+
{ 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" },
|
| 573 |
+
{ 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" },
|
| 574 |
+
{ 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" },
|
| 575 |
+
{ 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" },
|
| 576 |
+
{ 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" },
|
| 577 |
+
{ 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" },
|
| 578 |
+
{ 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" },
|
| 579 |
+
{ 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" },
|
| 580 |
+
{ 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" },
|
| 581 |
+
]
|
| 582 |
+
|
| 583 |
+
[[package]]
|
| 584 |
+
name = "typing-extensions"
|
| 585 |
+
version = "4.15.0"
|
| 586 |
+
source = { registry = "https://pypi.org/simple" }
|
| 587 |
+
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" }
|
| 588 |
+
wheels = [
|
| 589 |
+
{ 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" },
|
| 590 |
+
]
|
| 591 |
+
|
| 592 |
+
[[package]]
|
| 593 |
+
name = "tzdata"
|
| 594 |
+
version = "2025.2"
|
| 595 |
+
source = { registry = "https://pypi.org/simple" }
|
| 596 |
+
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" }
|
| 597 |
+
wheels = [
|
| 598 |
+
{ 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" },
|
| 599 |
+
]
|
| 600 |
+
|
| 601 |
+
[[package]]
|
| 602 |
+
name = "urllib3"
|
| 603 |
+
version = "2.5.0"
|
| 604 |
+
source = { registry = "https://pypi.org/simple" }
|
| 605 |
+
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" }
|
| 606 |
+
wheels = [
|
| 607 |
+
{ 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" },
|
| 608 |
+
]
|
| 609 |
+
|
| 610 |
+
[[package]]
|
| 611 |
+
name = "watchdog"
|
| 612 |
+
version = "6.0.0"
|
| 613 |
+
source = { registry = "https://pypi.org/simple" }
|
| 614 |
+
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" }
|
| 615 |
+
wheels = [
|
| 616 |
+
{ 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" },
|
| 617 |
+
{ 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" },
|
| 618 |
+
{ 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" },
|
| 619 |
+
{ 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" },
|
| 620 |
+
{ 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" },
|
| 621 |
+
{ 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" },
|
| 622 |
+
{ 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" },
|
| 623 |
+
{ 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" },
|
| 624 |
+
{ 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" },
|
| 625 |
+
{ 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" },
|
| 626 |
+
]
|
wrdler/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__version__ = "0.0.1"
|
| 2 |
+
__all__ = ["models", "generator", "logic", "ui", "game_storage"]
|
wrdler/assets/audio/effects/correct_guess.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:65fcf49f02fd7a6c70dd3c270c254c03dd286e9fb50e382d285b79cb5e24d22d
|
| 3 |
+
size 97255
|
wrdler/assets/audio/effects/hit.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:baaf44f8d29b5543d6c3418ae8ea5d8144046362055b95678b552965f3850a6b
|
| 3 |
+
size 25833
|
wrdler/assets/audio/effects/incorrect_guess.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:07f70499881c735fc284e9a6b17f0f6d383b35b6e06f0c90aa672597110b916c
|
| 3 |
+
size 23449
|
wrdler/assets/audio/effects/miss.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:07f70499881c735fc284e9a6b17f0f6d383b35b6e06f0c90aa672597110b916c
|
| 3 |
+
size 23449
|
wrdler/assets/audio/music/background.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:05fce6cbeff847885ee01717c879b6fad7f347460c3006cfc071cafc37b59451
|
| 3 |
+
size 2161810
|
wrdler/assets/audio/music/congratulations.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:232ca809d3940e7d3491f29ac97230fe0691f21e46993d6d3f42f905c9d225bf
|
| 3 |
+
size 1811619
|
wrdler/assets/scope.gif
ADDED
|
wrdler/assets/scope_blue.gif
ADDED
|
wrdler/assets/scope_blue.png
ADDED
|
wrdler/audio.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import streamlit as st
|
| 4 |
+
|
| 5 |
+
def _get_music_dir() -> str:
|
| 6 |
+
return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
|
| 7 |
+
|
| 8 |
+
def _get_effects_dir() -> str:
|
| 9 |
+
return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
|
| 10 |
+
|
| 11 |
+
def get_audio_tracks() -> list[tuple[str, str]]:
|
| 12 |
+
"""Return list of (label, absolute_path) for .mp3 files in assets/audio/music."""
|
| 13 |
+
audio_dir = _get_music_dir()
|
| 14 |
+
if not os.path.isdir(audio_dir):
|
| 15 |
+
return []
|
| 16 |
+
tracks = []
|
| 17 |
+
for fname in os.listdir(audio_dir):
|
| 18 |
+
if fname.lower().endswith('.mp3'):
|
| 19 |
+
path = os.path.join(audio_dir, fname)
|
| 20 |
+
# Use the filename without extension as the display name
|
| 21 |
+
name = os.path.splitext(fname)[0]
|
| 22 |
+
tracks.append((name, path))
|
| 23 |
+
return tracks
|
| 24 |
+
|
| 25 |
+
@st.cache_data(show_spinner=False)
|
| 26 |
+
def _load_audio_data_url(path: str) -> str:
|
| 27 |
+
"""Return a data: URL for the given audio file so the browser can play it."""
|
| 28 |
+
import base64, mimetypes
|
| 29 |
+
mime, _ = mimetypes.guess_type(path)
|
| 30 |
+
if not mime:
|
| 31 |
+
# Default to mp3 to avoid blocked playback if unknown
|
| 32 |
+
mime = "audio/mpeg"
|
| 33 |
+
with open(path, "rb") as fp:
|
| 34 |
+
encoded = base64.b64encode(fp.read()).decode("ascii")
|
| 35 |
+
return f"data:{mime};base64,{encoded}"
|
| 36 |
+
|
| 37 |
+
def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float, loop: bool = True) -> None:
|
| 38 |
+
"""Create/update a single hidden <audio> element in the top page and play/pause it.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
enabled: Whether the background audio should be active.
|
| 42 |
+
src_data_url: data: URL for the audio source.
|
| 43 |
+
volume: 0.0–1.0 volume level.
|
| 44 |
+
loop: Whether the audio should loop (default True).
|
| 45 |
+
"""
|
| 46 |
+
from streamlit.components.v1 import html as _html
|
| 47 |
+
|
| 48 |
+
if not enabled or not src_data_url:
|
| 49 |
+
_html(
|
| 50 |
+
"""
|
| 51 |
+
<script>
|
| 52 |
+
(function(){
|
| 53 |
+
const doc = window.parent?.document || document;
|
| 54 |
+
const el = doc.getElementById('bw-bg-audio');
|
| 55 |
+
if (el) { try { el.pause(); } catch(e){} }
|
| 56 |
+
})();
|
| 57 |
+
</script>
|
| 58 |
+
""",
|
| 59 |
+
height=0,
|
| 60 |
+
)
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
# Clamp volume
|
| 64 |
+
vol = max(0.0, min(1.0, float(volume)))
|
| 65 |
+
should_loop = "true" if loop else "false"
|
| 66 |
+
|
| 67 |
+
# Inject or update a single persistent audio element and make sure it starts after interaction if autoplay is blocked
|
| 68 |
+
_html(
|
| 69 |
+
f"""
|
| 70 |
+
<script>
|
| 71 |
+
(function(){{
|
| 72 |
+
const doc = window.parent?.document || document;
|
| 73 |
+
let audio = doc.getElementById('bw-bg-audio');
|
| 74 |
+
if (!audio) {{
|
| 75 |
+
audio = doc.createElement('audio');
|
| 76 |
+
audio.id = 'bw-bg-audio';
|
| 77 |
+
audio.style.display = 'none';
|
| 78 |
+
doc.body.appendChild(audio);
|
| 79 |
+
}}
|
| 80 |
+
|
| 81 |
+
// Ensure loop is explicitly set every time, even if element already exists
|
| 82 |
+
const shouldLoop = {should_loop};
|
| 83 |
+
audio.loop = shouldLoop;
|
| 84 |
+
if (shouldLoop) {{
|
| 85 |
+
audio.setAttribute('loop', '');
|
| 86 |
+
}} else {{
|
| 87 |
+
audio.removeAttribute('loop');
|
| 88 |
+
}}
|
| 89 |
+
audio.autoplay = true;
|
| 90 |
+
audio.setAttribute('autoplay', '');
|
| 91 |
+
|
| 92 |
+
const newSrc = "{src_data_url}";
|
| 93 |
+
if (audio.src !== newSrc) {{
|
| 94 |
+
audio.src = newSrc;
|
| 95 |
+
}}
|
| 96 |
+
audio.muted = false;
|
| 97 |
+
audio.volume = {vol:.3f};
|
| 98 |
+
|
| 99 |
+
const tryPlay = () => {{
|
| 100 |
+
const p = audio.play();
|
| 101 |
+
if (p && p.catch) {{ p.catch(() => {{ /* ignore autoplay block until user gesture */ }}); }}
|
| 102 |
+
}};
|
| 103 |
+
tryPlay();
|
| 104 |
+
|
| 105 |
+
const unlock = () => {{
|
| 106 |
+
tryPlay();
|
| 107 |
+
}};
|
| 108 |
+
// Add once-only listeners to resume playback after first user interaction
|
| 109 |
+
doc.addEventListener('pointerdown', unlock, {{ once: true }});
|
| 110 |
+
doc.addEventListener('keydown', unlock, {{ once: true }});
|
| 111 |
+
doc.addEventListener('touchstart', unlock, {{ once: true }});
|
| 112 |
+
}})();
|
| 113 |
+
</script>
|
| 114 |
+
""",
|
| 115 |
+
height=0,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
def _inject_audio_control_sync():
|
| 119 |
+
"""Inject JS to sync volume and enable/disable state immediately."""
|
| 120 |
+
from streamlit.components.v1 import html as _html
|
| 121 |
+
_html(
|
| 122 |
+
'''
|
| 123 |
+
<script>
|
| 124 |
+
(function(){
|
| 125 |
+
const doc = window.parent?.document || document;
|
| 126 |
+
const audio = doc.getElementById('bw-bg-audio');
|
| 127 |
+
if (!audio) return;
|
| 128 |
+
// Get values from Streamlit DOM
|
| 129 |
+
const volInput = doc.querySelector('input[type="range"][aria-label="Volume"]');
|
| 130 |
+
const enableInput = doc.querySelector('input[type="checkbox"][aria-label="Enable music"]');
|
| 131 |
+
if (volInput) {
|
| 132 |
+
volInput.addEventListener('input', function(){
|
| 133 |
+
audio.volume = parseFloat(this.value)/100;
|
| 134 |
+
});
|
| 135 |
+
// Set initial volume
|
| 136 |
+
audio.volume = parseFloat(volInput.value)/100;
|
| 137 |
+
}
|
| 138 |
+
if (enableInput) {
|
| 139 |
+
enableInput.addEventListener('change', function(){
|
| 140 |
+
if (this.checked) {
|
| 141 |
+
audio.muted = false;
|
| 142 |
+
audio.play().catch(()=>{});
|
| 143 |
+
} else {
|
| 144 |
+
audio.muted = true;
|
| 145 |
+
audio.pause();
|
| 146 |
+
}
|
| 147 |
+
});
|
| 148 |
+
// Set initial mute state
|
| 149 |
+
if (enableInput.checked) {
|
| 150 |
+
audio.muted = false;
|
| 151 |
+
audio.play().catch(()=>{});
|
| 152 |
+
} else {
|
| 153 |
+
audio.muted = true;
|
| 154 |
+
audio.pause();
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
})();
|
| 158 |
+
</script>
|
| 159 |
+
''',
|
| 160 |
+
height=0,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Sound effects functionality
|
| 164 |
+
def get_sound_effect_files() -> dict[str, str]:
|
| 165 |
+
"""
|
| 166 |
+
Return dictionary of sound effect name -> absolute path.
|
| 167 |
+
Prefers .mp3 files; falls back to .wav if no .mp3 is found.
|
| 168 |
+
"""
|
| 169 |
+
audio_dir = _get_effects_dir()
|
| 170 |
+
if not os.path.isdir(audio_dir):
|
| 171 |
+
return {}
|
| 172 |
+
|
| 173 |
+
effect_names = [
|
| 174 |
+
"correct_guess",
|
| 175 |
+
"incorrect_guess",
|
| 176 |
+
"hit",
|
| 177 |
+
"miss",
|
| 178 |
+
"congratulations",
|
| 179 |
+
]
|
| 180 |
+
|
| 181 |
+
def _find_effect_file(base: str) -> Optional[str]:
|
| 182 |
+
# Prefer mp3, then wav for backward compatibility
|
| 183 |
+
for ext in (".mp3", ".wav"):
|
| 184 |
+
path = os.path.join(audio_dir, f"{base}{ext}")
|
| 185 |
+
if os.path.exists(path):
|
| 186 |
+
return path
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
result: dict[str, str] = {}
|
| 190 |
+
for name in effect_names:
|
| 191 |
+
path = _find_effect_file(name)
|
| 192 |
+
if path:
|
| 193 |
+
result[name] = path
|
| 194 |
+
|
| 195 |
+
return result
|
| 196 |
+
|
| 197 |
+
def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
| 198 |
+
"""
|
| 199 |
+
Play a sound effect by name.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
effect_name: One of 'correct_guess', 'incorrect_guess', 'hit', 'miss', 'congratulations'
|
| 203 |
+
volume: Volume level (0.0 to 1.0)
|
| 204 |
+
"""
|
| 205 |
+
from streamlit.components.v1 import html as _html
|
| 206 |
+
|
| 207 |
+
# Respect Enable Sound Effects setting from sidebar
|
| 208 |
+
try:
|
| 209 |
+
if not st.session_state.get("enable_sound_effects", True):
|
| 210 |
+
return
|
| 211 |
+
except Exception:
|
| 212 |
+
pass
|
| 213 |
+
|
| 214 |
+
sound_files = get_sound_effect_files()
|
| 215 |
+
|
| 216 |
+
if effect_name not in sound_files:
|
| 217 |
+
return # Sound file doesn't exist, silently skip
|
| 218 |
+
|
| 219 |
+
sound_path = sound_files[effect_name]
|
| 220 |
+
sound_data_url = _load_audio_data_url(sound_path)
|
| 221 |
+
|
| 222 |
+
# Clamp volume
|
| 223 |
+
vol = max(0.0, min(1.0, float(volume)))
|
| 224 |
+
|
| 225 |
+
# Play sound effect using a unique audio element
|
| 226 |
+
_html(
|
| 227 |
+
f"""
|
| 228 |
+
<script>
|
| 229 |
+
(function(){{
|
| 230 |
+
const doc = window.parent?.document || document;
|
| 231 |
+
const audio = doc.createElement('audio');
|
| 232 |
+
audio.src = "{sound_data_url}";
|
| 233 |
+
audio.volume = {vol:.3f};
|
| 234 |
+
audio.style.display = 'none';
|
| 235 |
+
doc.body.appendChild(audio);
|
| 236 |
+
|
| 237 |
+
// Play and remove after playback
|
| 238 |
+
audio.play().catch(e => console.error('Sound effect play error:', e));
|
| 239 |
+
audio.addEventListener('ended', () => {{
|
| 240 |
+
doc.body.removeChild(audio);
|
| 241 |
+
}});
|
| 242 |
+
}})();
|
| 243 |
+
</script>
|
| 244 |
+
""",
|
| 245 |
+
height=0,
|
| 246 |
+
)
|
wrdler/game_storage.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: wrdler/game_storage.py
|
| 2 |
+
"""
|
| 3 |
+
Wrdler-specific storage wrapper for HuggingFace storage operations.
|
| 4 |
+
|
| 5 |
+
This module provides high-level functions for saving and loading Wrdler games
|
| 6 |
+
using the shared storage module from wrdler.modules.
|
| 7 |
+
"""
|
| 8 |
+
__version__ = "0.1.3"
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import tempfile
|
| 12 |
+
import os
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 15 |
+
import logging
|
| 16 |
+
from urllib.parse import unquote
|
| 17 |
+
|
| 18 |
+
from wrdler.modules import (
|
| 19 |
+
upload_files_to_repo,
|
| 20 |
+
gen_full_url,
|
| 21 |
+
HF_REPO_ID,
|
| 22 |
+
SHORTENER_JSON_FILE,
|
| 23 |
+
SPACE_NAME
|
| 24 |
+
)
|
| 25 |
+
from wrdler.modules.storage import _get_json_from_repo
|
| 26 |
+
from wrdler.local_storage import save_json_to_file
|
| 27 |
+
from wrdler.word_loader import compute_word_difficulties
|
| 28 |
+
|
| 29 |
+
# Configure logging
|
| 30 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def generate_uid() -> str:
|
| 35 |
+
"""
|
| 36 |
+
Generate a unique identifier for a game.
|
| 37 |
+
|
| 38 |
+
Format: YYYYMMDDTHHMMSSZ-RANDOM
|
| 39 |
+
Example: 20250123T153045Z-A7B9C2
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
str: Unique game identifier
|
| 43 |
+
"""
|
| 44 |
+
import random
|
| 45 |
+
import string
|
| 46 |
+
|
| 47 |
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 48 |
+
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
| 49 |
+
return f"{timestamp}-{random_suffix}"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def serialize_game_settings(
|
| 53 |
+
word_list: List[str],
|
| 54 |
+
username: str,
|
| 55 |
+
score: int,
|
| 56 |
+
time_seconds: int,
|
| 57 |
+
game_mode: str,
|
| 58 |
+
grid_size: int = 12,
|
| 59 |
+
spacer: int = 1,
|
| 60 |
+
may_overlap: bool = False,
|
| 61 |
+
wordlist_source: Optional[str] = None,
|
| 62 |
+
challenge_id: Optional[str] = None
|
| 63 |
+
) -> Dict[str, Any]:
|
| 64 |
+
"""
|
| 65 |
+
Serialize game settings into a JSON-compatible dictionary.
|
| 66 |
+
Creates initial structure with one user's result.
|
| 67 |
+
Each user has their own uid and word_list.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
word_list: List of words used in THIS user's game
|
| 71 |
+
username: Player's name
|
| 72 |
+
score: Final score achieved
|
| 73 |
+
time_seconds: Time taken to complete (in seconds)
|
| 74 |
+
game_mode: Game mode ("classic" or "too_easy")
|
| 75 |
+
grid_size: Grid size (default: 12)
|
| 76 |
+
spacer: Word spacing configuration (0-2, default: 1)
|
| 77 |
+
may_overlap: Whether words can overlap (default: False)
|
| 78 |
+
wordlist_source: Source file name (e.g., "classic.txt")
|
| 79 |
+
challenge_id: Optional challenge ID (generated if not provided)
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
dict: Serialized game settings with users array
|
| 83 |
+
"""
|
| 84 |
+
if challenge_id is None:
|
| 85 |
+
challenge_id = generate_uid()
|
| 86 |
+
|
| 87 |
+
# Try compute difficulty using the source file; optional
|
| 88 |
+
difficulty_value: Optional[float] = None
|
| 89 |
+
try:
|
| 90 |
+
if wordlist_source:
|
| 91 |
+
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 92 |
+
wordlist_path = os.path.join(words_dir, wordlist_source)
|
| 93 |
+
if os.path.exists(wordlist_path):
|
| 94 |
+
total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
|
| 95 |
+
difficulty_value = float(total_diff)
|
| 96 |
+
except Exception as _e:
|
| 97 |
+
# optional field, swallow errors
|
| 98 |
+
difficulty_value = None
|
| 99 |
+
|
| 100 |
+
# Build user result with desired ordering: uid, username, word_list, word_list_difficulty, score, time, timestamp
|
| 101 |
+
user_result = {
|
| 102 |
+
"uid": generate_uid(),
|
| 103 |
+
"username": username,
|
| 104 |
+
"word_list": word_list,
|
| 105 |
+
}
|
| 106 |
+
if difficulty_value is not None:
|
| 107 |
+
user_result["word_list_difficulty"] = difficulty_value
|
| 108 |
+
user_result["score"] = score
|
| 109 |
+
user_result["time"] = time_seconds
|
| 110 |
+
user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
|
| 111 |
+
|
| 112 |
+
settings = {
|
| 113 |
+
"challenge_id": challenge_id,
|
| 114 |
+
"game_mode": game_mode,
|
| 115 |
+
"grid_size": grid_size,
|
| 116 |
+
"puzzle_options": {
|
| 117 |
+
"spacer": spacer,
|
| 118 |
+
"may_overlap": may_overlap
|
| 119 |
+
},
|
| 120 |
+
"users": [user_result],
|
| 121 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 122 |
+
"version": __version__
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if wordlist_source:
|
| 126 |
+
settings["wordlist_source"] = wordlist_source
|
| 127 |
+
|
| 128 |
+
return settings
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def add_user_result_to_game(
|
| 132 |
+
sid: str,
|
| 133 |
+
username: str,
|
| 134 |
+
word_list: List[str],
|
| 135 |
+
score: int,
|
| 136 |
+
time_seconds: int,
|
| 137 |
+
repo_id: Optional[str] = None
|
| 138 |
+
) -> bool:
|
| 139 |
+
"""
|
| 140 |
+
Add a user's result to an existing shared challenge.
|
| 141 |
+
Each user gets their own uid and word_list.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
sid: Short ID of the existing challenge
|
| 145 |
+
username: Player's name
|
| 146 |
+
word_list: List of words THIS user played
|
| 147 |
+
score: Score achieved
|
| 148 |
+
time_seconds: Time taken (seconds)
|
| 149 |
+
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
bool: True if successfully added, False otherwise
|
| 153 |
+
"""
|
| 154 |
+
if repo_id is None:
|
| 155 |
+
repo_id = HF_REPO_ID
|
| 156 |
+
|
| 157 |
+
logger.info(f"➕ Adding user result to challenge {sid}")
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
# Load existing game settings
|
| 161 |
+
settings = load_game_from_sid(sid, repo_id)
|
| 162 |
+
if not settings:
|
| 163 |
+
logger.error(f"❌ Challenge not found: {sid}")
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
# Compute optional difficulty using the saved wordlist_source if available
|
| 167 |
+
difficulty_value: Optional[float] = None
|
| 168 |
+
try:
|
| 169 |
+
wordlist_source = settings.get("wordlist_source")
|
| 170 |
+
if wordlist_source:
|
| 171 |
+
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 172 |
+
wordlist_path = os.path.join(words_dir, wordlist_source)
|
| 173 |
+
if os.path.exists(wordlist_path):
|
| 174 |
+
total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
|
| 175 |
+
difficulty_value = float(total_diff)
|
| 176 |
+
except Exception:
|
| 177 |
+
difficulty_value = None
|
| 178 |
+
|
| 179 |
+
# Create new user result with ordering and optional difficulty
|
| 180 |
+
user_result = {
|
| 181 |
+
"uid": generate_uid(),
|
| 182 |
+
"username": username,
|
| 183 |
+
"word_list": word_list,
|
| 184 |
+
}
|
| 185 |
+
if difficulty_value is not None:
|
| 186 |
+
user_result["word_list_difficulty"] = difficulty_value
|
| 187 |
+
user_result["score"] = score
|
| 188 |
+
user_result["time"] = time_seconds
|
| 189 |
+
user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
|
| 190 |
+
|
| 191 |
+
# Add to users array
|
| 192 |
+
if "users" not in settings:
|
| 193 |
+
settings["users"] = []
|
| 194 |
+
settings["users"].append(user_result)
|
| 195 |
+
|
| 196 |
+
logger.info(f"👥 Now {len(settings['users'])} users in game")
|
| 197 |
+
|
| 198 |
+
# Get the file path from the sid
|
| 199 |
+
status, full_url = gen_full_url(
|
| 200 |
+
short_url=sid,
|
| 201 |
+
repo_id=repo_id,
|
| 202 |
+
json_file=SHORTENER_JSON_FILE
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
if status != "success_retrieved_full" or not full_url:
|
| 206 |
+
logger.error(f"❌ Could not resolve sid: {sid}")
|
| 207 |
+
return False
|
| 208 |
+
|
| 209 |
+
# Extract challenge_id from URL
|
| 210 |
+
url_parts = full_url.split("/resolve/main/")
|
| 211 |
+
if len(url_parts) != 2:
|
| 212 |
+
logger.error(f"❌ Invalid URL format: {full_url}")
|
| 213 |
+
return False
|
| 214 |
+
|
| 215 |
+
file_path = url_parts[1] # e.g., "games/{challenge_id}/settings.json"
|
| 216 |
+
challenge_id = file_path.split("/")[1] # Extract challenge_id
|
| 217 |
+
folder_name = f"games/{challenge_id}"
|
| 218 |
+
|
| 219 |
+
# Save updated settings back to HF
|
| 220 |
+
try:
|
| 221 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 222 |
+
settings_path = save_json_to_file(settings, tmpdir, "settings.json")
|
| 223 |
+
logger.info(f"📤 Updating {folder_name}/settings.json")
|
| 224 |
+
|
| 225 |
+
response = upload_files_to_repo(
|
| 226 |
+
files=[settings_path],
|
| 227 |
+
repo_id=repo_id,
|
| 228 |
+
folder_name=folder_name,
|
| 229 |
+
repo_type="dataset"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
logger.info(f"✅ User result added for {username}")
|
| 233 |
+
return True
|
| 234 |
+
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"❌ Failed to upload updated settings: {e}")
|
| 237 |
+
return False
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"❌ Failed to add user result: {e}")
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def save_game_to_hf(
|
| 245 |
+
word_list: List[str],
|
| 246 |
+
username: str,
|
| 247 |
+
score: int,
|
| 248 |
+
time_seconds: int,
|
| 249 |
+
game_mode: str,
|
| 250 |
+
grid_size: int = 12,
|
| 251 |
+
spacer: int = 1,
|
| 252 |
+
may_overlap: bool = False,
|
| 253 |
+
repo_id: Optional[str] = None,
|
| 254 |
+
wordlist_source: Optional[str] = None
|
| 255 |
+
) -> Tuple[str, Optional[str], Optional[str]]:
|
| 256 |
+
"""
|
| 257 |
+
Save game settings to HuggingFace repository and generate shareable URL.
|
| 258 |
+
Creates a new game entry with the first user's result.
|
| 259 |
+
|
| 260 |
+
This function:
|
| 261 |
+
1. Generates a unique UID for the game
|
| 262 |
+
2. Serializes game settings to JSON with first user
|
| 263 |
+
3. Uploads settings.json to HF repo under games/{uid}/
|
| 264 |
+
4. Creates a shortened URL (sid) for sharing
|
| 265 |
+
5. Returns the full URL and short ID
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
word_list: List of words used in the game
|
| 269 |
+
username: Player's name
|
| 270 |
+
score: Final score achieved
|
| 271 |
+
time_seconds: Time taken to complete (in seconds)
|
| 272 |
+
game_mode: Game mode ("classic" or "too_easy")
|
| 273 |
+
grid_size: Grid size (default: 12)
|
| 274 |
+
spacer: Word spacing configuration (0-2, default: 1)
|
| 275 |
+
may_overlap: Whether words can overlap (default: False)
|
| 276 |
+
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
|
| 277 |
+
wordlist_source: Source wordlist file name (e.g., "classic.txt")
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
tuple: (challenge_id, full_url, sid) where:
|
| 281 |
+
- challenge_id: Unique challenge identifier
|
| 282 |
+
- full_url: Full URL to settings.json
|
| 283 |
+
- sid: Shortened ID for sharing (8 characters)
|
| 284 |
+
|
| 285 |
+
Raises:
|
| 286 |
+
Exception: If upload or URL shortening fails
|
| 287 |
+
|
| 288 |
+
Example:
|
| 289 |
+
>>> uid, full_url, sid = save_game_to_hf(
|
| 290 |
+
... word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
|
| 291 |
+
... username="Alice",
|
| 292 |
+
... score=42,
|
| 293 |
+
... time_seconds=180,
|
| 294 |
+
... game_mode="classic",
|
| 295 |
+
... wordlist_source="classic.txt"
|
| 296 |
+
... )
|
| 297 |
+
>>> print(f"Share: https://{SPACE_NAME}/?game_id={sid}")
|
| 298 |
+
"""
|
| 299 |
+
if repo_id is None:
|
| 300 |
+
repo_id = HF_REPO_ID
|
| 301 |
+
|
| 302 |
+
logger.info(f"💾 Saving game to HuggingFace repo: {repo_id}")
|
| 303 |
+
|
| 304 |
+
# Generate challenge ID and serialize settings
|
| 305 |
+
challenge_id = generate_uid()
|
| 306 |
+
settings = serialize_game_settings(
|
| 307 |
+
word_list=word_list,
|
| 308 |
+
username=username,
|
| 309 |
+
score=score,
|
| 310 |
+
time_seconds=time_seconds,
|
| 311 |
+
game_mode=game_mode,
|
| 312 |
+
grid_size=grid_size,
|
| 313 |
+
spacer=spacer,
|
| 314 |
+
may_overlap=may_overlap,
|
| 315 |
+
challenge_id=challenge_id,
|
| 316 |
+
wordlist_source=wordlist_source
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
logger.debug(f"🆔 Generated Challenge ID: {challenge_id}")
|
| 320 |
+
|
| 321 |
+
# Write settings to a temp directory using a fixed filename 'settings.json'
|
| 322 |
+
folder_name = f"games/{challenge_id}"
|
| 323 |
+
try:
|
| 324 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 325 |
+
settings_path = save_json_to_file(settings, tmpdir, "settings.json")
|
| 326 |
+
logger.info(f"📤 Uploading to {folder_name}/settings.json")
|
| 327 |
+
# Upload to HF repo under games/{uid}/settings.json
|
| 328 |
+
response = upload_files_to_repo(
|
| 329 |
+
files=[settings_path],
|
| 330 |
+
repo_id=repo_id,
|
| 331 |
+
folder_name=folder_name,
|
| 332 |
+
repo_type="dataset"
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# Construct full URL to settings.json
|
| 336 |
+
full_url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}/settings.json"
|
| 337 |
+
logger.info(f"✅ Uploaded: {full_url}")
|
| 338 |
+
|
| 339 |
+
# Generate short URL
|
| 340 |
+
logger.info("🔗 Creating short URL...")
|
| 341 |
+
status, sid = gen_full_url(
|
| 342 |
+
full_url=full_url,
|
| 343 |
+
repo_id=repo_id,
|
| 344 |
+
json_file=SHORTENER_JSON_FILE
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
if status in ["created_short", "success_retrieved_short", "exists_match"]:
|
| 348 |
+
logger.info(f"✅ Short ID created: {sid}")
|
| 349 |
+
share_url = f"https://{SPACE_NAME}/?game_id={sid}"
|
| 350 |
+
logger.info(f"🎮 Share URL: {share_url}")
|
| 351 |
+
return challenge_id, full_url, sid
|
| 352 |
+
else:
|
| 353 |
+
logger.warning(f"⚠️ URL shortening failed: {status}")
|
| 354 |
+
return challenge_id, full_url, None
|
| 355 |
+
|
| 356 |
+
except Exception as e:
|
| 357 |
+
logger.error(f"❌ Failed to save game: {e}")
|
| 358 |
+
raise
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def load_game_from_sid(
|
| 362 |
+
sid: str,
|
| 363 |
+
repo_id: Optional[str] = None
|
| 364 |
+
) -> Optional[Dict[str, Any]]:
|
| 365 |
+
"""
|
| 366 |
+
Load game settings from a short ID (sid).
|
| 367 |
+
If settings.json cannot be found, return None and allow normal game loading.
|
| 368 |
+
|
| 369 |
+
Args:
|
| 370 |
+
sid: Short ID (8 characters) from shareable URL
|
| 371 |
+
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
|
| 372 |
+
|
| 373 |
+
Returns:
|
| 374 |
+
dict | None: Game settings or None if not found
|
| 375 |
+
|
| 376 |
+
dict: Challenge settings containing:
|
| 377 |
+
- challenge_id: Unique challenge identifier
|
| 378 |
+
- wordlist_source: Source wordlist file (e.g., "classic.txt")
|
| 379 |
+
- game_mode: Game mode
|
| 380 |
+
- grid_size: Grid size
|
| 381 |
+
- puzzle_options: Puzzle configuration (spacer, may_overlap)
|
| 382 |
+
- users: Array of user results, each with:
|
| 383 |
+
- uid: Unique user game identifier
|
| 384 |
+
- username: Player name
|
| 385 |
+
- word_list: Words THIS user played
|
| 386 |
+
- score: Score achieved
|
| 387 |
+
- time: Time taken (seconds)
|
| 388 |
+
- timestamp: When result was recorded
|
| 389 |
+
- created_at: When challenge was created
|
| 390 |
+
- version: Storage version
|
| 391 |
+
|
| 392 |
+
Returns None if sid not found or download fails
|
| 393 |
+
|
| 394 |
+
Example:
|
| 395 |
+
>>> settings = load_game_from_sid("abc12345")
|
| 396 |
+
>>> if settings:
|
| 397 |
+
... print(f"Challenge ID: {settings['challenge_id']}")
|
| 398 |
+
... print(f"Wordlist: {settings['wordlist_source']}")
|
| 399 |
+
... for user in settings['users']:
|
| 400 |
+
... print(f"{user['username']}: {user['score']} pts")
|
| 401 |
+
"""
|
| 402 |
+
if repo_id is None:
|
| 403 |
+
repo_id = HF_REPO_ID
|
| 404 |
+
|
| 405 |
+
logger.info(f"🔍 Loading game from sid: {sid}")
|
| 406 |
+
|
| 407 |
+
try:
|
| 408 |
+
# Resolve sid to full URL
|
| 409 |
+
status, full_url = gen_full_url(
|
| 410 |
+
short_url=sid,
|
| 411 |
+
repo_id=repo_id,
|
| 412 |
+
json_file=SHORTENER_JSON_FILE
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
if status != "success_retrieved_full" or not full_url:
|
| 416 |
+
logger.warning(f"⚠️ Could not resolve sid: {sid} (status: {status})")
|
| 417 |
+
return None
|
| 418 |
+
|
| 419 |
+
logger.info(f"✅ Resolved to: {full_url}")
|
| 420 |
+
|
| 421 |
+
# Extract the file path from the full URL
|
| 422 |
+
# URL format: https://huggingface.co/datasets/{repo_id}/resolve/main/{path}
|
| 423 |
+
# We need just the path part: games/{uid}/settings.json
|
| 424 |
+
try:
|
| 425 |
+
url_parts = full_url.split("/resolve/main/")
|
| 426 |
+
if len(url_parts) != 2:
|
| 427 |
+
logger.error(f"❌ Invalid URL format: {full_url}")
|
| 428 |
+
return None
|
| 429 |
+
|
| 430 |
+
file_path = url_parts[1]
|
| 431 |
+
logger.info(f"📥 Downloading {file_path} using authenticated API...")
|
| 432 |
+
|
| 433 |
+
settings = _get_json_from_repo(repo_id, file_path, repo_type="dataset")
|
| 434 |
+
if not settings:
|
| 435 |
+
logger.error(f"❌ settings.json not found for sid: {sid}. Loading normal game.")
|
| 436 |
+
return None
|
| 437 |
+
|
| 438 |
+
logger.info(f"✅ Loaded challenge: {settings.get('challenge_id', 'unknown')}")
|
| 439 |
+
users = settings.get('users', [])
|
| 440 |
+
logger.debug(f"Users in challenge: {len(users)}")
|
| 441 |
+
|
| 442 |
+
return settings
|
| 443 |
+
|
| 444 |
+
except Exception as e:
|
| 445 |
+
logger.error(f"❌ Failed to parse URL or download: {e}")
|
| 446 |
+
return None
|
| 447 |
+
|
| 448 |
+
except Exception as e:
|
| 449 |
+
logger.error(f"❌ Unexpected error loading game: {e}")
|
| 450 |
+
return None
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def get_shareable_url(sid: str, base_url: str = None) -> str:
|
| 454 |
+
"""
|
| 455 |
+
Generate a shareable URL from a short ID.
|
| 456 |
+
If running locally, use localhost. Otherwise, use HuggingFace Space domain.
|
| 457 |
+
Additionally, if an "iframe_host" query parameter is present in the current
|
| 458 |
+
Streamlit request, it takes precedence and will be used as the base URL.
|
| 459 |
+
|
| 460 |
+
Args:
|
| 461 |
+
sid: Short ID (8 characters)
|
| 462 |
+
base_url: Optional override for the base URL (for testing or custom deployments)
|
| 463 |
+
|
| 464 |
+
Returns:
|
| 465 |
+
str: Full shareable URL
|
| 466 |
+
|
| 467 |
+
Example:
|
| 468 |
+
>>> url = get_shareable_url("abc12345")
|
| 469 |
+
>>> print(url)
|
| 470 |
+
https://surn-battlewords.hf.space/?game_id=abc12345
|
| 471 |
+
"""
|
| 472 |
+
import os
|
| 473 |
+
from wrdler.modules.constants import SPACE_NAME
|
| 474 |
+
|
| 475 |
+
# 0) If not explicitly provided, try to read iframe_host from Streamlit query params
|
| 476 |
+
if base_url is None:
|
| 477 |
+
try:
|
| 478 |
+
import streamlit as st # local import to avoid hard dependency
|
| 479 |
+
params = getattr(st, "query_params", None)
|
| 480 |
+
if params is None and hasattr(st, "experimental_get_query_params"):
|
| 481 |
+
params = st.experimental_get_query_params()
|
| 482 |
+
if params and "iframe_host" in params:
|
| 483 |
+
raw_host = params.get("iframe_host")
|
| 484 |
+
# st.query_params may return str or list[str]
|
| 485 |
+
if isinstance(raw_host, (list, tuple)):
|
| 486 |
+
raw_host = raw_host[0] if raw_host else None
|
| 487 |
+
if raw_host:
|
| 488 |
+
decoded = unquote(str(raw_host))
|
| 489 |
+
if decoded:
|
| 490 |
+
base_url = decoded
|
| 491 |
+
except Exception:
|
| 492 |
+
# Ignore any errors here and fall back to defaults below
|
| 493 |
+
pass
|
| 494 |
+
|
| 495 |
+
# 1) If base_url is provided (either parameter or iframe_host), use it directly
|
| 496 |
+
if base_url:
|
| 497 |
+
sep = '&' if '?' in base_url else '?'
|
| 498 |
+
return f"{base_url}{sep}game_id={sid}"
|
| 499 |
+
|
| 500 |
+
if os.environ.get("IS_LOCAL", "true").lower() == "true":
|
| 501 |
+
# 2) Check for local development (common Streamlit env vars)
|
| 502 |
+
port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
|
| 503 |
+
host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
|
| 504 |
+
if host in ("localhost", "127.0.0.1") or os.environ.get("IS_LOCAL", "").lower() == "true":
|
| 505 |
+
return f"http://{host}:{port}/?game_id={sid}"
|
| 506 |
+
|
| 507 |
+
# 3) Otherwise, build HuggingFace Space URL from SPACE_NAME
|
| 508 |
+
space = (SPACE_NAME or "surn/wrdler").lower().replace("/", "-")
|
| 509 |
+
return f"https://{space}.hf.space/?game_id={sid}"
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
if __name__ == "__main__":
|
| 513 |
+
# Example usage
|
| 514 |
+
print("Wrdler Game Storage Module")
|
| 515 |
+
print(f"Version: {__version__}")
|
| 516 |
+
print(f"Target Repository: {HF_REPO_ID}")
|
| 517 |
+
print(f"Space Name: {SPACE_NAME}")
|
| 518 |
+
|
| 519 |
+
# Example: Save a game
|
| 520 |
+
print("\n--- Example: Save Game ---")
|
| 521 |
+
try:
|
| 522 |
+
challenge_id, full_url, sid = save_game_to_hf(
|
| 523 |
+
word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
|
| 524 |
+
username="Alice",
|
| 525 |
+
score=42,
|
| 526 |
+
time_seconds=180,
|
| 527 |
+
game_mode="classic"
|
| 528 |
+
)
|
| 529 |
+
print(f"Challenge ID: {challenge_id}")
|
| 530 |
+
print(f"Full URL: {full_url}")
|
| 531 |
+
print(f"Short ID: {sid}")
|
| 532 |
+
print(f"Share: {get_shareable_url(sid)}")
|
| 533 |
+
except Exception as e:
|
| 534 |
+
print(f"Error: {e}")
|
| 535 |
+
|
| 536 |
+
# Example: Load a game
|
| 537 |
+
print("\n--- Example: Load Game ---")
|
| 538 |
+
if sid:
|
| 539 |
+
settings = load_game_from_sid(sid)
|
| 540 |
+
if settings:
|
| 541 |
+
print(f"Loaded Challenge: {settings['challenge_id']}")
|
| 542 |
+
print(f"Wordlist Source: {settings.get('wordlist_source', 'N/A')}")
|
| 543 |
+
users = settings.get('users', [])
|
| 544 |
+
print(f"Users: {len(users)}")
|
| 545 |
+
for user in users:
|
| 546 |
+
print(f" - {user['username']}: {user['score']} pts in {user['time']}s")
|
wrdler/generate_sounds.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Standalone script to generate sound effects using Hugging Face API.
|
| 4 |
+
Uses only built-in Python libraries (no external dependencies).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import urllib.request
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# Load environment variables from .env if present
|
| 14 |
+
env_path = Path(__file__).parent / ".env"
|
| 15 |
+
if env_path.exists():
|
| 16 |
+
with open(env_path) as f:
|
| 17 |
+
for line in f:
|
| 18 |
+
if line.strip() and not line.startswith("#"):
|
| 19 |
+
key, _, value = line.strip().partition("=")
|
| 20 |
+
os.environ[key] = value
|
| 21 |
+
|
| 22 |
+
# Get Hugging Face API token from environment variable
|
| 23 |
+
HF_API_TOKEN = os.environ.get("HF_API_TOKEN")
|
| 24 |
+
if not HF_API_TOKEN:
|
| 25 |
+
print("Warning: HF_API_TOKEN not set in environment or .env file.")
|
| 26 |
+
|
| 27 |
+
# Using your UnlimitedMusicGen Gradio Space
|
| 28 |
+
SPACE_URL = "https://surn-unlimitedmusicgen.hf.space"
|
| 29 |
+
GRADIO_API_URL = f"{SPACE_URL}/api/predict"
|
| 30 |
+
GRADIO_STATUS_URL = f"{SPACE_URL}/call/predict/{{event_id}}"
|
| 31 |
+
|
| 32 |
+
# Sound effects to generate
|
| 33 |
+
EFFECT_PROMPTS = {
|
| 34 |
+
"correct_guess": {"prompt": "A short, sharp ding sound for a correct guess", "duration": 2},
|
| 35 |
+
"incorrect_guess": {"prompt": "A low buzz sound for an incorrect guess", "duration": 2},
|
| 36 |
+
"miss": {"prompt": "A soft thud sound for a miss", "duration": 1},
|
| 37 |
+
"hit": {"prompt": "A bright chime sound for a hit", "duration": 1},
|
| 38 |
+
"congratulations": {"prompt": "A triumphant fanfare sound for congratulations", "duration": 3}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
def generate_sound_effect_gradio(effect_name: str, prompt: str, duration: float, output_dir: Path) -> bool:
|
| 42 |
+
"""Generate a single sound effect using Gradio API (async call)."""
|
| 43 |
+
|
| 44 |
+
print(f"\nGenerating: {effect_name}")
|
| 45 |
+
print(f" Prompt: {prompt}")
|
| 46 |
+
print(f" Duration: {duration}s")
|
| 47 |
+
|
| 48 |
+
# Step 1: Submit generation request
|
| 49 |
+
payload = json.dumps({
|
| 50 |
+
"data": [prompt, duration]
|
| 51 |
+
}).encode('utf-8')
|
| 52 |
+
|
| 53 |
+
headers = {
|
| 54 |
+
"Content-Type": "application/json"
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
print(f" Submitting request to Gradio API...")
|
| 59 |
+
|
| 60 |
+
# Submit the job
|
| 61 |
+
req = urllib.request.Request(GRADIO_API_URL, data=payload, headers=headers, method='POST')
|
| 62 |
+
|
| 63 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 64 |
+
if response.status == 200:
|
| 65 |
+
result = json.loads(response.read().decode())
|
| 66 |
+
event_id = result.get("event_id")
|
| 67 |
+
|
| 68 |
+
if not event_id:
|
| 69 |
+
print(f" ✗ No event_id returned")
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
print(f" Job submitted, event_id: {event_id}")
|
| 73 |
+
|
| 74 |
+
# Step 2: Poll for results
|
| 75 |
+
status_url = GRADIO_STATUS_URL.format(event_id=event_id)
|
| 76 |
+
|
| 77 |
+
for poll_attempt in range(30): # Poll for up to 5 minutes
|
| 78 |
+
time.sleep(10)
|
| 79 |
+
print(f" Polling for results (attempt {poll_attempt + 1}/30)...")
|
| 80 |
+
|
| 81 |
+
status_req = urllib.request.Request(status_url, headers=headers)
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
with urllib.request.urlopen(status_req, timeout=30) as status_response:
|
| 85 |
+
# Gradio returns streaming events, read until we get the result
|
| 86 |
+
for line in status_response:
|
| 87 |
+
line = line.decode('utf-8').strip()
|
| 88 |
+
if line.startswith('data: '):
|
| 89 |
+
event_data = json.loads(line[6:]) # Remove 'data: ' prefix
|
| 90 |
+
|
| 91 |
+
if event_data.get('msg') == 'process_completed':
|
| 92 |
+
# Get the audio file URL
|
| 93 |
+
output_data = event_data.get('output', {}).get('data', [])
|
| 94 |
+
if output_data and len(output_data) > 0:
|
| 95 |
+
audio_url = output_data[0].get('url')
|
| 96 |
+
if audio_url:
|
| 97 |
+
# Download the audio file
|
| 98 |
+
full_audio_url = f"https://surn-unlimitedmusicgen.hf.space{audio_url}"
|
| 99 |
+
print(f" Downloading from: {full_audio_url}")
|
| 100 |
+
|
| 101 |
+
audio_req = urllib.request.Request(full_audio_url)
|
| 102 |
+
with urllib.request.urlopen(audio_req, timeout=30) as audio_response:
|
| 103 |
+
audio_data = audio_response.read()
|
| 104 |
+
|
| 105 |
+
# Save to file
|
| 106 |
+
output_path = output_dir / f"{effect_name}.wav"
|
| 107 |
+
with open(output_path, "wb") as f:
|
| 108 |
+
f.write(audio_data)
|
| 109 |
+
|
| 110 |
+
print(f" ✓ Success! Saved to: {output_path}")
|
| 111 |
+
print(f" File size: {len(audio_data)} bytes")
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
elif event_data.get('msg') == 'process_error':
|
| 115 |
+
print(f" ✗ Generation error: {event_data.get('output')}")
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
except Exception as poll_error:
|
| 119 |
+
print(f" Polling error: {poll_error}")
|
| 120 |
+
continue
|
| 121 |
+
|
| 122 |
+
print(f" ✗ Timeout waiting for generation")
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
else:
|
| 126 |
+
print(f" ✗ Error {response.status}: {response.read().decode()}")
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f" ✗ Error: {e}")
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
def main():
|
| 134 |
+
"""Generate all sound effects."""
|
| 135 |
+
|
| 136 |
+
print("=" * 70)
|
| 137 |
+
print("Sound Effects Generator for BattleWords")
|
| 138 |
+
print("=" * 70)
|
| 139 |
+
print(f"Using UnlimitedMusicGen Gradio API")
|
| 140 |
+
print(f"API URL: {GRADIO_API_URL}")
|
| 141 |
+
print(f"\nGenerating {len(EFFECT_PROMPTS)} sound effects...\n")
|
| 142 |
+
|
| 143 |
+
# Create output directory
|
| 144 |
+
output_dir = Path(__file__).parent / "assets" / "audio"
|
| 145 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
print(f"Output directory: {output_dir}\n")
|
| 147 |
+
|
| 148 |
+
# Generate each effect
|
| 149 |
+
success_count = 0
|
| 150 |
+
for effect_name, config in EFFECT_PROMPTS.items():
|
| 151 |
+
if generate_sound_effect_gradio(
|
| 152 |
+
effect_name,
|
| 153 |
+
config["prompt"],
|
| 154 |
+
config["duration"],
|
| 155 |
+
output_dir
|
| 156 |
+
):
|
| 157 |
+
success_count += 1
|
| 158 |
+
|
| 159 |
+
# Small delay between requests
|
| 160 |
+
if effect_name != list(EFFECT_PROMPTS.keys())[-1]:
|
| 161 |
+
print(" Waiting 5 seconds before next request...")
|
| 162 |
+
time.sleep(5)
|
| 163 |
+
|
| 164 |
+
print("\n" + "=" * 70)
|
| 165 |
+
print(f"Generation complete! {success_count}/{len(EFFECT_PROMPTS)} successful")
|
| 166 |
+
print("=" * 70)
|
| 167 |
+
|
| 168 |
+
if success_count == len(EFFECT_PROMPTS):
|
| 169 |
+
print("\n✓ All sound effects generated successfully!")
|
| 170 |
+
else:
|
| 171 |
+
print(f"\n⚠ {len(EFFECT_PROMPTS) - success_count} sound effects failed to generate")
|
| 172 |
+
|
| 173 |
+
if __name__ == "__main__":
|
| 174 |
+
main()
|
wrdler/generator.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
import hashlib # NEW
|
| 5 |
+
from typing import Dict, List, Optional, Union # UPDATED
|
| 6 |
+
from .word_loader import load_word_list
|
| 7 |
+
from .models import Coord, Word, Puzzle
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _fits_and_free(cells: List[Coord], used: set[Coord], size: int) -> bool:
|
| 11 |
+
for c in cells:
|
| 12 |
+
if not c.in_bounds(size) or c in used:
|
| 13 |
+
return False
|
| 14 |
+
return True
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _build_cells(start: Coord, length: int, direction: str) -> List[Coord]:
|
| 18 |
+
if direction == "H":
|
| 19 |
+
return [Coord(start.x, start.y + i) for i in range(length)]
|
| 20 |
+
else:
|
| 21 |
+
return [Coord(start.x + i, start.y) for i in range(length)]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _chebyshev_distance(a: Coord, b: Coord) -> int:
|
| 25 |
+
return max(abs(a.x - b.x), abs(a.y - b.y))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _seed_from_id(puzzle_id: str) -> int:
|
| 29 |
+
"""Derive a deterministic 64-bit seed from a string id."""
|
| 30 |
+
h = hashlib.sha256(puzzle_id.encode("utf-8")).digest()
|
| 31 |
+
return int.from_bytes(h[:8], "big", signed=False)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def generate_puzzle(
|
| 35 |
+
grid_size: int = 12,
|
| 36 |
+
words_by_len: Optional[Dict[int, List[str]]] = None,
|
| 37 |
+
seed: Optional[Union[int, str]] = None,
|
| 38 |
+
max_attempts: int = 5000,
|
| 39 |
+
spacer: int = 1,
|
| 40 |
+
puzzle_id: Optional[str] = None, # NEW
|
| 41 |
+
_retry: int = 0, # NEW internal for deterministic retries
|
| 42 |
+
target_words: Optional[List[str]] = None, # NEW: for loading shared games
|
| 43 |
+
may_overlap: bool = False, # NEW: for future crossword-style gameplay
|
| 44 |
+
) -> Puzzle:
|
| 45 |
+
"""
|
| 46 |
+
Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
|
| 47 |
+
no cell overlaps. Radar pulses are last-letter cells.
|
| 48 |
+
Ensures the same word text is not selected more than once.
|
| 49 |
+
|
| 50 |
+
Parameters
|
| 51 |
+
- grid_size: grid dimension (default 12)
|
| 52 |
+
- words_by_len: preloaded word pools by length
|
| 53 |
+
- seed: optional RNG seed
|
| 54 |
+
- max_attempts: cap for placement attempts before restarting
|
| 55 |
+
- spacer: separation constraint between different words (0–2 supported)
|
| 56 |
+
- 0: words may touch
|
| 57 |
+
- 1: at least 1 blank tile between words (default)
|
| 58 |
+
- 2: at least 2 blank tiles between words
|
| 59 |
+
- target_words: optional list of exactly 6 words to use (for shared games)
|
| 60 |
+
- may_overlap: whether words can overlap (default False, for future use)
|
| 61 |
+
|
| 62 |
+
Determinism:
|
| 63 |
+
- If puzzle_id is provided, it's used to derive the RNG seed. Retries use f"{puzzle_id}:{_retry}".
|
| 64 |
+
- Else if seed is provided (int or str), it's used (retries offset deterministically).
|
| 65 |
+
- Else RNG is non-deterministic as before.
|
| 66 |
+
"""
|
| 67 |
+
# Compute deterministic seed if requested
|
| 68 |
+
if puzzle_id is not None:
|
| 69 |
+
seed_val = _seed_from_id(f"{puzzle_id}:{_retry}")
|
| 70 |
+
elif isinstance(seed, str):
|
| 71 |
+
seed_val = _seed_from_id(f"{seed}:{_retry}")
|
| 72 |
+
elif isinstance(seed, int):
|
| 73 |
+
seed_val = seed + _retry
|
| 74 |
+
else:
|
| 75 |
+
seed_val = None
|
| 76 |
+
|
| 77 |
+
rng = random.Random(seed_val) if seed_val is not None else random.Random()
|
| 78 |
+
|
| 79 |
+
# If target_words is provided, use those specific words
|
| 80 |
+
if target_words:
|
| 81 |
+
if len(target_words) != 6:
|
| 82 |
+
raise ValueError(f"target_words must contain exactly 6 words, got {len(target_words)}")
|
| 83 |
+
# Group target words by length
|
| 84 |
+
pools: Dict[int, List[str]] = {}
|
| 85 |
+
for word in target_words:
|
| 86 |
+
L = len(word)
|
| 87 |
+
if L not in pools:
|
| 88 |
+
pools[L] = []
|
| 89 |
+
pools[L].append(word.upper())
|
| 90 |
+
target_lengths = sorted([len(w) for w in target_words])
|
| 91 |
+
else:
|
| 92 |
+
# Normal random word selection
|
| 93 |
+
words_by_len = words_by_len or load_word_list()
|
| 94 |
+
target_lengths = [4, 4, 5, 5, 6, 6]
|
| 95 |
+
# Pre-shuffle the word pools for variety but deterministic with seed.
|
| 96 |
+
pools: Dict[int, List[str]] = {}
|
| 97 |
+
for L in (4, 5, 6):
|
| 98 |
+
unique_words = list(dict.fromkeys(words_by_len.get(L, [])))
|
| 99 |
+
rng.shuffle(unique_words)
|
| 100 |
+
pools[L] = unique_words
|
| 101 |
+
|
| 102 |
+
used: set[Coord] = set()
|
| 103 |
+
used_texts: set[str] = set()
|
| 104 |
+
placed: List[Word] = []
|
| 105 |
+
|
| 106 |
+
attempts = 0
|
| 107 |
+
for L in target_lengths:
|
| 108 |
+
placed_ok = False
|
| 109 |
+
pool = pools[L]
|
| 110 |
+
if not pool:
|
| 111 |
+
raise RuntimeError(f"No words available for length {L}")
|
| 112 |
+
|
| 113 |
+
word_try_order = pool[:] # copy
|
| 114 |
+
rng.shuffle(word_try_order)
|
| 115 |
+
|
| 116 |
+
for cand_text in word_try_order:
|
| 117 |
+
if attempts >= max_attempts:
|
| 118 |
+
break
|
| 119 |
+
attempts += 1
|
| 120 |
+
|
| 121 |
+
if cand_text in used_texts:
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
for _ in range(50):
|
| 125 |
+
direction = rng.choice(["H", "V"])
|
| 126 |
+
if direction == "H":
|
| 127 |
+
row = rng.randrange(0, grid_size)
|
| 128 |
+
col = rng.randrange(0, grid_size - L + 1)
|
| 129 |
+
else:
|
| 130 |
+
row = rng.randrange(0, grid_size - L + 1)
|
| 131 |
+
col = rng.randrange(0, grid_size)
|
| 132 |
+
|
| 133 |
+
cells = _build_cells(Coord(row, col), L, direction)
|
| 134 |
+
if _fits_and_free(cells, used, grid_size):
|
| 135 |
+
w = Word(cand_text, Coord(row, col), direction)
|
| 136 |
+
placed.append(w)
|
| 137 |
+
used.update(cells)
|
| 138 |
+
used_texts.add(cand_text)
|
| 139 |
+
try:
|
| 140 |
+
pool.remove(cand_text)
|
| 141 |
+
except ValueError:
|
| 142 |
+
pass
|
| 143 |
+
placed_ok = True
|
| 144 |
+
break
|
| 145 |
+
|
| 146 |
+
if placed_ok:
|
| 147 |
+
break
|
| 148 |
+
|
| 149 |
+
if not placed_ok:
|
| 150 |
+
# Hard reset and retry whole generation if we hit a wall
|
| 151 |
+
if attempts >= max_attempts:
|
| 152 |
+
raise RuntimeError("Puzzle generation failed: max attempts reached")
|
| 153 |
+
return generate_puzzle(
|
| 154 |
+
grid_size=grid_size,
|
| 155 |
+
words_by_len=words_by_len,
|
| 156 |
+
seed=rng.randrange(1 << 30),
|
| 157 |
+
max_attempts=max_attempts,
|
| 158 |
+
spacer=spacer,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
puzzle = Puzzle(words=placed, spacer=spacer, may_overlap=may_overlap)
|
| 162 |
+
try:
|
| 163 |
+
validate_puzzle(puzzle, grid_size=grid_size)
|
| 164 |
+
except AssertionError:
|
| 165 |
+
# Deterministic retry on validation failure
|
| 166 |
+
|
| 167 |
+
# Regenerate on validation failure (e.g., spacer rule violation)
|
| 168 |
+
return generate_puzzle(
|
| 169 |
+
grid_size=grid_size,
|
| 170 |
+
words_by_len=words_by_len,
|
| 171 |
+
seed=seed,
|
| 172 |
+
max_attempts=max_attempts,
|
| 173 |
+
spacer=spacer,
|
| 174 |
+
puzzle_id=puzzle_id,
|
| 175 |
+
_retry=_retry + 1,
|
| 176 |
+
)
|
| 177 |
+
return puzzle
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
|
| 181 |
+
# Bounds and overlap checks
|
| 182 |
+
seen: set[Coord] = set()
|
| 183 |
+
counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
|
| 184 |
+
for w in puzzle.words:
|
| 185 |
+
if len(w.text) not in (4, 5, 6):
|
| 186 |
+
raise AssertionError("Word length invalid")
|
| 187 |
+
counts[len(w.text)] += 1
|
| 188 |
+
for c in w.cells:
|
| 189 |
+
if not c.in_bounds(grid_size):
|
| 190 |
+
raise AssertionError("Cell out of bounds")
|
| 191 |
+
if c in seen:
|
| 192 |
+
raise AssertionError("Overlapping words detected")
|
| 193 |
+
seen.add(c)
|
| 194 |
+
# Last cell must match radar pulse for that word
|
| 195 |
+
if w.last_cell not in puzzle.radar:
|
| 196 |
+
raise AssertionError("Radar pulse missing for last cell")
|
| 197 |
+
|
| 198 |
+
if counts[4] != 2 or counts[5] != 2 or counts[6] != 2:
|
| 199 |
+
raise AssertionError("Incorrect counts of word lengths")
|
| 200 |
+
|
| 201 |
+
# Enforce spacer rule (supports 0–2). Default spacer is 1 (from models.Puzzle).
|
| 202 |
+
spacer_val = getattr(puzzle, "spacer", 1)
|
| 203 |
+
if spacer_val in (1, 2):
|
| 204 |
+
word_cells = [set(w.cells) for w in puzzle.words]
|
| 205 |
+
for i in range(len(word_cells)):
|
| 206 |
+
for j in range(i + 1, len(word_cells)):
|
| 207 |
+
for c1 in word_cells[i]:
|
| 208 |
+
for c2 in word_cells[j]:
|
| 209 |
+
if _chebyshev_distance(c1, c2) <= spacer_val:
|
| 210 |
+
raise AssertionError(f"Spacing violation (spacer={spacer_val}): {c1} vs {c2}")
|
| 211 |
+
|
| 212 |
+
def sort_word_file(filepath: str) -> List[str]:
|
| 213 |
+
"""
|
| 214 |
+
Reads a word list file, skips header/comment lines, and returns words sorted
|
| 215 |
+
by length (ascending), then alphabetically within each length group.
|
| 216 |
+
"""
|
| 217 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 218 |
+
lines = f.readlines()
|
| 219 |
+
# Skip header/comment lines
|
| 220 |
+
words = [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
|
| 221 |
+
# Sort by length, then alphabetically
|
| 222 |
+
sorted_words = sorted(words, key=lambda w: (len(w), w))
|
| 223 |
+
return sorted_words
|
wrdler/local_storage.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: wrdler/local_storage.py
|
| 2 |
+
"""
|
| 3 |
+
Storage module for Wrdler game.
|
| 4 |
+
|
| 5 |
+
Provides functionality for:
|
| 6 |
+
1. Saving/loading game results to local JSON files
|
| 7 |
+
2. Managing high scores and leaderboards
|
| 8 |
+
3. Sharing game IDs via query strings
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
from dataclasses import dataclass, field, asdict
|
| 13 |
+
from typing import List, Dict, Optional, Any
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class GameResult:
|
| 21 |
+
game_id: str
|
| 22 |
+
wordlist: str
|
| 23 |
+
game_mode: str
|
| 24 |
+
score: int
|
| 25 |
+
tier: str
|
| 26 |
+
elapsed_seconds: int
|
| 27 |
+
words_found: List[str]
|
| 28 |
+
completed_at: str
|
| 29 |
+
player_name: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 32 |
+
return asdict(self)
|
| 33 |
+
|
| 34 |
+
@classmethod
|
| 35 |
+
def from_dict(cls, data: Dict[str, Any]) -> "GameResult":
|
| 36 |
+
return cls(**data)
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class HighScoreEntry:
|
| 40 |
+
player_name: str
|
| 41 |
+
score: int
|
| 42 |
+
tier: str
|
| 43 |
+
wordlist: str
|
| 44 |
+
game_mode: str
|
| 45 |
+
elapsed_seconds: int
|
| 46 |
+
completed_at: str
|
| 47 |
+
game_id: str
|
| 48 |
+
|
| 49 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 50 |
+
return asdict(self)
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_dict(cls, data: Dict[str, Any]) -> "HighScoreEntry":
|
| 54 |
+
return cls(**data)
|
| 55 |
+
|
| 56 |
+
class GameStorage:
|
| 57 |
+
def __init__(self, storage_dir: Optional[str] = None):
|
| 58 |
+
if storage_dir is None:
|
| 59 |
+
storage_dir = os.path.join(
|
| 60 |
+
os.path.expanduser("~"),
|
| 61 |
+
".wrdler",
|
| 62 |
+
"data"
|
| 63 |
+
)
|
| 64 |
+
self.storage_dir = Path(storage_dir)
|
| 65 |
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
self.results_file = self.storage_dir / "game_results.json"
|
| 67 |
+
self.highscores_file = self.storage_dir / "highscores.json"
|
| 68 |
+
|
| 69 |
+
def save_result(self, result: GameResult) -> bool:
|
| 70 |
+
try:
|
| 71 |
+
results = self.load_all_results()
|
| 72 |
+
results.append(result.to_dict())
|
| 73 |
+
with open(self.results_file, 'w', encoding='utf-8') as f:
|
| 74 |
+
json.dump(results, f, indent=2, ensure_ascii=False)
|
| 75 |
+
self._update_highscores(result)
|
| 76 |
+
return True
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"Error saving result: {e}")
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
def load_all_results(self) -> List[Dict[str, Any]]:
|
| 82 |
+
if not self.results_file.exists():
|
| 83 |
+
return []
|
| 84 |
+
try:
|
| 85 |
+
with open(self.results_file, 'r', encoding='utf-8') as f:
|
| 86 |
+
return json.load(f)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"Error loading results: {e}")
|
| 89 |
+
return []
|
| 90 |
+
|
| 91 |
+
def get_results_by_game_id(self, game_id: str) -> List[GameResult]:
|
| 92 |
+
all_results = self.load_all_results()
|
| 93 |
+
matching = [
|
| 94 |
+
GameResult.from_dict(r)
|
| 95 |
+
for r in all_results
|
| 96 |
+
if r.get("game_id") == game_id
|
| 97 |
+
]
|
| 98 |
+
return sorted(matching, key=lambda x: x.score, reverse=True)
|
| 99 |
+
|
| 100 |
+
def _update_highscores(self, result: GameResult) -> None:
|
| 101 |
+
highscores = self.load_highscores()
|
| 102 |
+
entry = HighScoreEntry(
|
| 103 |
+
player_name=result.player_name or "Anonymous",
|
| 104 |
+
score=result.score,
|
| 105 |
+
tier=result.tier,
|
| 106 |
+
wordlist=result.wordlist,
|
| 107 |
+
game_mode=result.game_mode,
|
| 108 |
+
elapsed_seconds=result.elapsed_seconds,
|
| 109 |
+
completed_at=result.completed_at,
|
| 110 |
+
game_id=result.game_id
|
| 111 |
+
)
|
| 112 |
+
highscores.append(entry.to_dict())
|
| 113 |
+
highscores.sort(key=lambda x: x["score"], reverse=True)
|
| 114 |
+
highscores = highscores[:100]
|
| 115 |
+
with open(self.highscores_file, 'w', encoding='utf-8') as f:
|
| 116 |
+
json.dump(highscores, f, indent=2, ensure_ascii=False)
|
| 117 |
+
|
| 118 |
+
def load_highscores(
|
| 119 |
+
self,
|
| 120 |
+
wordlist: Optional[str] = None,
|
| 121 |
+
game_mode: Optional[str] = None,
|
| 122 |
+
limit: int = 10
|
| 123 |
+
) -> List[HighScoreEntry]:
|
| 124 |
+
if not self.highscores_file.exists():
|
| 125 |
+
return []
|
| 126 |
+
try:
|
| 127 |
+
with open(self.highscores_file, 'r', encoding='utf-8') as f:
|
| 128 |
+
scores = json.load(f)
|
| 129 |
+
if wordlist:
|
| 130 |
+
scores = [s for s in scores if s.get("wordlist") == wordlist]
|
| 131 |
+
if game_mode:
|
| 132 |
+
scores = [s for s in scores if s.get("game_mode") == game_mode]
|
| 133 |
+
scores.sort(key=lambda x: x["score"], reverse=True)
|
| 134 |
+
return [HighScoreEntry.from_dict(s) for s in scores[:limit]]
|
| 135 |
+
except Exception as e:
|
| 136 |
+
print(f"Error loading highscores: {e}")
|
| 137 |
+
return []
|
| 138 |
+
|
| 139 |
+
def get_player_stats(self, player_name: str) -> Dict[str, Any]:
|
| 140 |
+
all_results = self.load_all_results()
|
| 141 |
+
player_results = [
|
| 142 |
+
GameResult.from_dict(r)
|
| 143 |
+
for r in all_results
|
| 144 |
+
if r.get("player_name") == player_name
|
| 145 |
+
]
|
| 146 |
+
if not player_results:
|
| 147 |
+
return {
|
| 148 |
+
"games_played": 0,
|
| 149 |
+
"total_score": 0,
|
| 150 |
+
"average_score": 0,
|
| 151 |
+
"best_score": 0,
|
| 152 |
+
"best_tier": None
|
| 153 |
+
}
|
| 154 |
+
scores = [r.score for r in player_results]
|
| 155 |
+
return {
|
| 156 |
+
"games_played": len(player_results),
|
| 157 |
+
"total_score": sum(scores),
|
| 158 |
+
"average_score": sum(scores) / len(scores),
|
| 159 |
+
"best_score": max(scores),
|
| 160 |
+
"best_tier": max(player_results, key=lambda x: x.score).tier,
|
| 161 |
+
"fastest_time": min(r.elapsed_seconds for r in player_results)
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
def save_json_to_file(data: dict, directory: str, filename: str = "settings.json") -> str:
|
| 165 |
+
"""
|
| 166 |
+
Save a dictionary as a JSON file with a specified filename in the given directory.
|
| 167 |
+
Returns the full path to the saved file.
|
| 168 |
+
"""
|
| 169 |
+
os.makedirs(directory, exist_ok=True)
|
| 170 |
+
file_path = os.path.join(directory, filename)
|
| 171 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 172 |
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 173 |
+
return file_path
|
| 174 |
+
|
| 175 |
+
def generate_game_id_from_words(words: List[str]) -> str:
|
| 176 |
+
import hashlib
|
| 177 |
+
sorted_words = sorted([w.upper() for w in words])
|
| 178 |
+
word_string = "".join(sorted_words)
|
| 179 |
+
hash_obj = hashlib.sha256(word_string.encode('utf-8'))
|
| 180 |
+
return hash_obj.hexdigest()[:8].upper()
|
| 181 |
+
|
| 182 |
+
def parse_game_id_from_url() -> Optional[str]:
|
| 183 |
+
try:
|
| 184 |
+
import streamlit as st
|
| 185 |
+
params = st.query_params
|
| 186 |
+
return params.get("game_id")
|
| 187 |
+
except Exception:
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
def create_shareable_url(game_id: str, base_url: Optional[str] = None) -> str:
|
| 191 |
+
if base_url is None:
|
| 192 |
+
base_url = "https://huggingface.co/spaces/Surn/Wrdler"
|
| 193 |
+
return f"{base_url}?game_id={game_id}"
|
wrdler/logic.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Tuple
|
| 4 |
+
|
| 5 |
+
from .models import Coord, Puzzle, GameState, Word
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _chebyshev_distance(a: Coord, b: Coord) -> int:
|
| 9 |
+
"""8-neighborhood distance (max norm)."""
|
| 10 |
+
return max(abs(a.x - b.x), abs(a.y - b.y))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def build_letter_map(puzzle: Puzzle, skip_validation: bool = True) -> Dict[Coord, str]:
|
| 14 |
+
"""Build a coordinate->letter map for the given puzzle.
|
| 15 |
+
|
| 16 |
+
Spacer support (0–2):
|
| 17 |
+
- spacer = 0: words may touch (no separation enforced).
|
| 18 |
+
- spacer = 1: words must be separated by at least 1 blank tile
|
| 19 |
+
(no two letters from different words at Chebyshev distance <= 1).
|
| 20 |
+
- spacer = 2: at least 2 blank tiles between words
|
| 21 |
+
(no two letters from different words at Chebyshev distance <= 2).
|
| 22 |
+
|
| 23 |
+
Overlaps are not handled here (negative spacer not supported in this function).
|
| 24 |
+
This function raises ValueError if the configured spacing is violated.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
puzzle: The puzzle to build the letter map for
|
| 28 |
+
skip_validation: If True, skip spacer validation (default: True).
|
| 29 |
+
Validation is expensive and only needed during puzzle
|
| 30 |
+
generation, not during gameplay.
|
| 31 |
+
"""
|
| 32 |
+
mapping: Dict[Coord, str] = {}
|
| 33 |
+
|
| 34 |
+
spacer = getattr(puzzle, "spacer", 1)
|
| 35 |
+
|
| 36 |
+
# Build mapping normally (no overlap merging beyond first-come-wins semantics)
|
| 37 |
+
for w in puzzle.words:
|
| 38 |
+
for idx, c in enumerate(w.cells):
|
| 39 |
+
ch = w.text[idx]
|
| 40 |
+
if c not in mapping:
|
| 41 |
+
mapping[c] = ch
|
| 42 |
+
else:
|
| 43 |
+
# If an explicit overlap occurs, we don't support it here.
|
| 44 |
+
# Keep the first-seen letter and continue.
|
| 45 |
+
pass
|
| 46 |
+
|
| 47 |
+
# Enforce spacer in the range 0–2 (only when validation is requested)
|
| 48 |
+
if not skip_validation and spacer in (1, 2):
|
| 49 |
+
# Prepare sets of cells per word
|
| 50 |
+
word_cells = [set(w.cells) for w in puzzle.words]
|
| 51 |
+
for i in range(len(word_cells)):
|
| 52 |
+
for j in range(i + 1, len(word_cells)):
|
| 53 |
+
cells_i = word_cells[i]
|
| 54 |
+
cells_j = word_cells[j]
|
| 55 |
+
# If any pair is too close, it's a violation
|
| 56 |
+
for c1 in cells_i:
|
| 57 |
+
# Early exit by scanning a small neighborhood around c1
|
| 58 |
+
# since Chebyshev distance <= spacer
|
| 59 |
+
for c2 in cells_j:
|
| 60 |
+
if _chebyshev_distance(c1, c2) <= spacer:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"Words too close (spacer={spacer}): {c1} and {c2}"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# spacer == 0 -> no checks; other values are ignored here intentionally
|
| 66 |
+
|
| 67 |
+
return mapping
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def reveal_cell(state: GameState, letter_map: Dict[Coord, str], coord: Coord) -> None:
|
| 71 |
+
if coord in state.revealed:
|
| 72 |
+
state.last_action = "Already revealed."
|
| 73 |
+
return
|
| 74 |
+
state.revealed.add(coord)
|
| 75 |
+
# Determine if this reveal uncovered a letter or an empty cell
|
| 76 |
+
ch = letter_map.get(coord, "·")
|
| 77 |
+
# Only allow guessing if a letter was revealed; preserve existing True (e.g., after a correct guess)
|
| 78 |
+
state.can_guess = state.can_guess or (ch != "·")
|
| 79 |
+
if ch == "·":
|
| 80 |
+
state.last_action = f"Revealed empty at ({coord.x+1},{coord.y+1})."
|
| 81 |
+
else:
|
| 82 |
+
state.last_action = f"Revealed '{ch}' at ({coord.x+1},{coord.y+1})."
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def guess_word(state: GameState, guess_text: str) -> Tuple[bool, int]:
|
| 86 |
+
if not state.can_guess:
|
| 87 |
+
state.last_action = "You must reveal a cell before guessing."
|
| 88 |
+
return False, 0
|
| 89 |
+
guess = (guess_text or "").strip().upper()
|
| 90 |
+
if not (len(guess) in (4, 5, 6) and guess.isalpha()):
|
| 91 |
+
state.last_action = "Guess must be A–Z and length 4, 5, or 6."
|
| 92 |
+
state.can_guess = False
|
| 93 |
+
return False, 0
|
| 94 |
+
if guess in state.guessed:
|
| 95 |
+
state.last_action = f"Already guessed {guess}."
|
| 96 |
+
state.can_guess = False
|
| 97 |
+
return False, 0
|
| 98 |
+
|
| 99 |
+
# Find matching unguessed word
|
| 100 |
+
target: Word | None = None
|
| 101 |
+
for w in state.puzzle.words:
|
| 102 |
+
if w.text == guess and w.text not in state.guessed:
|
| 103 |
+
target = w
|
| 104 |
+
break
|
| 105 |
+
|
| 106 |
+
if target is None:
|
| 107 |
+
state.last_action = f"Try Again! '{guess}' is not in the puzzle."
|
| 108 |
+
state.can_guess = False
|
| 109 |
+
return False, 0
|
| 110 |
+
|
| 111 |
+
# Scoring: base = length, bonus = unrevealed cells in that word
|
| 112 |
+
unrevealed = sum(1 for c in target.cells if c not in state.revealed)
|
| 113 |
+
points = target.length + unrevealed
|
| 114 |
+
state.score += points
|
| 115 |
+
state.points_by_word[target.text] = points
|
| 116 |
+
|
| 117 |
+
# Reveal all cells of the word
|
| 118 |
+
for c in target.cells:
|
| 119 |
+
state.revealed.add(c)
|
| 120 |
+
state.guessed.add(target.text)
|
| 121 |
+
|
| 122 |
+
state.last_action = f"Correct! +{points} points for {target.text}."
|
| 123 |
+
if state.game_mode == "classic":
|
| 124 |
+
state.can_guess = True # <-- Allow another guess after a correct guess
|
| 125 |
+
else:
|
| 126 |
+
state.can_guess = False
|
| 127 |
+
return True, points
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def is_game_over(state: GameState) -> bool:
|
| 131 |
+
# Game ends if all words are guessed
|
| 132 |
+
if len(state.guessed) == 6:
|
| 133 |
+
return True
|
| 134 |
+
# Game ends if all word cells are revealed
|
| 135 |
+
# Use pre-computed _all_word_cells set from puzzle for performance
|
| 136 |
+
if state.puzzle._all_word_cells.issubset(state.revealed):
|
| 137 |
+
return True
|
| 138 |
+
return False
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def compute_tier(score: int) -> str:
|
| 142 |
+
if score >= 42:
|
| 143 |
+
return "Fantastic"
|
| 144 |
+
if 38 <= score <= 41:
|
| 145 |
+
return "Great"
|
| 146 |
+
if 34 <= score <= 37:
|
| 147 |
+
return "Good"
|
| 148 |
+
return "Keep practicing"
|
| 149 |
+
|
| 150 |
+
def hidden_word_display(letters_display: int, character: str = "?") -> str:
|
| 151 |
+
"""Return a string of characters of length letters_display."""
|
| 152 |
+
return character * letters_display
|
| 153 |
+
|
| 154 |
+
def auto_mark_completed_words(state: GameState) -> bool:
|
| 155 |
+
"""Automatically mark words as found when all their letters are revealed.
|
| 156 |
+
|
| 157 |
+
Returns True if any word state changed (e.g., guessed/score/points).
|
| 158 |
+
Scoring in this case is base length only (no unrevealed bonus).
|
| 159 |
+
"""
|
| 160 |
+
changed = False
|
| 161 |
+
for w in state.puzzle.words:
|
| 162 |
+
if w.text in state.guessed:
|
| 163 |
+
continue
|
| 164 |
+
if all(c in state.revealed for c in w.cells):
|
| 165 |
+
# Award base points if not already assigned
|
| 166 |
+
if w.text not in state.points_by_word:
|
| 167 |
+
base_points = w.length
|
| 168 |
+
state.points_by_word[w.text] = base_points
|
| 169 |
+
state.score += base_points
|
| 170 |
+
state.guessed.add(w.text)
|
| 171 |
+
changed = True
|
| 172 |
+
if changed:
|
| 173 |
+
# Do not alter can_guess; just note the auto-complete
|
| 174 |
+
state.last_action = (state.last_action or "") + "\nAuto-complete: revealed word(s) marked as found."
|
| 175 |
+
return changed
|
wrdler/models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Literal, List, Set, Dict, Optional
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
Direction = Literal["H", "V"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True, order=True)
|
| 13 |
+
class Coord:
|
| 14 |
+
x: int # row, 0-based
|
| 15 |
+
y: int # col, 0-based
|
| 16 |
+
|
| 17 |
+
def in_bounds(self, size: int) -> bool:
|
| 18 |
+
return 0 <= self.x < size and 0 <= self.y < size
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class Word:
|
| 23 |
+
text: str
|
| 24 |
+
start: Coord
|
| 25 |
+
direction: Direction
|
| 26 |
+
cells: List[Coord] = field(default_factory=list)
|
| 27 |
+
|
| 28 |
+
def __post_init__(self):
|
| 29 |
+
self.text = self.text.upper()
|
| 30 |
+
if self.direction not in ("H", "V"):
|
| 31 |
+
raise ValueError("direction must be 'H' or 'V'")
|
| 32 |
+
if not self.text.isalpha():
|
| 33 |
+
raise ValueError("word must be alphabetic A–Z only")
|
| 34 |
+
# compute cells based on start and direction
|
| 35 |
+
length = len(self.text)
|
| 36 |
+
cells: List[Coord] = []
|
| 37 |
+
for i in range(length):
|
| 38 |
+
if self.direction == "H":
|
| 39 |
+
cells.append(Coord(self.start.x, self.start.y + i))
|
| 40 |
+
else:
|
| 41 |
+
cells.append(Coord(self.start.x + i, self.start.y))
|
| 42 |
+
object.__setattr__(self, "cells", cells)
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def length(self) -> int:
|
| 46 |
+
return len(self.text)
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def last_cell(self) -> Coord:
|
| 50 |
+
return self.cells[-1]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class Puzzle:
|
| 55 |
+
"""Puzzle configuration and metadata.
|
| 56 |
+
Fields
|
| 57 |
+
- words: The list of placed words.
|
| 58 |
+
- radar: Points used to render the UI radar (defaults to each word's last cell).
|
| 59 |
+
- may_overlap: If True, words may overlap on shared letters (e.g., a crossword-style junction).
|
| 60 |
+
- spacer: (2 to -3) Controls proximity and overlap rules between distinct words:
|
| 61 |
+
* spacer = 0 -> words may be directly adjacent (touching next to each other).
|
| 62 |
+
* spacer = 1 -> at least 1 blank cell must separate words (no immediate adjacency).
|
| 63 |
+
* spacer > 1 -> enforce that many blank cells of separation.
|
| 64 |
+
* spacer < 0 -> allow overlaps on a common letter; abs(spacer) is the maximum
|
| 65 |
+
number of trailing letters each word may extend beyond the
|
| 66 |
+
shared letter (e.g., -3 allows up to 3 letters past the overlap).
|
| 67 |
+
|
| 68 |
+
Note: These are configuration hints for the generator/logic. Enforcement is not implemented here.
|
| 69 |
+
"""
|
| 70 |
+
words: List[Word]
|
| 71 |
+
radar: List[Coord] = field(default_factory=list)
|
| 72 |
+
may_overlap: bool = False
|
| 73 |
+
spacer: int = 1
|
| 74 |
+
# Unique identifier for this puzzle instance (used for deterministic regen and per-session assets)
|
| 75 |
+
uid: str = field(default_factory=lambda: uuid.uuid4().hex)
|
| 76 |
+
# Cached set of all word cells (computed once, used by is_game_over check)
|
| 77 |
+
_all_word_cells: Set[Coord] = field(default_factory=set, repr=False, init=False)
|
| 78 |
+
|
| 79 |
+
def __post_init__(self):
|
| 80 |
+
pulses = [w.last_cell for w in self.words]
|
| 81 |
+
self.radar = pulses
|
| 82 |
+
|
| 83 |
+
# Pre-compute all word cells once for faster is_game_over() checks
|
| 84 |
+
all_cells: Set[Coord] = set()
|
| 85 |
+
for w in self.words:
|
| 86 |
+
all_cells.update(w.cells)
|
| 87 |
+
object.__setattr__(self, '_all_word_cells', all_cells)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@dataclass
|
| 91 |
+
class GameState:
|
| 92 |
+
grid_size: int
|
| 93 |
+
puzzle: Puzzle
|
| 94 |
+
revealed: Set[Coord]
|
| 95 |
+
guessed: Set[str]
|
| 96 |
+
score: int
|
| 97 |
+
last_action: str
|
| 98 |
+
can_guess: bool
|
| 99 |
+
game_mode: Literal["classic", "too easy"] = "classic"
|
| 100 |
+
points_by_word: Dict[str, int] = field(default_factory=dict)
|
| 101 |
+
start_time: Optional[datetime] = None
|
| 102 |
+
end_time: Optional[datetime] = None
|
wrdler/modules/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# wrdler/modules/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Shared utility modules for Wrdler.
|
| 4 |
+
|
| 5 |
+
These modules are imported from the OpenBadge project and provide
|
| 6 |
+
reusable functionality for storage, constants, and file utilities.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .storage import (
|
| 10 |
+
upload_files_to_repo,
|
| 11 |
+
gen_full_url,
|
| 12 |
+
generate_permalink,
|
| 13 |
+
generate_permalink_from_urls,
|
| 14 |
+
store_issuer_keypair,
|
| 15 |
+
get_issuer_keypair,
|
| 16 |
+
get_verification_methods_registry,
|
| 17 |
+
list_issuer_ids
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
from .constants import (
|
| 21 |
+
HF_API_TOKEN,
|
| 22 |
+
HF_REPO_ID,
|
| 23 |
+
SHORTENER_JSON_FILE,
|
| 24 |
+
SPACE_NAME,
|
| 25 |
+
TMPDIR,
|
| 26 |
+
upload_file_types,
|
| 27 |
+
model_extensions,
|
| 28 |
+
image_extensions,
|
| 29 |
+
audio_extensions,
|
| 30 |
+
video_extensions,
|
| 31 |
+
doc_extensions
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
from .file_utils import (
|
| 35 |
+
get_file_parts,
|
| 36 |
+
rename_file_to_lowercase_extension,
|
| 37 |
+
get_filename,
|
| 38 |
+
convert_title_to_filename,
|
| 39 |
+
get_filename_from_filepath,
|
| 40 |
+
delete_file,
|
| 41 |
+
get_unique_file_path,
|
| 42 |
+
download_and_save_image,
|
| 43 |
+
download_and_save_file
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
__all__ = [
|
| 47 |
+
# storage.py
|
| 48 |
+
'upload_files_to_repo',
|
| 49 |
+
'gen_full_url',
|
| 50 |
+
'generate_permalink',
|
| 51 |
+
'generate_permalink_from_urls',
|
| 52 |
+
'store_issuer_keypair',
|
| 53 |
+
'get_issuer_keypair',
|
| 54 |
+
'get_verification_methods_registry',
|
| 55 |
+
'list_issuer_ids',
|
| 56 |
+
|
| 57 |
+
# constants.py
|
| 58 |
+
'HF_API_TOKEN',
|
| 59 |
+
'HF_REPO_ID',
|
| 60 |
+
'SHORTENER_JSON_FILE',
|
| 61 |
+
'SPACE_NAME',
|
| 62 |
+
'TMPDIR',
|
| 63 |
+
'upload_file_types',
|
| 64 |
+
'model_extensions',
|
| 65 |
+
'image_extensions',
|
| 66 |
+
'audio_extensions',
|
| 67 |
+
'video_extensions',
|
| 68 |
+
'doc_extensions',
|
| 69 |
+
|
| 70 |
+
# file_utils.py
|
| 71 |
+
'get_file_parts',
|
| 72 |
+
'rename_file_to_lowercase_extension',
|
| 73 |
+
'get_filename',
|
| 74 |
+
'convert_title_to_filename',
|
| 75 |
+
'get_filename_from_filepath',
|
| 76 |
+
'delete_file',
|
| 77 |
+
'get_unique_file_path',
|
| 78 |
+
'download_and_save_image',
|
| 79 |
+
'download_and_save_file'
|
| 80 |
+
]
|
wrdler/modules/constants.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# battlewords/modules/constants.py
|
| 2 |
+
"""
|
| 3 |
+
Storage-related constants for BattleWords.
|
| 4 |
+
Trimmed version of OpenBadge constants - only includes what's needed for storage.py
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
# Load environment variables from .env file
|
| 13 |
+
dotenv_path = Path(__file__).parent.parent.parent / '.env'
|
| 14 |
+
load_dotenv(dotenv_path)
|
| 15 |
+
|
| 16 |
+
# Hugging Face Configuration
|
| 17 |
+
HF_API_TOKEN = os.getenv("HF_TOKEN", os.getenv("HF_API_TOKEN", None))
|
| 18 |
+
CRYPTO_PK = os.getenv("CRYPTO_PK", None)
|
| 19 |
+
|
| 20 |
+
# Repository Configuration
|
| 21 |
+
HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
|
| 22 |
+
SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
|
| 23 |
+
SHORTENER_JSON_FILE = "shortener.json"
|
| 24 |
+
|
| 25 |
+
# Temporary Directory Configuration
|
| 26 |
+
try:
|
| 27 |
+
if os.environ.get('TMPDIR'):
|
| 28 |
+
TMPDIR = os.environ['TMPDIR']
|
| 29 |
+
else:
|
| 30 |
+
TMPDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
|
| 31 |
+
except:
|
| 32 |
+
TMPDIR = tempfile.gettempdir()
|
| 33 |
+
|
| 34 |
+
os.makedirs(TMPDIR, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
# File Extension Sets (for storage.py compatibility)
|
| 37 |
+
model_extensions = {".glb", ".gltf", ".obj", ".ply"}
|
| 38 |
+
model_extensions_list = list(model_extensions)
|
| 39 |
+
|
| 40 |
+
image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
|
| 41 |
+
image_extensions_list = list(image_extensions)
|
| 42 |
+
|
| 43 |
+
audio_extensions = {".mp3", ".wav", ".ogg", ".flac"}
|
| 44 |
+
audio_extensions_list = list(audio_extensions)
|
| 45 |
+
|
| 46 |
+
video_extensions = {".mp4"}
|
| 47 |
+
video_extensions_list = list(video_extensions)
|
| 48 |
+
|
| 49 |
+
doc_extensions = {".json"}
|
| 50 |
+
doc_extensions_list = list(doc_extensions)
|
| 51 |
+
|
| 52 |
+
upload_file_types = (
|
| 53 |
+
model_extensions_list +
|
| 54 |
+
image_extensions_list +
|
| 55 |
+
audio_extensions_list +
|
| 56 |
+
video_extensions_list +
|
| 57 |
+
doc_extensions_list
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
wrdler/modules/file_utils.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file_utils
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import requests
|
| 6 |
+
from PIL import Image
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
def get_file_parts(file_path: str):
|
| 11 |
+
# Split the path into directory and filename
|
| 12 |
+
directory, filename = os.path.split(file_path)
|
| 13 |
+
|
| 14 |
+
# Split the filename into name and extension
|
| 15 |
+
name, ext = os.path.splitext(filename)
|
| 16 |
+
|
| 17 |
+
# Convert the extension to lowercase
|
| 18 |
+
new_ext = ext.lower()
|
| 19 |
+
return directory, filename, name, ext, new_ext
|
| 20 |
+
|
| 21 |
+
def rename_file_to_lowercase_extension(file_path: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Renames a file's extension to lowercase in place.
|
| 24 |
+
|
| 25 |
+
Parameters:
|
| 26 |
+
file_path (str): The original file path.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
str: The new file path with the lowercase extension.
|
| 30 |
+
|
| 31 |
+
Raises:
|
| 32 |
+
OSError: If there is an error renaming the file (e.g., file not found, permissions issue).
|
| 33 |
+
"""
|
| 34 |
+
directory, filename, name, ext, new_ext = get_file_parts(file_path)
|
| 35 |
+
# If the extension changes, rename the file
|
| 36 |
+
if ext != new_ext:
|
| 37 |
+
new_filename = name + new_ext
|
| 38 |
+
new_file_path = os.path.join(directory, new_filename)
|
| 39 |
+
try:
|
| 40 |
+
os.rename(file_path, new_file_path)
|
| 41 |
+
print(f"Rename {file_path} to {new_file_path}\n")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"os.rename failed: {e}. Falling back to binary copy operation.")
|
| 44 |
+
try:
|
| 45 |
+
# Read the file in binary mode and write it to new_file_path
|
| 46 |
+
with open(file_path, 'rb') as f:
|
| 47 |
+
data = f.read()
|
| 48 |
+
with open(new_file_path, 'wb') as f:
|
| 49 |
+
f.write(data)
|
| 50 |
+
print(f"Copied {file_path} to {new_file_path}\n")
|
| 51 |
+
# Optionally, remove the original file after copying
|
| 52 |
+
#os.remove(file_path)
|
| 53 |
+
except Exception as inner_e:
|
| 54 |
+
print(f"Failed to copy file from {file_path} to {new_file_path}: {inner_e}")
|
| 55 |
+
raise inner_e
|
| 56 |
+
return new_file_path
|
| 57 |
+
else:
|
| 58 |
+
return file_path
|
| 59 |
+
|
| 60 |
+
def get_filename(file):
|
| 61 |
+
# extract filename from file object
|
| 62 |
+
filename = None
|
| 63 |
+
if file is not None:
|
| 64 |
+
filename = file.name
|
| 65 |
+
return filename
|
| 66 |
+
|
| 67 |
+
def convert_title_to_filename(title):
|
| 68 |
+
# convert title to filename
|
| 69 |
+
filename = title.lower().replace(" ", "_").replace("/", "_")
|
| 70 |
+
return filename
|
| 71 |
+
|
| 72 |
+
def get_filename_from_filepath(filepath):
|
| 73 |
+
file_name = os.path.basename(filepath)
|
| 74 |
+
file_base, file_extension = os.path.splitext(file_name)
|
| 75 |
+
return file_base, file_extension
|
| 76 |
+
|
| 77 |
+
def delete_file(file_path: str) -> None:
|
| 78 |
+
"""
|
| 79 |
+
Deletes the specified file.
|
| 80 |
+
|
| 81 |
+
Parameters:
|
| 82 |
+
file_path (str): The path to thefile to delete.
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
FileNotFoundError: If the file does not exist.
|
| 86 |
+
Exception: If there is an error deleting the file.
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
path = Path(file_path)
|
| 90 |
+
path.unlink()
|
| 91 |
+
print(f"Deleted original file: {file_path}")
|
| 92 |
+
except FileNotFoundError:
|
| 93 |
+
print(f"File not found: {file_path}")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Error deleting file: {e}")
|
| 96 |
+
|
| 97 |
+
def get_unique_file_path(directory, filename, file_ext, counter=0):
|
| 98 |
+
"""
|
| 99 |
+
Recursively increments the filename until a unique path is found.
|
| 100 |
+
|
| 101 |
+
Parameters:
|
| 102 |
+
directory (str): The directory for the file.
|
| 103 |
+
filename (str): The base filename.
|
| 104 |
+
file_ext (str): The file extension including the leading dot.
|
| 105 |
+
counter (int): The current counter value to append.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
str: A unique file path that does not exist.
|
| 109 |
+
"""
|
| 110 |
+
if counter == 0:
|
| 111 |
+
filepath = os.path.join(directory, f"{filename}{file_ext}")
|
| 112 |
+
else:
|
| 113 |
+
filepath = os.path.join(directory, f"{filename}{counter}{file_ext}")
|
| 114 |
+
|
| 115 |
+
if not os.path.exists(filepath):
|
| 116 |
+
return filepath
|
| 117 |
+
else:
|
| 118 |
+
return get_unique_file_path(directory, filename, file_ext, counter + 1)
|
| 119 |
+
|
| 120 |
+
# Example usage:
|
| 121 |
+
# new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
|
| 122 |
+
|
| 123 |
+
def download_and_save_image(url: str, dst_folder: Path, token: str = None) -> Path:
|
| 124 |
+
"""
|
| 125 |
+
Downloads an image from a URL with authentication if a token is provided,
|
| 126 |
+
verifies it with PIL, and saves it in dst_folder with a unique filename.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
url (str): The image URL.
|
| 130 |
+
dst_folder (Path): The destination folder for the image.
|
| 131 |
+
token (str, optional): A valid Bearer token. If not provided, the HF_API_TOKEN
|
| 132 |
+
environment variable is used if available.
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Path: The saved image's file path.
|
| 136 |
+
"""
|
| 137 |
+
headers = {}
|
| 138 |
+
# Use provided token; otherwise, fall back to environment variable.
|
| 139 |
+
api_token = token
|
| 140 |
+
if api_token:
|
| 141 |
+
headers["Authorization"] = f"Bearer {api_token}"
|
| 142 |
+
|
| 143 |
+
response = requests.get(url, headers=headers)
|
| 144 |
+
response.raise_for_status()
|
| 145 |
+
pil_image = Image.open(BytesIO(response.content))
|
| 146 |
+
|
| 147 |
+
parsed_url = urlparse(url)
|
| 148 |
+
original_filename = os.path.basename(parsed_url.path) # e.g., "background.png"
|
| 149 |
+
base, ext = os.path.splitext(original_filename)
|
| 150 |
+
|
| 151 |
+
# Use get_unique_file_path from file_utils.py to generate a unique file path.
|
| 152 |
+
unique_filepath_str = get_unique_file_path(str(dst_folder), base, ext)
|
| 153 |
+
dst = Path(unique_filepath_str)
|
| 154 |
+
dst_folder.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
pil_image.save(dst)
|
| 156 |
+
return dst
|
| 157 |
+
|
| 158 |
+
def download_and_save_file(url: str, dst_folder: Path, token: str = None) -> Path:
|
| 159 |
+
"""
|
| 160 |
+
Downloads a binary file (e.g., audio or video) from a URL with authentication if a token is provided,
|
| 161 |
+
and saves it in dst_folder with a unique filename.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
url (str): The file URL.
|
| 165 |
+
dst_folder (Path): The destination folder for the file.
|
| 166 |
+
token (str, optional): A valid Bearer token.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Path: The saved file's path.
|
| 170 |
+
"""
|
| 171 |
+
headers = {}
|
| 172 |
+
if token:
|
| 173 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 174 |
+
|
| 175 |
+
response = requests.get(url, headers=headers)
|
| 176 |
+
response.raise_for_status()
|
| 177 |
+
|
| 178 |
+
parsed_url = urlparse(url)
|
| 179 |
+
original_filename = os.path.basename(parsed_url.path)
|
| 180 |
+
base, ext = os.path.splitext(original_filename)
|
| 181 |
+
|
| 182 |
+
unique_filepath_str = get_unique_file_path(str(dst_folder), base, ext)
|
| 183 |
+
dst = Path(unique_filepath_str)
|
| 184 |
+
dst_folder.mkdir(parents=True, exist_ok=True)
|
| 185 |
+
|
| 186 |
+
with open(dst, "wb") as f:
|
| 187 |
+
f.write(response.content)
|
| 188 |
+
|
| 189 |
+
return dst
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
# Example usage
|
| 194 |
+
url = "https://example.com/image.png"
|
| 195 |
+
dst_folder = Path("downloads")
|
| 196 |
+
download_and_save_image(url, dst_folder)
|
| 197 |
+
# Example usage for file download
|
| 198 |
+
file_url = "https://example.com/file.mp3"
|
| 199 |
+
downloaded_file = download_and_save_file(file_url, dst_folder)
|
| 200 |
+
print(f"File downloaded to: {downloaded_file}")
|
| 201 |
+
# Example usage for renaming file extension
|
| 202 |
+
file_path = "example.TXT"
|
| 203 |
+
new_file_path = rename_file_to_lowercase_extension(file_path)
|
| 204 |
+
print(f"Renamed file to: {new_file_path}")
|
wrdler/modules/storage.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Storage Module (`modules/storage.py`) Usage Guide
|
| 2 |
+
|
| 3 |
+
The `storage.py` module provides helper functions for:
|
| 4 |
+
- Generating permalinks for 3D viewer projects.
|
| 5 |
+
- Uploading files in batches to a Hugging Face repository.
|
| 6 |
+
- Managing URL shortening by storing (short URL, full URL) pairs in a JSON file on the repository.
|
| 7 |
+
- Retrieving full URLs from short URL IDs and vice versa.
|
| 8 |
+
- Handle specific file types for 3D models, images, video and audio.
|
| 9 |
+
- **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
|
| 10 |
+
|
| 11 |
+
## Key Functions
|
| 12 |
+
|
| 13 |
+
### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 14 |
+
- **Purpose:**
|
| 15 |
+
Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
|
| 16 |
+
- **Usage Example:**from modules.storage import generate_permalink
|
| 17 |
+
|
| 18 |
+
valid_files = [
|
| 19 |
+
"models/3d_model.glb",
|
| 20 |
+
"images/model_texture.png",
|
| 21 |
+
"images/model_depth.png"
|
| 22 |
+
]
|
| 23 |
+
base_url_external = "https://huggingface.co/datasets/Surn/Storage/resolve/main/saved_models/my_model"
|
| 24 |
+
permalink = generate_permalink(valid_files, base_url_external)
|
| 25 |
+
if permalink:
|
| 26 |
+
print("Permalink:", permalink)
|
| 27 |
+
### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 28 |
+
- **Purpose:**
|
| 29 |
+
Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
|
| 30 |
+
- **Usage Example:**from modules.storage import generate_permalink_from_urls
|
| 31 |
+
|
| 32 |
+
model_url = "https://example.com/model.glb"
|
| 33 |
+
hm_url = "https://example.com/heightmap.png"
|
| 34 |
+
img_url = "https://example.com/source.png"
|
| 35 |
+
|
| 36 |
+
permalink = generate_permalink_from_urls(model_url, hm_url, img_url)
|
| 37 |
+
print("Generated Permalink:", permalink)
|
| 38 |
+
### 3. `upload_files_to_repo(files, repo_id, folder_name, create_permalink=False, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 39 |
+
- **Purpose:**
|
| 40 |
+
Uploads a batch of files (each file represented as a path string) to a specified Hugging Face repository (e.g. `"Surn/Storage"`) under a given folder.
|
| 41 |
+
The function's return type is `Union[Dict[str, Any], List[Tuple[Any, str]]]`.
|
| 42 |
+
- When `create_permalink` is `True` and exactly three valid files (one model and two images) are provided, the function returns a dictionary:{
|
| 43 |
+
"response": <upload_folder_response>,
|
| 44 |
+
"permalink": "<full_permalink_url>",
|
| 45 |
+
"short_permalink": "<shortened_permalink_url_with_sid>"
|
| 46 |
+
} - Otherwise (or if `create_permalink` is `False` or conditions for permalink creation are not met), it returns a list of tuples, where each tuple is `(upload_folder_response, individual_file_link)`.
|
| 47 |
+
- If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
|
| 48 |
+
- **Usage Example:**
|
| 49 |
+
|
| 50 |
+
**a. Uploading with permalink creation:**from modules.storage import upload_files_to_repo
|
| 51 |
+
|
| 52 |
+
files_for_permalink = [
|
| 53 |
+
"local/path/to/model.glb",
|
| 54 |
+
"local/path/to/heightmap.png",
|
| 55 |
+
"local/path/to/image.png"
|
| 56 |
+
]
|
| 57 |
+
repo_id = "Surn/Storage" # Make sure this is defined, e.g., from constants or environment variables
|
| 58 |
+
folder_name = "my_new_model_with_permalink"
|
| 59 |
+
|
| 60 |
+
upload_result = upload_files_to_repo(
|
| 61 |
+
files_for_permalink,
|
| 62 |
+
repo_id,
|
| 63 |
+
folder_name,
|
| 64 |
+
create_permalink=True
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if isinstance(upload_result, dict):
|
| 68 |
+
print("Upload Response:", upload_result.get("response"))
|
| 69 |
+
print("Full Permalink:", upload_result.get("permalink"))
|
| 70 |
+
print("Short Permalink:", upload_result.get("short_permalink"))
|
| 71 |
+
elif upload_result: # Check if list is not empty
|
| 72 |
+
print("Upload Response for individual files:")
|
| 73 |
+
for res, link in upload_result:
|
| 74 |
+
print(f" Response: {res}, Link: {link}")
|
| 75 |
+
else:
|
| 76 |
+
print("No files uploaded or error occurred.")
|
| 77 |
+
**b. Uploading without permalink creation (or if conditions for permalink are not met):**from modules.storage import upload_files_to_repo
|
| 78 |
+
|
| 79 |
+
files_individual = [
|
| 80 |
+
"local/path/to/another_model.obj",
|
| 81 |
+
"local/path/to/texture.jpg"
|
| 82 |
+
]
|
| 83 |
+
repo_id = "Surn/Storage"
|
| 84 |
+
folder_name = "my_other_uploads"
|
| 85 |
+
|
| 86 |
+
upload_results_list = upload_files_to_repo(
|
| 87 |
+
files_individual,
|
| 88 |
+
repo_id,
|
| 89 |
+
folder_name,
|
| 90 |
+
create_permalink=False # Or if create_permalink=True but not 1 model & 2 images
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if upload_results_list: # Will be a list of tuples
|
| 94 |
+
print("Upload results for individual files:")
|
| 95 |
+
for res, link in upload_results_list:
|
| 96 |
+
print(f" Upload Response: {res}, File Link: {link}")
|
| 97 |
+
else:
|
| 98 |
+
print("No files uploaded or error occurred.")
|
| 99 |
+
### 4. URL Shortening Functions: `gen_full_url(...)` and Helpers
|
| 100 |
+
The module also enables URL shortening by managing a JSON file (e.g. `shortener.json`) in a Hugging Face repository. It supports CRUD-like operations:
|
| 101 |
+
- **Read:** Look up the full URL using a provided short URL ID.
|
| 102 |
+
- **Create:** Generate a new short URL ID for a full URL if no existing mapping exists.
|
| 103 |
+
- **Update/Conflict Handling:**
|
| 104 |
+
If both short URL ID and full URL are provided, it checks consistency and either confirms or reports a conflict.
|
| 105 |
+
|
| 106 |
+
#### `gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space", json_file="shortener.json")`
|
| 107 |
+
- **Purpose:**
|
| 108 |
+
Based on which parameter is provided, it retrieves or creates a mapping between a short URL ID and a full URL.
|
| 109 |
+
- If only `short_url` (the ID) is given, it returns the corresponding `full_url`.
|
| 110 |
+
- If only `full_url` is given, it looks up an existing `short_url` ID or generates and stores a new one.
|
| 111 |
+
- If both are given, it validates and returns the mapping or an error status.
|
| 112 |
+
- **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
|
| 113 |
+
- **Usage Examples:**
|
| 114 |
+
|
| 115 |
+
**a. Convert a full URL into a short URL ID:**from modules.storage import gen_full_url
|
| 116 |
+
from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 117 |
+
|
| 118 |
+
full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
|
| 119 |
+
|
| 120 |
+
status, short_id = gen_full_url(
|
| 121 |
+
full_url=full_permalink,
|
| 122 |
+
repo_id=HF_REPO_ID,
|
| 123 |
+
json_file=SHORTENER_JSON_FILE
|
| 124 |
+
)
|
| 125 |
+
print("Status:", status)
|
| 126 |
+
if status == "created_short" or status == "success_retrieved_short":
|
| 127 |
+
print("Shortened URL ID:", short_id)
|
| 128 |
+
# Construct the full short URL for sharing:
|
| 129 |
+
# permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
|
| 130 |
+
# shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 131 |
+
# print("Shareable Short URL:", shareable_short_url)
|
| 132 |
+
**b. Retrieve the full URL from a short URL ID:**from modules.storage import gen_full_url
|
| 133 |
+
from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 134 |
+
|
| 135 |
+
short_id_to_lookup = "aBcDeFg1" # Example short URL ID
|
| 136 |
+
|
| 137 |
+
status, retrieved_full_url = gen_full_url(
|
| 138 |
+
short_url=short_id_to_lookup,
|
| 139 |
+
repo_id=HF_REPO_ID,
|
| 140 |
+
json_file=SHORTENER_JSON_FILE
|
| 141 |
+
)
|
| 142 |
+
print("Status:", status)
|
| 143 |
+
if status == "success_retrieved_full":
|
| 144 |
+
print("Retrieved Full URL:", retrieved_full_url)
|
| 145 |
+
## 🔑 Cryptographic Key Management Functions
|
| 146 |
+
|
| 147 |
+
### 5. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
|
| 148 |
+
- **Purpose:**
|
| 149 |
+
Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
|
| 150 |
+
- **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
|
| 151 |
+
- **Storage Structure:**keys/issuers/{issuer_id}/
|
| 152 |
+
├── private_key.json (encrypted)
|
| 153 |
+
└── public_key.json- **Returns:** `bool` - True if keys were stored successfully, False otherwise.
|
| 154 |
+
- **Usage Example:**from modules.storage import store_issuer_keypair
|
| 155 |
+
|
| 156 |
+
# Example Ed25519 keys (multibase encoded)
|
| 157 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 158 |
+
public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
| 159 |
+
private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
|
| 160 |
+
|
| 161 |
+
success = store_issuer_keypair(issuer_id, public_key, private_key)
|
| 162 |
+
if success:
|
| 163 |
+
print("Keys stored successfully")
|
| 164 |
+
else:
|
| 165 |
+
print("Failed to store keys")
|
| 166 |
+
### 6. `get_issuer_keypair(issuer_id, repo_id=None)`
|
| 167 |
+
- **Purpose:**
|
| 168 |
+
Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
|
| 169 |
+
- **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
|
| 170 |
+
- **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
|
| 171 |
+
- **Usage Example:**from modules.storage import get_issuer_keypair
|
| 172 |
+
|
| 173 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 174 |
+
public_key, private_key = get_issuer_keypair(issuer_id)
|
| 175 |
+
|
| 176 |
+
if public_key and private_key:
|
| 177 |
+
print("Keys retrieved successfully")
|
| 178 |
+
print(f"Public key: {public_key}")
|
| 179 |
+
# Use private_key for signing operations
|
| 180 |
+
else:
|
| 181 |
+
print("Keys not found or error occurred")
|
| 182 |
+
### 7. `get_verification_methods_registry(repo_id=None)`
|
| 183 |
+
- **Purpose:**
|
| 184 |
+
Retrieve the global verification methods registry containing all registered issuer public keys.
|
| 185 |
+
- **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
|
| 186 |
+
- **Usage Example:**from modules.storage import get_verification_methods_registry
|
| 187 |
+
|
| 188 |
+
registry = get_verification_methods_registry()
|
| 189 |
+
methods = registry.get("verification_methods", [])
|
| 190 |
+
|
| 191 |
+
for method in methods:
|
| 192 |
+
print(f"Issuer: {method['issuer_id']}")
|
| 193 |
+
print(f"Public Key: {method['public_key']}")
|
| 194 |
+
print(f"Key Type: {method['key_type']}")
|
| 195 |
+
print("---")
|
| 196 |
+
### 8. `list_issuer_ids(repo_id=None)`
|
| 197 |
+
- **Purpose:**
|
| 198 |
+
List all issuer IDs that have stored keys in the repository.
|
| 199 |
+
- **Returns:** `List[str]` - List of issuer IDs.
|
| 200 |
+
- **Usage Example:**from modules.storage import list_issuer_ids
|
| 201 |
+
|
| 202 |
+
issuer_ids = list_issuer_ids()
|
| 203 |
+
print("Registered issuers:")
|
| 204 |
+
for issuer_id in issuer_ids:
|
| 205 |
+
print(f" - {issuer_id}")
|
| 206 |
+
## Notes
|
| 207 |
+
- **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `modules/constants.py`. Ensure this environment variable is correctly set.
|
| 208 |
+
- **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `modules/constants.py` for the URL shortening feature.
|
| 209 |
+
- **🔐 Private Repository Requirement:** Key management functions require a PRIVATE Hugging Face repository to ensure the security of stored encrypted private keys. Never use these functions with public repositories.
|
| 210 |
+
- **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
|
| 211 |
+
- **Repository Configuration:** When using URL shortening, file uploads, and key management, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
|
| 212 |
+
- **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
|
| 213 |
+
- **Key Encryption:** Private keys are encrypted using basic XOR encryption (demo implementation). In production environments, upgrade to proper encryption like Fernet from the cryptography library.
|
| 214 |
+
- **Error Handling:** Functions include basic error handling (e.g., catching `RepositoryNotFoundError`, `EntryNotFoundError`, JSON decoding errors, or upload issues) and print messages to the console for debugging. Review function return values to handle these cases appropriately in your application.
|
| 215 |
+
|
| 216 |
+
## 🔒 Security Considerations for Key Management
|
| 217 |
+
|
| 218 |
+
1. **Private Repository Only:** Always use private repositories for key storage to protect cryptographic material.
|
| 219 |
+
2. **Key Sanitization:** Issuer IDs are sanitized for file system compatibility (replacing special characters with underscores).
|
| 220 |
+
3. **Encryption:** Private keys are encrypted before storage. Upgrade to Fernet encryption in production.
|
| 221 |
+
4. **Access Control:** Implement proper authentication and authorization for key access.
|
| 222 |
+
5. **Key Rotation:** Consider implementing key rotation mechanisms for enhanced security.
|
| 223 |
+
6. **Audit Logging:** Monitor key access and usage patterns for security auditing.
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
This guide provides the essential usage examples for interacting with the storage, URL-shortening, and cryptographic key management functionality. You can integrate these examples into your application or use them as a reference when extending functionality.
|