Surn commited on
Commit
4f625d4
·
0 Parent(s):

Initial Commit v0.0.1

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +37 -0
  2. .gitignore +493 -0
  3. .streamlit/config.toml +10 -0
  4. CLAUDE.md +426 -0
  5. Dockerfile +61 -0
  6. LOCALHOST_PWA_README.md +269 -0
  7. MANIFEST.in +1 -0
  8. PWA_INSTALL_GUIDE.md +208 -0
  9. README.md +535 -0
  10. app.py +22 -0
  11. generate_pwa_icons.py +98 -0
  12. inject-pwa-head.sh +49 -0
  13. pwa-head-inject.html +8 -0
  14. pyproject.toml +25 -0
  15. requirements.txt +15 -0
  16. specs/requirements.md +164 -0
  17. specs/specs.md +195 -0
  18. specs/wrdler_implementation_plan.md +525 -0
  19. src/streamlit_app.py +40 -0
  20. static/icon-192.png +0 -0
  21. static/icon-512.png +0 -0
  22. static/manifest.json +27 -0
  23. static/service-worker.js +99 -0
  24. tests/test_apptest.py +7 -0
  25. tests/test_compare_difficulty_functions.py +237 -0
  26. tests/test_download_game_settings.py +63 -0
  27. tests/test_generator.py +29 -0
  28. tests/test_logic.py +55 -0
  29. uv.lock +626 -0
  30. wrdler/__init__.py +2 -0
  31. wrdler/assets/audio/effects/correct_guess.mp3 +3 -0
  32. wrdler/assets/audio/effects/hit.mp3 +3 -0
  33. wrdler/assets/audio/effects/incorrect_guess.mp3 +3 -0
  34. wrdler/assets/audio/effects/miss.mp3 +3 -0
  35. wrdler/assets/audio/music/background.mp3 +3 -0
  36. wrdler/assets/audio/music/congratulations.mp3 +3 -0
  37. wrdler/assets/scope.gif +0 -0
  38. wrdler/assets/scope_blue.gif +0 -0
  39. wrdler/assets/scope_blue.png +0 -0
  40. wrdler/audio.py +246 -0
  41. wrdler/game_storage.py +546 -0
  42. wrdler/generate_sounds.py +174 -0
  43. wrdler/generator.py +223 -0
  44. wrdler/local_storage.py +193 -0
  45. wrdler/logic.py +175 -0
  46. wrdler/models.py +102 -0
  47. wrdler/modules/__init__.py +80 -0
  48. wrdler/modules/constants.py +60 -0
  49. wrdler/modules/file_utils.py +204 -0
  50. 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.